Merge pull request #376 from netlify/entry-editor-ui
Move to static rich text editor control bar
This commit is contained in:
commit
754369d80e
@ -141,7 +141,6 @@
|
||||
CMS.registerEditorComponent({
|
||||
id: "youtube",
|
||||
label: "Youtube",
|
||||
icon: 'video',
|
||||
fields: [{name: 'id', label: 'Youtube Video ID'}],
|
||||
pattern: /^{{<\s?youtube (\S+)\s?>}}/,
|
||||
fromBlock: function(match) {
|
||||
|
@ -94,6 +94,7 @@
|
||||
"@kadira/storybook": "^1.36.0",
|
||||
"autoprefixer": "^6.3.3",
|
||||
"babel-plugin-transform-builtin-extend": "^1.1.0",
|
||||
"classnames": "^2.2.5",
|
||||
"dateformat": "^1.0.12",
|
||||
"deep-equal": "^1.0.1",
|
||||
"fuzzy": "^0.1.1",
|
||||
@ -146,6 +147,7 @@
|
||||
"react-simple-dnd": "^0.1.2",
|
||||
"react-sortable": "^1.2.0",
|
||||
"react-split-pane": "^0.1.57",
|
||||
"react-textarea-autosize-inputref": "^4.1.0",
|
||||
"react-toolbox": "^1.2.1",
|
||||
"react-topbar-progress-indicator": "^1.0.0",
|
||||
"react-waypoint": "^3.1.3",
|
||||
|
@ -7,9 +7,14 @@
|
||||
|
||||
.previewToggle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
top: 8px;
|
||||
right: 40px;
|
||||
z-index: 1000;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.previewToggleShow {
|
||||
right: 60px;
|
||||
}
|
||||
|
||||
.controlPane {
|
||||
@ -17,7 +22,7 @@
|
||||
overflow: auto;
|
||||
padding: 20px 20px 0;
|
||||
border-right: 1px solid var(--defaultColorLight);
|
||||
background-color: color(#f2f5f4 lightness(90%));
|
||||
background-color: var(--backgroundTertiaryColorDark);
|
||||
}
|
||||
|
||||
.previewPane {
|
||||
|
@ -2,11 +2,14 @@ import React, { Component, PropTypes } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import SplitPane from 'react-split-pane';
|
||||
import Button from 'react-toolbox/lib/button';
|
||||
import classnames from 'classnames';
|
||||
import { ScrollSync, ScrollSyncPane } from '../ScrollSync';
|
||||
import ControlPane from '../ControlPanel/ControlPane';
|
||||
import PreviewPane from '../PreviewPane/PreviewPane';
|
||||
import Toolbar from './EntryEditorToolbar';
|
||||
import { StickyContext } from '../UI/Sticky/Sticky';
|
||||
import styles from './EntryEditor.css';
|
||||
import stickyStyles from '../UI/Sticky/Sticky.css';
|
||||
|
||||
const PREVIEW_VISIBLE = 'cms.preview-visible';
|
||||
|
||||
@ -50,17 +53,25 @@ class EntryEditor extends Component {
|
||||
onCancelEdit,
|
||||
} = this.props;
|
||||
|
||||
const controlClassName = `${ styles.controlPane } ${ this.state.showEventBlocker && styles.blocker }`;
|
||||
const previewClassName = `${ styles.previewPane } ${ this.state.showEventBlocker && styles.blocker }`;
|
||||
const { previewVisible, showEventBlocker } = this.state;
|
||||
|
||||
const collectionPreviewEnabled = collection.getIn(['editor', 'preview'], true);
|
||||
|
||||
const togglePreviewButton = (
|
||||
<Button className={styles.previewToggle} onClick={this.handleTogglePreview}>Toggle Preview</Button>
|
||||
<Button
|
||||
className={classnames(styles.previewToggle, { previewVisible: styles.previewToggleShow })}
|
||||
onClick={this.handleTogglePreview}
|
||||
icon={previewVisible ? 'visibility_off' : 'visibility'}
|
||||
floating
|
||||
mini
|
||||
/>
|
||||
);
|
||||
|
||||
const editor = (
|
||||
<div className={controlClassName}>
|
||||
<StickyContext
|
||||
className={classnames(styles.controlPane, { [styles.blocker]: showEventBlocker })}
|
||||
registerListener={fn => this.updateStickyContext = fn}
|
||||
>
|
||||
{ collectionPreviewEnabled ? togglePreviewButton : null }
|
||||
<ControlPane
|
||||
collection={collection}
|
||||
@ -75,7 +86,7 @@ class EntryEditor extends Component {
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
ref={c => this.controlPaneRef = c} // eslint-disable-line
|
||||
/>
|
||||
</div>
|
||||
</StickyContext>
|
||||
);
|
||||
|
||||
const editorWithPreview = (
|
||||
@ -85,9 +96,10 @@ class EntryEditor extends Component {
|
||||
defaultSize="50%"
|
||||
onDragStarted={this.handleSplitPaneDragStart}
|
||||
onDragFinished={this.handleSplitPaneDragFinished}
|
||||
onChange={this.updateStickyContext}
|
||||
>
|
||||
<ScrollSyncPane>{editor}</ScrollSyncPane>
|
||||
<div className={previewClassName}>
|
||||
<div className={classnames(styles.previewPane, { [styles.blocker]: showEventBlocker })}>
|
||||
<PreviewPane
|
||||
collection={collection}
|
||||
entry={entry}
|
||||
|
19
src/components/UI/Sticky/Sticky.css
Normal file
19
src/components/UI/Sticky/Sticky.css
Normal file
@ -0,0 +1,19 @@
|
||||
.stickyContainer {
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.sticky {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stickyActive:not(.stickyAtBottom) {
|
||||
position: fixed !important;
|
||||
top: 64px !important;
|
||||
}
|
||||
|
||||
.stickyAtBottom {
|
||||
position: absolute !important;
|
||||
top: auto !important;
|
||||
bottom: 30px !important;
|
||||
}
|
||||
|
219
src/components/UI/Sticky/Sticky.js
Normal file
219
src/components/UI/Sticky/Sticky.js
Normal file
@ -0,0 +1,219 @@
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { partial, without } from 'lodash';
|
||||
import styles from './Sticky.css';
|
||||
|
||||
/**
|
||||
* Sticky is a collection of three components meant to facilitate "sticky" UI
|
||||
* behavior for nested components. It uses React Context to provide an isolated,
|
||||
* children-accessible state machine. It was specifically built for the rich
|
||||
* text editor toolbar to achieve the following:
|
||||
*
|
||||
* - work within a scrollable section as if it were the window
|
||||
* - remain at the top of the scrollable section if the rich text field begins
|
||||
* to scroll up and out
|
||||
* - scroll away with the rich text field when it is almost out of view
|
||||
* - work when multiple rich text fields are present
|
||||
*
|
||||
* No available solution was near facilitating this for a React app. Eventually,
|
||||
* if use continues, it should be improved to be more abstract and potentially
|
||||
* split off to a separate library unto itself, covering more use cases than
|
||||
* just the rich text toolbar.
|
||||
*
|
||||
* Sticky consists of three components, which are documented right here to
|
||||
* facilitate a concise, high level overview:
|
||||
*
|
||||
* - StickyContext: the scrollable area that essentially serves as the window
|
||||
* should be wrapped in StickyContext
|
||||
* - StickyContainer: wraps the secondary container that the sticky element is
|
||||
* bound within, eg. the rich text field
|
||||
* - Sticky: wraps the sticky element itself
|
||||
*/
|
||||
|
||||
export class StickyContext extends Component {
|
||||
static childContextTypes = {
|
||||
subscribeToStickyContext: PropTypes.func,
|
||||
unsubscribeToStickyContext: PropTypes.func,
|
||||
requestUpdate: PropTypes.func,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
|
||||
/**
|
||||
* registerListener: accepts a function that is called with the `updateStickies` method as an
|
||||
* arg, so it can be accessed from the component implementation site similar to how refs are
|
||||
* accessed:
|
||||
*
|
||||
* <StickyContext registerListener={fn => this.updateStickyContext = fn}>
|
||||
*
|
||||
* This function can then be used from the component implementation site to force update the
|
||||
* entire Sticky instance, which is sometimes necessary.
|
||||
*/
|
||||
registerListener: PropTypes.func,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.subscriptions = [];
|
||||
}
|
||||
|
||||
subscribeToStickyContext = (fn) => {
|
||||
this.subscriptions.push(fn);
|
||||
};
|
||||
|
||||
getChildContext() {
|
||||
return {
|
||||
subscribeToStickyContext: this.subscribeToStickyContext,
|
||||
unsubscribeToStickyContext: (fn) => { this.subscriptions = without(this.subscriptions, fn); },
|
||||
requestUpdate: () => { window.setTimeout(() => { this.updateStickies.call(this, this.ref); }); },
|
||||
};
|
||||
}
|
||||
|
||||
updateStickies = (ref) => {
|
||||
const stickyContextTop = ref && ref.getBoundingClientRect().top;
|
||||
this.subscriptions.forEach((fn) => { fn(stickyContextTop); });
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.updateStickies(this.ref);
|
||||
this.props.registerListener(this.updateStickies.bind(this, this.ref));
|
||||
}
|
||||
|
||||
handleScroll = (event) => {
|
||||
this.updateStickies(event.target);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={this.props.className} onScroll={this.handleScroll} ref={(ref) => { this.ref = ref; }}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class StickyContainer extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.subscriptions = [];
|
||||
}
|
||||
|
||||
static contextTypes = {
|
||||
subscribeToStickyContext: PropTypes.func,
|
||||
unsubscribeToStickyContext: PropTypes.func,
|
||||
requestUpdate: PropTypes.func,
|
||||
};
|
||||
|
||||
static childContextTypes = {
|
||||
subscribeToStickyContainer: PropTypes.func,
|
||||
unsubscribeToStickyContainer: PropTypes.func,
|
||||
requestUpdate: PropTypes.func,
|
||||
};
|
||||
|
||||
getChildContext() {
|
||||
return {
|
||||
subscribeToStickyContainer: (fn) => { this.subscriptions.push(fn); },
|
||||
unsubscribeToStickyContainer: (fn) => { this.subscriptions = without(this.subscriptions, fn); },
|
||||
requestUpdate: () => { this.context.requestUpdate.call(this); },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* getPosition: used for updating the sticky element to stick or not stick, and also provides
|
||||
* width info. Because a sticky element uses fixed positioning, it may not be able to be sized
|
||||
* relative to a parent, so the StickyContainer width is provided to allow the Sticky to be sized
|
||||
* accordingly.
|
||||
*/
|
||||
getPosition = (contextTop) => {
|
||||
const rect = this.ref.getBoundingClientRect();
|
||||
const shouldStick = rect.top < contextTop;
|
||||
const shouldStickAtBottom = rect.bottom - 60 < contextTop;
|
||||
this.subscriptions.forEach((fn) => { fn(shouldStick, shouldStickAtBottom, rect.width); });
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.context.subscribeToStickyContext(this.getPosition);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.context.unsubscribeToStickyContext(this.getPosition);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
id={this.context.string}
|
||||
className={classnames(this.props.className, styles.stickyContainer)}
|
||||
ref={(ref) => { this.ref = ref }}
|
||||
>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Sticky extends Component {
|
||||
static contextTypes = {
|
||||
subscribeToStickyContainer: PropTypes.func,
|
||||
unsubscribeToStickyContainer: PropTypes.func,
|
||||
requestUpdate: PropTypes.func,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
|
||||
/**
|
||||
* fillContainerWidth: allows the sticky width to be dynamically set to the width of it's
|
||||
* StickyContainer when sticky (fixed positioning).
|
||||
*/
|
||||
fillContainerWidth: PropTypes.bool,
|
||||
}
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
updateSticky = (shouldStick, shouldStickAtBottom, containerWidth) => {
|
||||
this.setState({ shouldStick, shouldStickAtBottom, containerWidth });
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.context.subscribeToStickyContainer(this.updateSticky);
|
||||
this.context.requestUpdate();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.context.unsubscribeToStickyContainer(this.updateSticky);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props, state } = this;
|
||||
const stickyPlaceholderHeight = state.shouldStick ? this.ref.getBoundingClientRect().height : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{paddingBottom: stickyPlaceholderHeight}}></div>
|
||||
<div
|
||||
className={classnames(
|
||||
props.className,
|
||||
styles.sticky,
|
||||
{
|
||||
[styles.stickyActive]: state.shouldStick,
|
||||
[styles.stickyAtBottom]: state.shouldStickAtBottom,
|
||||
},
|
||||
)}
|
||||
style={
|
||||
props.fillContainerWidth && state.containerWidth && state.shouldStick ?
|
||||
{ width: state.containerWidth } :
|
||||
null
|
||||
}
|
||||
ref={(ref) => {this.ref = ref}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -8,16 +8,24 @@
|
||||
--successColor: #1c7;
|
||||
--warningColor: #fa0;
|
||||
--errorColor: #f52;
|
||||
--textColor: #272e30;
|
||||
--borderRadius: 2px;
|
||||
--borderRadiusLarge: 10px;
|
||||
--dropShadow:
|
||||
0 2px 2px 0 rgba(0, 0, 0, 0.14),
|
||||
0 3px 1px -2px rgba(0, 0, 0, 0.2),
|
||||
0 1px 5px 0 rgba(0, 0, 0, 0.12);
|
||||
--topmostZindex: 99999;
|
||||
--foregroundAltColor: #fff;
|
||||
--backgroundAltColor: #272e30;
|
||||
--textFieldBorderColor: #e7e7e7;
|
||||
--highlightFGColor: #fff;
|
||||
--highlightBGColor: #3ab7a5;
|
||||
--controlLabelColor: #272e30;
|
||||
--highlightFGAltColor: #eee;
|
||||
--controlLabelColor: var(--textColor);
|
||||
--controlBGColor: #fff;
|
||||
--backgroundTertiaryColor: #f2f5f4;
|
||||
--backgroundTertiaryColorDark: color(var(--backgroundTertiaryColor) lightness(90%));
|
||||
}
|
||||
|
||||
.base {
|
||||
@ -36,3 +44,9 @@
|
||||
.depth {
|
||||
box-shadow: var(--shadowColor) 0 1px 6px;
|
||||
}
|
||||
|
||||
.clearfix:after {
|
||||
content: '';
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
|
@ -3,18 +3,16 @@ import registry from '../../lib/registry';
|
||||
import RawEditor from './MarkdownControlElements/RawEditor';
|
||||
import VisualEditor from './MarkdownControlElements/VisualEditor';
|
||||
import { processEditorPlugins } from './richText';
|
||||
import { connect } from 'react-redux';
|
||||
import { switchVisualMode } from '../../actions/editor';
|
||||
import { StickyContainer } from '../UI/Sticky/Sticky';
|
||||
|
||||
const MODE_STORAGE_KEY = 'cms.md-mode';
|
||||
|
||||
class MarkdownControl extends React.Component {
|
||||
export default class MarkdownControl extends React.Component {
|
||||
static propTypes = {
|
||||
editor: PropTypes.object.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
switchVisualMode: PropTypes.func.isRequired,
|
||||
value: PropTypes.node,
|
||||
};
|
||||
|
||||
@ -35,22 +33,19 @@ class MarkdownControl extends React.Component {
|
||||
render() {
|
||||
const { onChange, onAddAsset, onRemoveAsset, getAsset, value } = this.props;
|
||||
const { mode } = this.state;
|
||||
if (mode === 'visual') {
|
||||
return (
|
||||
<div className="cms-editor-visual">
|
||||
<VisualEditor
|
||||
onChange={onChange}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
onMode={this.handleMode}
|
||||
getAsset={getAsset}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
const visualEditor = (
|
||||
<div className="cms-editor-visual">
|
||||
<VisualEditor
|
||||
onChange={onChange}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
onMode={this.handleMode}
|
||||
getAsset={getAsset}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const rawEditor = (
|
||||
<div className="cms-editor-raw">
|
||||
<RawEditor
|
||||
onChange={onChange}
|
||||
@ -62,10 +57,6 @@ class MarkdownControl extends React.Component {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <StickyContainer>{ mode === 'visual' ? visualEditor : rawEditor }</StickyContainer>;
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({ editor: state.editor }),
|
||||
{ switchVisualMode }
|
||||
)(MarkdownControl);
|
||||
|
@ -1,135 +0,0 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
left: -18px;
|
||||
display: none;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: block;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: 1px solid #444;
|
||||
border-radius: 100%;
|
||||
background: transparent;
|
||||
line-height: 13px;
|
||||
}
|
||||
|
||||
.expanded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pluginForm,
|
||||
.menu {
|
||||
margin-top: -20px;
|
||||
margin-bottom: 30px;
|
||||
margin-left: 20px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: 1px 1px 20px;
|
||||
|
||||
& h3 {
|
||||
padding: 5px 20px;
|
||||
border-bottom: 1px solid;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
list-style: none;
|
||||
|
||||
& li button {
|
||||
display: block;
|
||||
padding: 5px 20px;
|
||||
min-width: 30%;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-bottom: 1px solid;
|
||||
background: #fff;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
& li:last-child button {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
& li:hover button {
|
||||
background: #efefef;
|
||||
}
|
||||
}
|
||||
|
||||
.menu.expanded {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.control {
|
||||
position: relative;
|
||||
padding: 20px 20px;
|
||||
color: #7c8382;
|
||||
|
||||
& input,
|
||||
& textarea,
|
||||
& select {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
outline: 0;
|
||||
border: none;
|
||||
background: 0 0;
|
||||
box-shadow: none;
|
||||
color: #7c8382;
|
||||
font-size: 18px;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 18px;
|
||||
color: #aab0af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.widget {
|
||||
position: relative;
|
||||
border-bottom: 1px solid #aaa;
|
||||
|
||||
&:after {
|
||||
position: absolute;
|
||||
bottom: -7px;
|
||||
left: 42px;
|
||||
z-index: 1;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-right: 1px solid #aaa;
|
||||
border-bottom: 1px solid #aaa;
|
||||
background-color: #f2f5f4;
|
||||
content: '';
|
||||
-webkit-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:last-child:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid #eee;
|
||||
background: #fff;
|
||||
text-align: right;
|
||||
}
|
@ -1,134 +0,0 @@
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import { fromJS } from 'immutable';
|
||||
import { Button } from 'react-toolbox/lib/button';
|
||||
import { resolveWidget } from '../../Widgets';
|
||||
import styles from './BlockMenu.css';
|
||||
|
||||
export default class BlockMenu extends Component {
|
||||
static propTypes = {
|
||||
isOpen: PropTypes.bool,
|
||||
selectionPosition: PropTypes.object,
|
||||
plugins: PropTypes.object.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isExpanded: false,
|
||||
openPlugin: null,
|
||||
pluginData: fromJS({}),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { selectionPosition } = this.props;
|
||||
if (selectionPosition) {
|
||||
const style = this.element.style;
|
||||
style.setProperty('top', `${ selectionPosition.top }px`);
|
||||
}
|
||||
}
|
||||
|
||||
handleToggle = (e) => {
|
||||
e.preventDefault();
|
||||
this.setState({ isExpanded: !this.state.isExpanded });
|
||||
};
|
||||
|
||||
handleRef = (ref) => {
|
||||
this.element = ref;
|
||||
};
|
||||
|
||||
handlePlugin(plugin) {
|
||||
return (e) => {
|
||||
e.preventDefault();
|
||||
this.setState({ openPlugin: plugin, pluginData: fromJS({}) });
|
||||
};
|
||||
}
|
||||
|
||||
buttonFor(plugin) {
|
||||
return (<li key={`plugin-${ plugin.get('id') }`}>
|
||||
<button onClick={this.handlePlugin(plugin)}>{plugin.get('label')}</button>
|
||||
</li>);
|
||||
}
|
||||
|
||||
handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const { openPlugin, pluginData } = this.state;
|
||||
this.props.onBlock(openPlugin, pluginData);
|
||||
this.setState({ openPlugin: null, isExpanded: false });
|
||||
};
|
||||
|
||||
handleCancel = (e) => {
|
||||
e.preventDefault();
|
||||
this.setState({ openPlugin: null, isExpanded: false });
|
||||
};
|
||||
|
||||
controlFor(field) {
|
||||
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
|
||||
const { pluginData } = this.state;
|
||||
const widget = resolveWidget(field.get('widget') || 'string');
|
||||
const value = pluginData.get(field.get('name'));
|
||||
|
||||
return (
|
||||
<div className={styles.control} key={`field-${ field.get('name') }`}>
|
||||
<label className={styles.label}>{field.get('label')}</label>
|
||||
{
|
||||
React.createElement(widget.control, {
|
||||
field,
|
||||
value,
|
||||
onChange: (val) => {
|
||||
this.setState({
|
||||
pluginData: pluginData.set(field.get('name'), val),
|
||||
});
|
||||
},
|
||||
onAddAsset,
|
||||
onRemoveAsset,
|
||||
getAsset,
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
pluginForm(plugin) {
|
||||
return (<form className={styles.pluginForm} onSubmit={this.handleSubmit}>
|
||||
<h3>Insert {plugin.get('label')}</h3>
|
||||
{plugin.get('fields').map(field => this.controlFor(field))}
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
raised
|
||||
onClick={this.handleSubmit}
|
||||
>
|
||||
Insert
|
||||
</Button>
|
||||
{' '}
|
||||
<Button onClick={this.handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isOpen, plugins } = this.props;
|
||||
const { isExpanded, openPlugin } = this.state;
|
||||
const classNames = [styles.root];
|
||||
if (isOpen) {
|
||||
classNames.push(styles.visible);
|
||||
}
|
||||
if (openPlugin) {
|
||||
classNames.push(styles.openPlugin);
|
||||
}
|
||||
|
||||
return (<div className={classNames.join(' ')} ref={this.handleRef}>
|
||||
<button className={styles.button} onClick={this.handleToggle}>+</button>
|
||||
<ul className={[styles.menu, isExpanded && !openPlugin ? styles.expanded : styles.collapsed].join(' ')}>
|
||||
{plugins.map(plugin => this.buttonFor(plugin))}
|
||||
</ul>
|
||||
{openPlugin && this.pluginForm(openPlugin)}
|
||||
</div>);
|
||||
}
|
||||
}
|
@ -2,6 +2,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editorControlBar {
|
||||
composes: editorControlBar from "../VisualEditor/index.css";
|
||||
}
|
||||
|
||||
.dragging { }
|
||||
|
||||
.shim {
|
||||
|
@ -3,10 +3,11 @@ import MarkupIt from 'markup-it';
|
||||
import markdownSyntax from 'markup-it/syntaxes/markdown';
|
||||
import htmlSyntax from 'markup-it/syntaxes/html';
|
||||
import CaretPosition from 'textarea-caret-position';
|
||||
import TextareaAutosize from 'react-textarea-autosize-inputref';
|
||||
import registry from '../../../../lib/registry';
|
||||
import { createAssetProxy } from '../../../../valueObjects/AssetProxy';
|
||||
import Toolbar from '../Toolbar';
|
||||
import BlockMenu from '../BlockMenu';
|
||||
import Toolbar from '../Toolbar/Toolbar';
|
||||
import { Sticky } from '../../../UI/Sticky/Sticky';
|
||||
import styles from './index.css';
|
||||
|
||||
const HAS_LINE_BREAK = /\n/m;
|
||||
@ -18,7 +19,7 @@ function processUrl(url) {
|
||||
if (url.match(/^(https?:\/\/|mailto:|\/)/)) {
|
||||
return url;
|
||||
}
|
||||
if (url.match(/^[^\/]+\.[^\/]+/)) {
|
||||
if (url.match(/^[^/]+\.[^/]+/)) {
|
||||
return `https://${ url }`;
|
||||
}
|
||||
return `/${ url }`;
|
||||
@ -44,7 +45,9 @@ function getCleanPaste(e) {
|
||||
// Handle complex pastes by stealing focus with a contenteditable div
|
||||
const div = document.createElement('div');
|
||||
div.contentEditable = true;
|
||||
div.setAttribute('style', 'opacity: 0; overflow: hidden; width: 1px; height: 1px; position: fixed; top: 50%; left: 0;');
|
||||
div.setAttribute(
|
||||
'style', 'opacity: 0; overflow: hidden; width: 1px; height: 1px; position: fixed; top: 50%; left: 0;'
|
||||
);
|
||||
document.body.appendChild(div);
|
||||
div.focus();
|
||||
setTimeout(() => {
|
||||
@ -194,7 +197,7 @@ export default class RawEditor extends React.Component {
|
||||
};
|
||||
|
||||
handleLink = () => {
|
||||
const url = prompt('URL:');
|
||||
const url = prompt('URL:'); // eslint-disable-line no-alert
|
||||
const selection = this.getSelection();
|
||||
this.replaceSelection(`[${ selection.selected }](${ processUrl(url) })`);
|
||||
};
|
||||
@ -205,9 +208,9 @@ export default class RawEditor extends React.Component {
|
||||
if (selection.start !== selection.end && !HAS_LINE_BREAK.test(selection.selected)) {
|
||||
try {
|
||||
const selectionPosition = this.caretPosition.get(selection.start, selection.end);
|
||||
this.setState({ showToolbar: true, showBlockMenu: false, selectionPosition });
|
||||
this.setState({ selectionPosition });
|
||||
} catch (e) {
|
||||
this.setState({ showToolbar: false, showBlockMenu: false });
|
||||
console.log(e); // eslint-disable-line no-console
|
||||
}
|
||||
} else if (selection.start === selection.end) {
|
||||
const newBlock =
|
||||
@ -222,12 +225,8 @@ export default class RawEditor extends React.Component {
|
||||
|
||||
if (newBlock) {
|
||||
const position = this.caretPosition.get(selection.start, selection.end);
|
||||
this.setState({ showToolbar: false, showBlockMenu: true, selectionPosition: position });
|
||||
} else {
|
||||
this.setState({ showToolbar: false, showBlockMenu: false });
|
||||
this.setState({ selectionPosition: position });
|
||||
}
|
||||
} else {
|
||||
this.setState({ showToolbar: false, showBlockMenu: false });
|
||||
}
|
||||
};
|
||||
|
||||
@ -236,10 +235,9 @@ export default class RawEditor extends React.Component {
|
||||
this.updateHeight();
|
||||
};
|
||||
|
||||
handleBlock = (plugin, data) => {
|
||||
handlePluginSubmit = (plugin, data) => {
|
||||
const toBlock = plugin.get('toBlock');
|
||||
this.replaceSelection(toBlock.call(toBlock, data.toJS()));
|
||||
this.setState({ showBlockMenu: false });
|
||||
};
|
||||
|
||||
handleHeader(header) {
|
||||
@ -309,7 +307,7 @@ export default class RawEditor extends React.Component {
|
||||
|
||||
render() {
|
||||
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
|
||||
const { showToolbar, showBlockMenu, plugins, selectionPosition, dragging } = this.state;
|
||||
const { plugins, selectionPosition, dragging } = this.state;
|
||||
const classNames = [styles.root];
|
||||
if (dragging) {
|
||||
classNames.push(styles.dragging);
|
||||
@ -322,27 +320,25 @@ export default class RawEditor extends React.Component {
|
||||
onDragOver={this.handleDragOver}
|
||||
onDrop={this.handleDrop}
|
||||
>
|
||||
<Toolbar
|
||||
isOpen={showToolbar}
|
||||
selectionPosition={selectionPosition}
|
||||
onH1={this.handleHeader('#')}
|
||||
onH2={this.handleHeader('##')}
|
||||
onBold={this.handleBold}
|
||||
onItalic={this.handleItalic}
|
||||
onLink={this.handleLink}
|
||||
onToggleMode={this.handleToggle}
|
||||
/>
|
||||
<BlockMenu
|
||||
isOpen={showBlockMenu}
|
||||
selectionPosition={selectionPosition}
|
||||
plugins={plugins}
|
||||
onBlock={this.handleBlock}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
getAsset={getAsset}
|
||||
/>
|
||||
<textarea
|
||||
ref={this.handleRef}
|
||||
<Sticky className={styles.editorControlBar} fillContainerWidth>
|
||||
<Toolbar
|
||||
selectionPosition={selectionPosition}
|
||||
onH1={this.handleHeader('#')}
|
||||
onH2={this.handleHeader('##')}
|
||||
onBold={this.handleBold}
|
||||
onItalic={this.handleItalic}
|
||||
onLink={this.handleLink}
|
||||
onToggleMode={this.handleToggle}
|
||||
plugins={plugins}
|
||||
onSubmit={this.handlePluginSubmit}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
getAsset={getAsset}
|
||||
rawMode
|
||||
/>
|
||||
</Sticky>
|
||||
<TextareaAutosize
|
||||
inputRef={this.handleRef}
|
||||
value={this.props.value || ''}
|
||||
onKeyDown={this.handleKey}
|
||||
onChange={this.handleChange}
|
||||
|
@ -1,28 +0,0 @@
|
||||
.Toolbar {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
margin: none;
|
||||
padding: none;
|
||||
box-shadow: 1px 1px 5px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.Button {
|
||||
display: inline-block;
|
||||
|
||||
& button {
|
||||
padding: 5px;
|
||||
border: none;
|
||||
border-right: 1px solid #eee;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.Button:last-child button {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.Visible {
|
||||
display: block;
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import { Icon } from '../../UI';
|
||||
import styles from './Toolbar.css';
|
||||
|
||||
function button(label, icon, action) {
|
||||
return (<li className={styles.Button}>
|
||||
<button className={styles[label]} onClick={action} title={label}>
|
||||
<Icon type={icon} />
|
||||
</button>
|
||||
</li>);
|
||||
}
|
||||
|
||||
export default class Toolbar extends Component {
|
||||
static propTypes = {
|
||||
isOpen: PropTypes.bool,
|
||||
selectionPosition: PropTypes.object,
|
||||
onH1: PropTypes.func.isRequired,
|
||||
onH2: PropTypes.func.isRequired,
|
||||
onBold: PropTypes.func.isRequired,
|
||||
onItalic: PropTypes.func.isRequired,
|
||||
onLink: PropTypes.func.isRequired,
|
||||
onToggleMode: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
componentDidUpdate() {
|
||||
const { selectionPosition } = this.props;
|
||||
if (selectionPosition) {
|
||||
const rect = this.element.getBoundingClientRect();
|
||||
const parentRect = this.element.parentElement.getBoundingClientRect();
|
||||
const style = this.element.style;
|
||||
const pos = {
|
||||
top: selectionPosition.top - rect.height - 5,
|
||||
left: Math.min(selectionPosition.left, parentRect.width - rect.width),
|
||||
};
|
||||
style.setProperty('top', `${ pos.top }px`);
|
||||
style.setProperty('left', `${ pos.left }px`);
|
||||
}
|
||||
}
|
||||
|
||||
handleRef = (ref) => {
|
||||
this.element = ref;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isOpen, onH1, onH2, onBold, onItalic, onLink, onToggleMode } = this.props;
|
||||
const classNames = [styles.Toolbar];
|
||||
|
||||
if (isOpen) {
|
||||
classNames.push(styles.Visible);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={classNames.join(' ')} ref={this.handleRef}>
|
||||
{button('Header 1', 'h1', onH1)}
|
||||
{button('Header 2', 'h2', onH2)}
|
||||
{button('Bold', 'bold', onBold)}
|
||||
{button('Italic', 'italic', onItalic)}
|
||||
{button('Link', 'link', onLink)}
|
||||
{button('View Code', 'code', onToggleMode)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
@import "../../../UI/theme";
|
||||
|
||||
.Toolbar {
|
||||
composes: clearfix;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.Toggle {
|
||||
float: right;
|
||||
margin-bottom: 0;
|
||||
padding-top: 4px;
|
||||
padding-right: 10px;
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { List } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Switch from 'react-toolbox/lib/switch';
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
import ToolbarComponentsMenu from './ToolbarComponentsMenu';
|
||||
import ToolbarPluginForm from './ToolbarPluginForm';
|
||||
import { Icon } from '../../../UI';
|
||||
import styles from './Toolbar.css';
|
||||
|
||||
export default class Toolbar extends React.Component {
|
||||
static propTypes = {
|
||||
selectionPosition: PropTypes.object,
|
||||
onH1: PropTypes.func.isRequired,
|
||||
onH2: PropTypes.func.isRequired,
|
||||
onBold: PropTypes.func.isRequired,
|
||||
onItalic: PropTypes.func.isRequired,
|
||||
onLink: PropTypes.func.isRequired,
|
||||
onToggleMode: PropTypes.func.isRequired,
|
||||
rawMode: PropTypes.bool,
|
||||
plugins: ImmutablePropTypes.listOf(ImmutablePropTypes.record),
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
activePlugin: null,
|
||||
};
|
||||
}
|
||||
|
||||
handlePluginFormDisplay = (plugin) => {
|
||||
this.setState({ activePlugin: plugin });
|
||||
}
|
||||
|
||||
handlePluginFormSubmit = (plugin, pluginData) => {
|
||||
this.props.onSubmit(plugin, pluginData);
|
||||
this.setState({ activePlugin: null });
|
||||
};
|
||||
|
||||
handlePluginFormCancel = (e) => {
|
||||
this.setState({ activePlugin: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
onH1,
|
||||
onH2,
|
||||
onBold,
|
||||
onItalic,
|
||||
onLink,
|
||||
onToggleMode,
|
||||
rawMode,
|
||||
plugins,
|
||||
onAddAsset,
|
||||
onRemoveAsset,
|
||||
getAsset,
|
||||
} = this.props;
|
||||
|
||||
const { activePlugin } = this.state;
|
||||
|
||||
return (
|
||||
<div className={styles.Toolbar}>
|
||||
<ToolbarButton label="Header 1" icon="h1" action={onH1}/>
|
||||
<ToolbarButton label="Header 2" icon="h2" action={onH2}/>
|
||||
<ToolbarButton label="Bold" icon="bold" action={onBold}/>
|
||||
<ToolbarButton label="Italic" icon="italic" action={onItalic}/>
|
||||
<ToolbarButton label="Link" icon="link" action={onLink}/>
|
||||
<ToolbarComponentsMenu
|
||||
plugins={plugins}
|
||||
onComponentMenuItemClick={this.handlePluginFormDisplay}
|
||||
/>
|
||||
{activePlugin &&
|
||||
<ToolbarPluginForm
|
||||
plugin={activePlugin}
|
||||
onSubmit={this.handlePluginFormSubmit}
|
||||
onCancel={this.handlePluginFormCancel}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
getAsset={getAsset}
|
||||
/>
|
||||
}
|
||||
<Switch label="Markdown" onChange={onToggleMode} checked={rawMode} className={styles.Toggle}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
@import "../../../UI/theme";
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: var(--highlightFGAltColor);
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Icon } from '../../../UI';
|
||||
import styles from './ToolbarButton.css';
|
||||
|
||||
const ToolbarButton = ({ label, icon, action, active }) => (
|
||||
<button
|
||||
className={classnames(styles.button, { [styles.active]: active })}
|
||||
onClick={action}
|
||||
title={label}
|
||||
>
|
||||
{ icon ? <Icon type={icon} /> : label }
|
||||
</button>
|
||||
);
|
||||
|
||||
ToolbarButton.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string,
|
||||
action: PropTypes.func.isRequired,
|
||||
active: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ToolbarButton;
|
@ -0,0 +1,14 @@
|
||||
.root {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
height: auto;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
|
||||
& span {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { Menu, MenuItem } from 'react-toolbox/lib/menu';
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
import styles from './ToolbarComponentsMenu.css';
|
||||
|
||||
export default class ToolbarComponentsMenu extends React.Component {
|
||||
static PropTypes = {
|
||||
plugins: ImmutablePropTypes.list.isRequired,
|
||||
onComponentMenuItemClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
componentsMenuActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
handleComponentsMenuToggle = () => {
|
||||
this.setState({ componentsMenuActive: !this.state.componentsMenuActive });
|
||||
};
|
||||
|
||||
handleComponentsMenuHide = () => {
|
||||
this.setState({ componentsMenuActive: false });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { plugins, onComponentMenuItemClick } = this.props;
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<ToolbarButton label="Add Component" icon="plus" action={this.handleComponentsMenuToggle}/>
|
||||
<Menu
|
||||
active={this.state.componentsMenuActive}
|
||||
position="auto"
|
||||
onHide={this.handleComponentsMenuHide}
|
||||
ripple={false}
|
||||
>
|
||||
{plugins.map(plugin => (
|
||||
<MenuItem
|
||||
key={plugin.get('id')}
|
||||
value={plugin.get('id')}
|
||||
caption={plugin.get('label')}
|
||||
onClick={() => onComponentMenuItemClick(plugin)}
|
||||
className={styles.menuItem}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
@import "../../../UI/theme";
|
||||
|
||||
.pluginForm {
|
||||
position: absolute;
|
||||
background-color: var(--backgroundTertiaryColorDark);
|
||||
margin-top: -20px;
|
||||
margin-bottom: 30px;
|
||||
margin-left: 20px;
|
||||
border-radius: var(--borderRadius);
|
||||
box-shadow: var(--dropShadow);
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
|
||||
/* Nested to override high specificity React Toolbox styles */
|
||||
& .header {
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
color: var(--textColor);
|
||||
background-color: var(--backgroundColor);
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 0 12px 16px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--backgroundColor);
|
||||
padding: 10px 20px;
|
||||
text-align: right;
|
||||
}
|
@ -0,0 +1,68 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { Map } from 'immutable';
|
||||
import { Button } from 'react-toolbox/lib/button';
|
||||
import ToolbarPluginFormControl from './ToolbarPluginFormControl';
|
||||
import styles from './ToolbarPluginForm.css';
|
||||
|
||||
export default class ToolbarPluginForm extends React.Component {
|
||||
static propTypes = {
|
||||
plugin: PropTypes.object.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired,
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
data: Map(),
|
||||
};
|
||||
}
|
||||
|
||||
handleSubmit = (event) => {
|
||||
const { plugin, onSubmit } = this.props;
|
||||
onSubmit(plugin, this.state.data);
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
plugin,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
value,
|
||||
onAddAsset,
|
||||
onRemoveAsset,
|
||||
getAsset,
|
||||
onChange,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<form className={styles.pluginForm} onSubmit={this.handleSubmit}>
|
||||
<h3 className={styles.header}>Insert {plugin.get('label')}</h3>
|
||||
<div className={styles.body}>
|
||||
{plugin.get('fields').map((field, index) => (
|
||||
<ToolbarPluginFormControl
|
||||
key={index}
|
||||
field={field}
|
||||
value={this.state.data.get(field.get('name'))}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
getAsset={getAsset}
|
||||
onChange={(val) => {
|
||||
this.setState({ data: this.state.data.set(field.get('name'), val) });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<Button raised onClick={this.handleSubmit}>Insert</Button>
|
||||
{' '}
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
.control {
|
||||
composes: control from "../../../ControlPanel/ControlPane.css"
|
||||
}
|
||||
|
||||
.label {
|
||||
composes: label from "../../../ControlPanel/ControlPane.css";
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
import React, { PropTypes } from 'react';
|
||||
import { resolveWidget } from '../../../Widgets';
|
||||
import styles from './ToolbarPluginFormControl.css';
|
||||
|
||||
const ToolbarPluginFormControl = ({
|
||||
field,
|
||||
value,
|
||||
pluginData,
|
||||
onAddAsset,
|
||||
onRemoveAsset,
|
||||
getAsset,
|
||||
onChange,
|
||||
}) => {
|
||||
const widget = resolveWidget(field.get('widget') || 'string');
|
||||
const key = `field-${ field.get('name') }`;
|
||||
const Control = widget.control;
|
||||
const controlProps = { field, value, onAddAsset, onRemoveAsset, getAsset, onChange };
|
||||
|
||||
return (
|
||||
<div className={styles.control} key={key}>
|
||||
<label className={styles.label} htmlFor={key}>{field.get('label')}</label>
|
||||
<Control {...controlProps}/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ToolbarPluginFormControl.propTypes = {
|
||||
field: PropTypes.object.isRequired,
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
]),
|
||||
onAddAsset: PropTypes.func.isRequired,
|
||||
onRemoveAsset: PropTypes.func.isRequired,
|
||||
getAsset: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ToolbarPluginFormControl;
|
@ -1,5 +1,12 @@
|
||||
@import "../../../UI/theme";
|
||||
|
||||
.editorControlBar {
|
||||
background-color: var(--controlBGColor);
|
||||
border-bottom: 1px solid var(--backgroundTertiaryColorDark);
|
||||
border-radius: var(--borderRadius) var(--borderRadius) 0 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.editor {
|
||||
position: relative;
|
||||
& h1, & h2, & h3 {
|
||||
@ -61,7 +68,11 @@
|
||||
position: relative;
|
||||
background-color: var(--controlBGColor);
|
||||
padding: 12px;
|
||||
border-radius: var(--borderRadius);
|
||||
border-radius: 0 0 var(--borderRadius) var(--borderRadius);
|
||||
|
||||
& ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
& .ProseMirror-content {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import { Map } from 'immutable';
|
||||
import { Schema } from 'prosemirror-model';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
@ -8,21 +9,21 @@ import {
|
||||
inputRules, allInputRules,
|
||||
} from 'prosemirror-inputrules';
|
||||
import { keymap } from 'prosemirror-keymap';
|
||||
import { schema, defaultMarkdownSerializer } from 'prosemirror-markdown';
|
||||
import { schema as markdownSchema, defaultMarkdownSerializer } from 'prosemirror-markdown';
|
||||
import { baseKeymap, setBlockType, toggleMark } from 'prosemirror-commands';
|
||||
import registry from '../../../../lib/registry';
|
||||
import { createAssetProxy } from '../../../../valueObjects/AssetProxy';
|
||||
import { buildKeymap } from './keymap';
|
||||
import createMarkdownParser from './parser';
|
||||
import Toolbar from '../Toolbar';
|
||||
import BlockMenu from '../BlockMenu';
|
||||
import Toolbar from '../Toolbar/Toolbar';
|
||||
import { Sticky } from '../../../UI/Sticky/Sticky';
|
||||
import styles from './index.css';
|
||||
|
||||
function processUrl(url) {
|
||||
if (url.match(/^(https?:\/\/|mailto:|\/)/)) {
|
||||
return url;
|
||||
}
|
||||
if (url.match(/^[^\/]+\.[^\/]+/)) {
|
||||
if (url.match(/^[^/]+\.[^/]+/)) {
|
||||
return `https://${ url }`;
|
||||
}
|
||||
return `/${ url }`;
|
||||
@ -37,15 +38,10 @@ const ruleset = {
|
||||
};
|
||||
|
||||
function buildInputRules(schema) {
|
||||
const result = [];
|
||||
for (const rule in ruleset) {
|
||||
const type = schema.nodes[rule];
|
||||
if (type) {
|
||||
const fn = ruleset[rule];
|
||||
result.push(fn[0].apply(fn.slice(1)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
return Map(ruleset)
|
||||
.filter(rule => schema.nodes[rule])
|
||||
.map(rule => rule[0].apply(rule[0].slice(1)))
|
||||
.toArray();
|
||||
}
|
||||
|
||||
function markActive(state, type) {
|
||||
@ -99,12 +95,12 @@ export default class Editor extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const plugins = registry.getEditorComponents();
|
||||
const s = schemaWithPlugins(schema, plugins);
|
||||
const schema = schemaWithPlugins(markdownSchema, plugins);
|
||||
this.state = {
|
||||
plugins,
|
||||
schema: s,
|
||||
parser: createMarkdownParser(s, plugins),
|
||||
serializer: createSerializer(s, plugins),
|
||||
schema,
|
||||
parser: createMarkdownParser(schema, plugins),
|
||||
serializer: createSerializer(schema, plugins),
|
||||
};
|
||||
}
|
||||
|
||||
@ -133,7 +129,7 @@ export default class Editor extends Component {
|
||||
}
|
||||
|
||||
handleAction = (action) => {
|
||||
const { schema, serializer } = this.state;
|
||||
const { serializer } = this.state;
|
||||
const newState = this.view.state.applyAction(action);
|
||||
const md = serializer.serialize(newState.doc);
|
||||
this.props.onChange(md);
|
||||
@ -152,15 +148,13 @@ export default class Editor extends Component {
|
||||
const pos = this.view.coordsAtPos(selection.from);
|
||||
const editorPos = this.view.content.getBoundingClientRect();
|
||||
const selectionPosition = { top: pos.top - editorPos.top, left: pos.left - editorPos.left };
|
||||
this.setState({ showToolbar: false, showBlockMenu: true, selectionPosition });
|
||||
} else {
|
||||
this.setState({ showToolbar: false, showBlockMenu: false });
|
||||
this.setState({ selectionPosition });
|
||||
}
|
||||
} else {
|
||||
const pos = this.view.coordsAtPos(selection.from);
|
||||
const editorPos = this.view.content.getBoundingClientRect();
|
||||
const selectionPosition = { top: pos.top - editorPos.top, left: pos.left - editorPos.left };
|
||||
this.setState({ showToolbar: true, showBlockMenu: false, selectionPosition });
|
||||
this.setState({ selectionPosition });
|
||||
}
|
||||
};
|
||||
|
||||
@ -202,13 +196,13 @@ export default class Editor extends Component {
|
||||
handleLink = () => {
|
||||
let url = null;
|
||||
if (!markActive(this.view.state, this.state.schema.marks.link)) {
|
||||
url = prompt('Link URL:');
|
||||
url = prompt('Link URL:'); // eslint-disable-line no-alert
|
||||
}
|
||||
const command = toggleMark(this.state.schema.marks.link, { href: url ? processUrl(url) : null });
|
||||
command(this.view.state, this.handleAction);
|
||||
};
|
||||
|
||||
handleBlock = (plugin, data) => {
|
||||
handlePluginSubmit = (plugin, data) => {
|
||||
const { schema } = this.state;
|
||||
const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`];
|
||||
this.view.props.onAction(this.view.state.tr.replaceSelectionWith(nodeType.create(data.toJS())).action());
|
||||
@ -268,7 +262,7 @@ export default class Editor extends Component {
|
||||
|
||||
render() {
|
||||
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
|
||||
const { plugins, showToolbar, showBlockMenu, selectionPosition, dragging } = this.state;
|
||||
const { plugins, selectionPosition, dragging } = this.state;
|
||||
const classNames = [styles.editor];
|
||||
if (dragging) {
|
||||
classNames.push(styles.dragging);
|
||||
@ -281,25 +275,22 @@ export default class Editor extends Component {
|
||||
onDragOver={this.handleDragOver}
|
||||
onDrop={this.handleDrop}
|
||||
>
|
||||
<Toolbar
|
||||
isOpen={showToolbar}
|
||||
selectionPosition={selectionPosition}
|
||||
onH1={this.handleHeader(1)}
|
||||
onH2={this.handleHeader(2)}
|
||||
onBold={this.handleBold}
|
||||
onItalic={this.handleItalic}
|
||||
onLink={this.handleLink}
|
||||
onToggleMode={this.handleToggle}
|
||||
/>
|
||||
<BlockMenu
|
||||
isOpen={showBlockMenu}
|
||||
selectionPosition={selectionPosition}
|
||||
plugins={plugins}
|
||||
onBlock={this.handleBlock}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
getAsset={getAsset}
|
||||
/>
|
||||
<Sticky className={styles.editorControlBar} fillContainerWidth>
|
||||
<Toolbar
|
||||
selectionPosition={selectionPosition}
|
||||
onH1={this.handleHeader(1)}
|
||||
onH2={this.handleHeader(2)}
|
||||
onBold={this.handleBold}
|
||||
onItalic={this.handleItalic}
|
||||
onLink={this.handleLink}
|
||||
onToggleMode={this.handleToggle}
|
||||
plugins={plugins}
|
||||
onSubmit={this.handlePluginSubmit}
|
||||
onAddAsset={onAddAsset}
|
||||
onRemoveAsset={onRemoveAsset}
|
||||
getAsset={getAsset}
|
||||
/>
|
||||
</Sticky>
|
||||
<div ref={this.handleRef} />
|
||||
<div className={styles.shim} />
|
||||
</div>);
|
||||
|
@ -1,28 +0,0 @@
|
||||
export const emptyParagraphBlock = {
|
||||
nodes: [
|
||||
{
|
||||
kind: 'block',
|
||||
type: 'paragraph',
|
||||
nodes: [{
|
||||
kind: 'text',
|
||||
ranges: [{
|
||||
text: '',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mediaproxyBlock = mediaproxy => ({
|
||||
kind: 'block',
|
||||
type: 'paragraph',
|
||||
nodes: [{
|
||||
kind: 'inline',
|
||||
type: 'mediaproxy',
|
||||
isVoid: true,
|
||||
data: {
|
||||
alt: mediaproxy.name,
|
||||
src: mediaproxy.public_path,
|
||||
},
|
||||
}],
|
||||
});
|
@ -45,7 +45,7 @@ const buildtInPlugins = [{
|
||||
},
|
||||
toBlock: data => ``,
|
||||
toPreview: data => <img src={data.image} alt={data.alt} />,
|
||||
pattern: /^!\[([^\]]+)\]\(([^\)]+)\)$/,
|
||||
pattern: /^!\[([^\]]+)]\(([^)]+)\)$/,
|
||||
fields: [{
|
||||
label: 'Image',
|
||||
name: 'image',
|
||||
|
Loading…
x
Reference in New Issue
Block a user