initial refactor, some bugfixes
This commit is contained in:
@ -1,6 +1,6 @@
|
|||||||
@import "../../../../UI/theme";
|
@import "../../../../UI/theme";
|
||||||
|
|
||||||
.root {
|
.rawWrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -12,7 +12,7 @@
|
|||||||
composes: editorControlBarSticky from "../VisualEditor/index.css";
|
composes: editorControlBarSticky from "../VisualEditor/index.css";
|
||||||
}
|
}
|
||||||
|
|
||||||
.SlateEditor {
|
.rawEditor {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { PropTypes } from 'react';
|
import React, { PropTypes } from 'react';
|
||||||
import { Editor as SlateEditor, Plain as SlatePlain } from 'slate';
|
import { Editor as Slate, Plain } from 'slate';
|
||||||
import { markdownToRemark, remarkToMarkdown } from '../../unified';
|
import { markdownToRemark, remarkToMarkdown } from '../../unified';
|
||||||
import Toolbar from '../Toolbar/Toolbar';
|
import Toolbar from '../Toolbar/Toolbar';
|
||||||
import { Sticky } from '../../../../UI/Sticky/Sticky';
|
import { Sticky } from '../../../../UI/Sticky/Sticky';
|
||||||
@ -8,32 +8,44 @@ import styles from './index.css';
|
|||||||
export default class RawEditor extends React.Component {
|
export default class RawEditor extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
/**
|
||||||
|
* The value received is a Remark AST (MDAST), and must be stringified
|
||||||
|
* to plain text before Slate's Plain serializer can convert it to the
|
||||||
|
* Slate AST.
|
||||||
|
*/
|
||||||
const value = remarkToMarkdown(this.props.value);
|
const value = remarkToMarkdown(this.props.value);
|
||||||
this.state = {
|
this.state = {
|
||||||
editorState: SlatePlain.deserialize(value || ''),
|
editorState: Plain.deserialize(value || ''),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldComponentUpdate(nextProps, nextState) {
|
shouldComponentUpdate(nextProps, nextState) {
|
||||||
if (this.state.editorState.equals(nextState.editorState)) {
|
return !this.state.editorState.equals(nextState.editorState);
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChange = editorState => {
|
handleChange = editorState => {
|
||||||
this.setState({ editorState });
|
this.setState({ editorState });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the document value changes, serialize from Slate's AST back to plain
|
||||||
|
* text (which is Markdown), and then deserialize from that to a Remark MDAST,
|
||||||
|
* before passing up as the new value.
|
||||||
|
*/
|
||||||
handleDocumentChange = (doc, editorState) => {
|
handleDocumentChange = (doc, editorState) => {
|
||||||
const value = SlatePlain.serialize(editorState);
|
const value = Plain.serialize(editorState);
|
||||||
const html = markdownToRemark(value);
|
const mdast = markdownToRemark(value);
|
||||||
this.props.onChange(html);
|
this.props.onChange(mdast);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a paste contains plain text, deserialize it to Slate's AST and insert
|
||||||
|
* to the document. Selection logic (where to insert, whether to replace) is
|
||||||
|
* handled by Slate.
|
||||||
|
*/
|
||||||
handlePaste = (e, data, state) => {
|
handlePaste = (e, data, state) => {
|
||||||
if (data.text) {
|
if (data.text) {
|
||||||
const fragment = SlatePlain.deserialize(data.text).document;
|
const fragment = Plain.deserialize(data.text).document;
|
||||||
return state.transform().insertFragment(fragment).apply();
|
return state.transform().insertFragment(fragment).apply();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -44,7 +56,7 @@ export default class RawEditor extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.rawWrapper}>
|
||||||
<Sticky
|
<Sticky
|
||||||
className={styles.editorControlBar}
|
className={styles.editorControlBar}
|
||||||
classNameActive={styles.editorControlBarSticky}
|
classNameActive={styles.editorControlBarSticky}
|
||||||
@ -52,8 +64,8 @@ export default class RawEditor extends React.Component {
|
|||||||
>
|
>
|
||||||
<Toolbar onToggleMode={this.handleToggleMode} disabled rawMode />
|
<Toolbar onToggleMode={this.handleToggleMode} disabled rawMode />
|
||||||
</Sticky>
|
</Sticky>
|
||||||
<SlateEditor
|
<Slate
|
||||||
className={styles.SlateEditor}
|
className={styles.rawEditor}
|
||||||
state={this.state.editorState}
|
state={this.state.editorState}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
onDocumentChange={this.handleDocumentChange}
|
onDocumentChange={this.handleDocumentChange}
|
||||||
|
@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import cn from 'classnames';
|
||||||
|
import styles from './index.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slate uses React components to render each type of node that it receives.
|
||||||
|
* This is the closest thing Slate has to a schema definition. The types are set
|
||||||
|
* by us when we manually deserialize from Remark's MDAST to Slate's AST.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MARK_COMPONENTS = {
|
||||||
|
bold: props => <strong>{props.children}</strong>,
|
||||||
|
italic: props => <em>{props.children}</em>,
|
||||||
|
strikethrough: props => <s>{props.children}</s>,
|
||||||
|
code: props => <code>{props.children}</code>,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NODE_COMPONENTS = {
|
||||||
|
paragraph: props => <p {...props.attributes}>{props.children}</p>,
|
||||||
|
'list-item': props => <li {...props.attributes}>{props.children}</li>,
|
||||||
|
'bulleted-list': props => <ul {...props.attributes}>{props.children}</ul>,
|
||||||
|
'numbered-list': props =>
|
||||||
|
<ol {...props.attributes} start={props.node.data.get('start') || 1}>{props.children}</ol>,
|
||||||
|
quote: props => <blockquote {...props.attributes}>{props.children}</blockquote>,
|
||||||
|
code: props => <pre><code {...props.attributes}>{props.children}</code></pre>,
|
||||||
|
'heading-one': props => <h1 {...props.attributes}>{props.children}</h1>,
|
||||||
|
'heading-two': props => <h2 {...props.attributes}>{props.children}</h2>,
|
||||||
|
'heading-three': props => <h3 {...props.attributes}>{props.children}</h3>,
|
||||||
|
'heading-four': props => <h4 {...props.attributes}>{props.children}</h4>,
|
||||||
|
'heading-five': props => <h5 {...props.attributes}>{props.children}</h5>,
|
||||||
|
'heading-six': props => <h6 {...props.attributes}>{props.children}</h6>,
|
||||||
|
table: props => <table><tbody {...props.attributes}>{props.children}</tbody></table>,
|
||||||
|
'table-row': props => <tr {...props.attributes}>{props.children}</tr>,
|
||||||
|
'table-cell': props => <td {...props.attributes}>{props.children}</td>,
|
||||||
|
'thematic-break': props => <hr {...props.attributes}/>,
|
||||||
|
'shortcode-wrapper': props => <div {...props.attributes}>{props.children}</div>,
|
||||||
|
link: props => {
|
||||||
|
const data = props.node.get('data');
|
||||||
|
const url = data.get('url');
|
||||||
|
const title = data.get('title');
|
||||||
|
return <a href={href} title={title} {...props.attributes}>{props.children}</a>;
|
||||||
|
},
|
||||||
|
shortcode: props => {
|
||||||
|
const { attributes, node, state: editorState } = props;
|
||||||
|
const isSelected = editorState.selection.hasFocusIn(node);
|
||||||
|
const className = cn(styles.shortcode, { [styles.shortcodeSelected]: isSelected });
|
||||||
|
return <span {...attributes} className={className} draggable >{node.data.get('shortcode')}</span>;
|
||||||
|
},
|
||||||
|
};
|
@ -11,7 +11,7 @@
|
|||||||
border-color: var(--textFieldBorderColor);
|
border-color: var(--textFieldBorderColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor {
|
.wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
& h1, & h2, & h3 {
|
& h1, & h2, & h3 {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@ -49,26 +49,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dragging { }
|
.editor {
|
||||||
|
|
||||||
.shim {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
display: none;
|
|
||||||
border: 2px dashed #aaa;
|
|
||||||
background: rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dragging .shim {
|
|
||||||
z-index: 1000;
|
|
||||||
display: block;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slateEditor {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
@ -1,215 +1,37 @@
|
|||||||
import React, { Component, PropTypes } from 'react';
|
import React, { Component, PropTypes } from 'react';
|
||||||
import ReactDOMServer from 'react-dom/server';
|
import { get, isEmpty } from 'lodash';
|
||||||
import { Map, List, fromJS } from 'immutable';
|
import { Editor as Slate, Raw, Block, Text } from 'slate';
|
||||||
import { get, reduce, mapValues } from 'lodash';
|
import { slateToRemark, remarkToSlate, htmlToSlate } from '../../unified';
|
||||||
import cn from 'classnames';
|
|
||||||
import { Editor as SlateEditor, Raw as SlateRaw, Text as SlateText, Block as SlateBlock, Selection as SlateSelection} from 'slate';
|
|
||||||
import EditList from 'slate-edit-list';
|
|
||||||
import EditTable from 'slate-edit-table';
|
|
||||||
import { markdownToRemark, remarkToMarkdown, slateToRemark, remarkToSlate, markdownToHtml, htmlToSlate } from '../../unified';
|
|
||||||
import registry from '../../../../../lib/registry';
|
import registry from '../../../../../lib/registry';
|
||||||
import { createAssetProxy } from '../../../../../valueObjects/AssetProxy';
|
|
||||||
import Toolbar from '../Toolbar/Toolbar';
|
import Toolbar from '../Toolbar/Toolbar';
|
||||||
import { Sticky } from '../../../../UI/Sticky/Sticky';
|
import { Sticky } from '../../../../UI/Sticky/Sticky';
|
||||||
|
import { MARK_COMPONENTS, NODE_COMPONENTS } from './components';
|
||||||
|
import RULES from './rules';
|
||||||
|
import plugins, { EditListConfigured } from './plugins';
|
||||||
|
import onKeyDown from './keys';
|
||||||
import styles from './index.css';
|
import styles from './index.css';
|
||||||
|
|
||||||
const DEFAULT_NODE = 'paragraph';
|
|
||||||
|
|
||||||
const BLOCK_TAGS = {
|
|
||||||
p: 'paragraph',
|
|
||||||
li: 'list-item',
|
|
||||||
ul: 'bulleted-list',
|
|
||||||
ol: 'numbered-list',
|
|
||||||
blockquote: 'quote',
|
|
||||||
pre: 'code',
|
|
||||||
h1: 'heading-one',
|
|
||||||
h2: 'heading-two',
|
|
||||||
h3: 'heading-three',
|
|
||||||
h4: 'heading-four',
|
|
||||||
h5: 'heading-five',
|
|
||||||
h6: 'heading-six',
|
|
||||||
}
|
|
||||||
|
|
||||||
const MARK_TAGS = {
|
|
||||||
strong: 'bold',
|
|
||||||
em: 'italic',
|
|
||||||
u: 'underline',
|
|
||||||
s: 'strikethrough',
|
|
||||||
del: 'strikethrough',
|
|
||||||
code: 'code'
|
|
||||||
}
|
|
||||||
|
|
||||||
const BLOCK_COMPONENTS = {
|
|
||||||
'container': props => <div {...props.attributes}>{props.children}</div>,
|
|
||||||
'paragraph': props => <p {...props.attributes}>{props.children}</p>,
|
|
||||||
'list-item': props => <li {...props.attributes}>{props.children}</li>,
|
|
||||||
'numbered-list': props => {
|
|
||||||
const { data } = props.node;
|
|
||||||
const start = data.get('start') || 1;
|
|
||||||
return <ol {...props.attributes} start={start}>{props.children}</ol>;
|
|
||||||
},
|
|
||||||
'bulleted-list': props => <ul {...props.attributes}>{props.children}</ul>,
|
|
||||||
'quote': props => <blockquote {...props.attributes}>{props.children}</blockquote>,
|
|
||||||
'code': props => <pre><code {...props.attributes}>{props.children}</code></pre>,
|
|
||||||
'heading-one': props => <h1 {...props.attributes}>{props.children}</h1>,
|
|
||||||
'heading-two': props => <h2 {...props.attributes}>{props.children}</h2>,
|
|
||||||
'heading-three': props => <h3 {...props.attributes}>{props.children}</h3>,
|
|
||||||
'heading-four': props => <h4 {...props.attributes}>{props.children}</h4>,
|
|
||||||
'heading-five': props => <h5 {...props.attributes}>{props.children}</h5>,
|
|
||||||
'heading-six': props => <h6 {...props.attributes}>{props.children}</h6>,
|
|
||||||
'image': props => {
|
|
||||||
const data = props.node && props.node.get('data');
|
|
||||||
const src = data.get('url');
|
|
||||||
const alt = data.get('alt');
|
|
||||||
const title = data.get('title');
|
|
||||||
return <div><img src={src} alt={alt} title={title}{...props.attributes}/></div>;
|
|
||||||
},
|
|
||||||
'table': props => <table><tbody {...props.attributes}>{props.children}</tbody></table>,
|
|
||||||
'table-row': props => <tr {...props.attributes}>{props.children}</tr>,
|
|
||||||
'table-cell': props => <td {...props.attributes}>{props.children}</td>,
|
|
||||||
'thematic-break': props => <hr {...props.attributes}/>,
|
|
||||||
};
|
|
||||||
const getShortcodeId = props => {
|
|
||||||
if (props.node) {
|
|
||||||
const result = props.node.getIn(['data', 'shortcode', 'shortcodeId']);
|
|
||||||
return result || props.node.getIn(['data', 'shortcode']).shortcodeId;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortcodeStyles = {border: '2px solid black', padding: '8px', margin: '2px 0', cursor: 'pointer'};
|
|
||||||
|
|
||||||
const NODE_COMPONENTS = {
|
|
||||||
...BLOCK_COMPONENTS,
|
|
||||||
'link': props => {
|
|
||||||
const data = props.node.get('data');
|
|
||||||
const href = data.get('url');
|
|
||||||
const title = data.get('title');
|
|
||||||
return <a href={href} title={title} {...props.attributes}>{props.children}</a>;
|
|
||||||
},
|
|
||||||
'shortcode': props => {
|
|
||||||
const { attributes, node, state: editorState } = props;
|
|
||||||
const { data } = node;
|
|
||||||
const isSelected = editorState.selection.hasFocusIn(node);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(styles.shortcode, { [styles.shortcodeSelected]: isSelected })}
|
|
||||||
{...attributes}
|
|
||||||
draggable
|
|
||||||
>
|
|
||||||
{data.get('shortcode')}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const MARK_COMPONENTS = {
|
|
||||||
bold: props => <strong>{props.children}</strong>,
|
|
||||||
italic: props => <em>{props.children}</em>,
|
|
||||||
strikethrough: props => <s>{props.children}</s>,
|
|
||||||
code: props => <code>{props.children}</code>,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SoftBreak = (options = {}) => ({
|
|
||||||
onKeyDown(e, data, state) {
|
|
||||||
if (data.key != 'enter') return;
|
|
||||||
if (options.shift && e.shiftKey == false) return;
|
|
||||||
|
|
||||||
const { onlyIn, ignoreIn, closeAfter, unwrapBlocks, defaultBlock = 'paragraph' } = options;
|
|
||||||
const { type, nodes } = state.startBlock;
|
|
||||||
if (onlyIn && !onlyIn.includes(type)) return;
|
|
||||||
if (ignoreIn && ignoreIn.includes(type)) return;
|
|
||||||
|
|
||||||
const shouldClose = nodes.last().characters.takeLast(closeAfter).every(c => c.text === '\n');
|
|
||||||
if (closeAfter && shouldClose) {
|
|
||||||
const trimmed = state.transform().deleteBackward(closeAfter);
|
|
||||||
const unwrapped = unwrapBlocks
|
|
||||||
? unwrapBlocks.reduce((acc, blockType) => acc.unwrapBlock(blockType), trimmed)
|
|
||||||
: trimmed;
|
|
||||||
return unwrapped.insertBlock(defaultBlock).apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.transform().insertText('\n').apply();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const BackspaceCloseBlock = (options = {}) => ({
|
|
||||||
onKeyDown(e, data, state) {
|
|
||||||
if (data.key != 'backspace') return;
|
|
||||||
|
|
||||||
const { defaultBlock = 'paragraph', ignoreIn, onlyIn } = options;
|
|
||||||
const { startBlock } = state;
|
|
||||||
const { type } = startBlock;
|
|
||||||
|
|
||||||
if (onlyIn && !onlyIn.includes(type)) return;
|
|
||||||
if (ignoreIn && ignoreIn.includes(type)) return;
|
|
||||||
|
|
||||||
const characters = startBlock.getFirstText().characters;
|
|
||||||
const isEmpty = !characters || characters.isEmpty();
|
|
||||||
|
|
||||||
if (isEmpty) {
|
|
||||||
return state.transform().insertBlock(defaultBlock).focus().apply();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const EditListPlugin = EditList({ types: ['bulleted-list', 'numbered-list'], typeItem: 'list-item' });
|
|
||||||
|
|
||||||
const slatePlugins = [
|
|
||||||
SoftBreak({ ignoreIn: ['paragraph', 'list-item', 'numbered-list', 'bulleted-list', 'table', 'table-row', 'table-cell'], closeAfter: 1 }),
|
|
||||||
BackspaceCloseBlock({ ignoreIn: ['paragraph', 'list-item', 'bulleted-list', 'numbered-list', 'table', 'table-row', 'table-cell'] }),
|
|
||||||
EditListPlugin,
|
|
||||||
EditTable({ typeTable: 'table', typeRow: 'table-row', typeCell: 'table-cell' }),
|
|
||||||
];
|
|
||||||
|
|
||||||
export default class Editor extends Component {
|
export default class Editor extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const plugins = registry.getEditorComponents();
|
const emptyBlock = Block.create({ kind: 'block', type: 'paragraph'});
|
||||||
const emptyRaw = {
|
const emptyRaw = { nodes: [emptyBlock] };
|
||||||
nodes: [{ kind: 'block', type: 'paragraph', nodes: [
|
const mdast = this.props.value && remarkToSlate(this.props.value);
|
||||||
{ kind: 'text', ranges: [{ text: '' }] }
|
const mdastHasNodes = !isEmpty(get(mdast, 'nodes'))
|
||||||
]}],
|
const editorState = Raw.deserialize(mdastHasNodes ? mdast : emptyRaw, { terse: true });
|
||||||
};
|
|
||||||
const remark = this.props.value && remarkToSlate(this.props.value);
|
|
||||||
const initialValue = get(remark, ['nodes', 'length']) ? remark : emptyRaw;
|
|
||||||
const editorState = SlateRaw.deserialize(initialValue, { terse: true });
|
|
||||||
this.state = {
|
this.state = {
|
||||||
editorState,
|
editorState,
|
||||||
schema: {
|
schema: {
|
||||||
nodes: NODE_COMPONENTS,
|
nodes: NODE_COMPONENTS,
|
||||||
marks: MARK_COMPONENTS,
|
marks: MARK_COMPONENTS,
|
||||||
rules: [
|
rules: RULES,
|
||||||
/**
|
|
||||||
* If the editor is ever in an empty state, insert an empty
|
|
||||||
* paragraph block.
|
|
||||||
*/
|
|
||||||
{
|
|
||||||
match: object => object.kind === 'document',
|
|
||||||
validate: doc => {
|
|
||||||
const hasBlocks = !doc.getBlocks().isEmpty();
|
|
||||||
return hasBlocks ? null : {};
|
|
||||||
},
|
|
||||||
normalize: transform => {
|
|
||||||
const block = SlateBlock.create({
|
|
||||||
type: 'paragraph',
|
|
||||||
nodes: [SlateText.createFromString('')],
|
|
||||||
});
|
|
||||||
const { key } = transform.state.document;
|
|
||||||
return transform.insertNodeByKey(key, 0, block).focus();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
plugins,
|
shortcodes: registry.getEditorComponents(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldComponentUpdate(nextProps, nextState) {
|
shouldComponentUpdate(nextProps, nextState) {
|
||||||
if (this.state.editorState.equals(nextState.editorState)) {
|
return !this.state.editorState.equals(nextState.editorState);
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePaste = (e, data, state) => {
|
handlePaste = (e, data, state) => {
|
||||||
@ -217,12 +39,12 @@ export default class Editor extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ast = htmlToSlate(data.html);
|
const ast = htmlToSlate(data.html);
|
||||||
const { document: doc } = SlateRaw.deserialize(ast, { terse: true });
|
const { document: doc } = Raw.deserialize(ast, { terse: true });
|
||||||
return state.transform().insertFragment(doc).apply();
|
return state.transform().insertFragment(doc).apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDocumentChange = (doc, editorState) => {
|
handleDocumentChange = (doc, editorState) => {
|
||||||
const raw = SlateRaw.serialize(editorState, { terse: true });
|
const raw = Raw.serialize(editorState, { terse: true });
|
||||||
const mdast = slateToRemark(raw);
|
const mdast = slateToRemark(raw);
|
||||||
this.props.onChange(mdast);
|
this.props.onChange(mdast);
|
||||||
};
|
};
|
||||||
@ -230,70 +52,6 @@ export default class Editor extends Component {
|
|||||||
hasMark = type => this.state.editorState.marks.some(mark => mark.type === type);
|
hasMark = type => this.state.editorState.marks.some(mark => mark.type === type);
|
||||||
hasBlock = type => this.state.editorState.blocks.some(node => node.type === type);
|
hasBlock = type => this.state.editorState.blocks.some(node => node.type === type);
|
||||||
|
|
||||||
handleKeyDown = (e, data, state) => {
|
|
||||||
const createDefaultBlock = () => {
|
|
||||||
return SlateBlock.create({
|
|
||||||
type: 'paragraph',
|
|
||||||
nodes: [SlateText.createFromString('')]
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if (data.key === 'enter') {
|
|
||||||
/**
|
|
||||||
* If "Enter" is pressed while a single void block is selected, a new
|
|
||||||
* paragraph should be added above or below it, and the current selection
|
|
||||||
* should be collapsed to the start of the new paragraph.
|
|
||||||
*
|
|
||||||
* If the selected block is the first block in the document, create the
|
|
||||||
* new block above it. If not, create the new block below it.
|
|
||||||
*/
|
|
||||||
const { document: doc, selection, anchorBlock, focusBlock } = state;
|
|
||||||
const singleBlockSelected = anchorBlock === focusBlock;
|
|
||||||
if (!singleBlockSelected || !focusBlock.isVoid) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const focusBlockParent = doc.getParent(focusBlock.key);
|
|
||||||
const focusBlockIndex = focusBlockParent.nodes.indexOf(focusBlock);
|
|
||||||
const focusBlockIsFirstChild = focusBlockIndex === 0;
|
|
||||||
|
|
||||||
const newBlock = createDefaultBlock();
|
|
||||||
const newBlockIndex = focusBlockIsFirstChild ? 0 : focusBlockIndex + 1;
|
|
||||||
|
|
||||||
return state.transform()
|
|
||||||
.insertNodeByKey(focusBlockParent.key, newBlockIndex, newBlock)
|
|
||||||
.collapseToStartOf(newBlock)
|
|
||||||
.apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.isMod) {
|
|
||||||
|
|
||||||
if (data.key === 'y') {
|
|
||||||
e.preventDefault();
|
|
||||||
return state.transform().redo().focus().apply({ save: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.key === 'z') {
|
|
||||||
e.preventDefault();
|
|
||||||
return state.transform()[data.isShift ? 'redo' : 'undo']().focus().apply({ save: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
const marks = {
|
|
||||||
b: 'bold',
|
|
||||||
i: 'italic',
|
|
||||||
u: 'underlined',
|
|
||||||
s: 'strikethrough',
|
|
||||||
'`': 'code',
|
|
||||||
};
|
|
||||||
|
|
||||||
const mark = marks[data.key];
|
|
||||||
|
|
||||||
if (mark) {
|
|
||||||
e.preventDefault();
|
|
||||||
return state.transform().toggleMark(mark).apply();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMarkClick = (event, type) => {
|
handleMarkClick = (event, type) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const resolvedState = this.state.editorState.transform().focus().toggleMark(type).apply();
|
const resolvedState = this.state.editorState.transform().focus().toggleMark(type).apply();
|
||||||
@ -310,7 +68,7 @@ export default class Editor extends Component {
|
|||||||
// Handle everything except list buttons.
|
// Handle everything except list buttons.
|
||||||
if (!['bulleted-list', 'numbered-list'].includes(type)) {
|
if (!['bulleted-list', 'numbered-list'].includes(type)) {
|
||||||
const isActive = this.hasBlock(type);
|
const isActive = this.hasBlock(type);
|
||||||
const transformed = transform.setBlock(isActive ? DEFAULT_NODE : type);
|
const transformed = transform.setBlock(isActive ? 'paragraph' : type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle the extra wrapping required for list buttons.
|
// Handle the extra wrapping required for list buttons.
|
||||||
@ -318,16 +76,16 @@ export default class Editor extends Component {
|
|||||||
const isSameListType = editorState.blocks.some(block => {
|
const isSameListType = editorState.blocks.some(block => {
|
||||||
return !!doc.getClosest(block.key, parent => parent.type === type);
|
return !!doc.getClosest(block.key, parent => parent.type === type);
|
||||||
});
|
});
|
||||||
const isInList = EditListPlugin.utils.isSelectionInList(editorState);
|
const isInList = EditListConfigured.utils.isSelectionInList(editorState);
|
||||||
|
|
||||||
if (isInList && isSameListType) {
|
if (isInList && isSameListType) {
|
||||||
EditListPlugin.transforms.unwrapList(transform, type);
|
EditListConfigured.transforms.unwrapList(transform, type);
|
||||||
} else if (isInList) {
|
} else if (isInList) {
|
||||||
const currentListType = type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list';
|
const currentListType = type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list';
|
||||||
EditListPlugin.transforms.unwrapList(transform, currentListType);
|
EditListConfigured.transforms.unwrapList(transform, currentListType);
|
||||||
EditListPlugin.transforms.wrapInList(transform, type);
|
EditListConfigured.transforms.wrapInList(transform, type);
|
||||||
} else {
|
} else {
|
||||||
EditListPlugin.transforms.wrapInList(transform, type);
|
EditListConfigured.transforms.wrapInList(transform, type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -381,7 +139,7 @@ export default class Editor extends Component {
|
|||||||
shortcodeValue: plugin.toBlock(shortcodeData.toJS()),
|
shortcodeValue: plugin.toBlock(shortcodeData.toJS()),
|
||||||
shortcodeData,
|
shortcodeData,
|
||||||
};
|
};
|
||||||
const nodes = [SlateText.createFromString('')];
|
const nodes = [Text.createFromString('')];
|
||||||
const block = { kind: 'block', type: 'shortcode', data, isVoid: true, nodes };
|
const block = { kind: 'block', type: 'shortcode', data, isVoid: true, nodes };
|
||||||
const resolvedState = editorState.transform().insertBlock(block).apply();
|
const resolvedState = editorState.transform().insertBlock(block).apply();
|
||||||
this.ref.onChange(resolvedState);
|
this.ref.onChange(resolvedState);
|
||||||
@ -401,17 +159,15 @@ export default class Editor extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
|
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
|
||||||
const { plugins, selectionPosition, dragging } = this.state;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.editor}>
|
<div className={styles.wrapper}>
|
||||||
<Sticky
|
<Sticky
|
||||||
className={styles.editorControlBar}
|
className={styles.editorControlBar}
|
||||||
classNameActive={styles.editorControlBarSticky}
|
classNameActive={styles.editorControlBarSticky}
|
||||||
fillContainerWidth
|
fillContainerWidth
|
||||||
>
|
>
|
||||||
<Toolbar
|
<Toolbar
|
||||||
selectionPosition={selectionPosition}
|
|
||||||
buttons={{
|
buttons={{
|
||||||
bold: this.getButtonProps('bold'),
|
bold: this.getButtonProps('bold'),
|
||||||
italic: this.getButtonProps('italic'),
|
italic: this.getButtonProps('italic'),
|
||||||
@ -424,21 +180,21 @@ export default class Editor extends Component {
|
|||||||
codeBlock: this.getButtonProps('code', { isBlock: true }),
|
codeBlock: this.getButtonProps('code', { isBlock: true }),
|
||||||
}}
|
}}
|
||||||
onToggleMode={this.handleToggle}
|
onToggleMode={this.handleToggle}
|
||||||
plugins={plugins}
|
plugins={this.state.shortcodes}
|
||||||
onSubmit={this.handlePluginSubmit}
|
onSubmit={this.handlePluginSubmit}
|
||||||
onAddAsset={onAddAsset}
|
onAddAsset={onAddAsset}
|
||||||
onRemoveAsset={onRemoveAsset}
|
onRemoveAsset={onRemoveAsset}
|
||||||
getAsset={getAsset}
|
getAsset={getAsset}
|
||||||
/>
|
/>
|
||||||
</Sticky>
|
</Sticky>
|
||||||
<SlateEditor
|
<Slate
|
||||||
className={styles.slateEditor}
|
className={styles.editor}
|
||||||
state={this.state.editorState}
|
state={this.state.editorState}
|
||||||
schema={this.state.schema}
|
schema={this.state.schema}
|
||||||
plugins={slatePlugins}
|
plugins={plugins}
|
||||||
onChange={editorState => this.setState({ editorState })}
|
onChange={editorState => this.setState({ editorState })}
|
||||||
onDocumentChange={this.handleDocumentChange}
|
onDocumentChange={this.handleDocumentChange}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onPaste={this.handlePaste}
|
onPaste={this.handlePaste}
|
||||||
ref={ref => this.ref = ref}
|
ref={ref => this.ref = ref}
|
||||||
spellCheck
|
spellCheck
|
||||||
|
@ -0,0 +1,67 @@
|
|||||||
|
import { Block, Text } from 'slate';
|
||||||
|
|
||||||
|
export default onKeyDown;
|
||||||
|
|
||||||
|
function onKeyDown(e, data, state) {
|
||||||
|
const createDefaultBlock = () => {
|
||||||
|
return Block.create({
|
||||||
|
type: 'paragraph',
|
||||||
|
nodes: [Text.createFromString('')]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (data.key === 'enter') {
|
||||||
|
/**
|
||||||
|
* If "Enter" is pressed while a single void block is selected, a new
|
||||||
|
* paragraph should be added above or below it, and the current selection
|
||||||
|
* should be collapsed to the start of the new paragraph.
|
||||||
|
*
|
||||||
|
* If the selected block is the first block in the document, create the
|
||||||
|
* new block above it. If not, create the new block below it.
|
||||||
|
*/
|
||||||
|
const { document: doc, selection, anchorBlock, focusBlock } = state;
|
||||||
|
const singleBlockSelected = anchorBlock === focusBlock;
|
||||||
|
if (!singleBlockSelected || !focusBlock.isVoid) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const focusBlockParent = doc.getParent(focusBlock.key);
|
||||||
|
const focusBlockIndex = focusBlockParent.nodes.indexOf(focusBlock);
|
||||||
|
const focusBlockIsFirstChild = focusBlockIndex === 0;
|
||||||
|
|
||||||
|
const newBlock = createDefaultBlock();
|
||||||
|
const newBlockIndex = focusBlockIsFirstChild ? 0 : focusBlockIndex + 1;
|
||||||
|
|
||||||
|
return state.transform()
|
||||||
|
.insertNodeByKey(focusBlockParent.key, newBlockIndex, newBlock)
|
||||||
|
.collapseToStartOf(newBlock)
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.isMod) {
|
||||||
|
|
||||||
|
if (data.key === 'y') {
|
||||||
|
e.preventDefault();
|
||||||
|
return state.transform().redo().focus().apply({ save: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.key === 'z') {
|
||||||
|
e.preventDefault();
|
||||||
|
return state.transform()[data.isShift ? 'redo' : 'undo']().focus().apply({ save: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const marks = {
|
||||||
|
b: 'bold',
|
||||||
|
i: 'italic',
|
||||||
|
u: 'underlined',
|
||||||
|
s: 'strikethrough',
|
||||||
|
'`': 'code',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mark = marks[data.key];
|
||||||
|
|
||||||
|
if (mark) {
|
||||||
|
e.preventDefault();
|
||||||
|
return state.transform().toggleMark(mark).apply();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,90 @@
|
|||||||
|
import EditList from 'slate-edit-list';
|
||||||
|
import EditTable from 'slate-edit-table';
|
||||||
|
|
||||||
|
const SoftBreak = (options = {}) => ({
|
||||||
|
onKeyDown(e, data, state) {
|
||||||
|
if (data.key != 'enter') return;
|
||||||
|
if (options.shift && e.shiftKey == false) return;
|
||||||
|
|
||||||
|
const { onlyIn, ignoreIn, closeAfter, unwrapBlocks, defaultBlock = 'paragraph' } = options;
|
||||||
|
const { type, nodes } = state.startBlock;
|
||||||
|
if (onlyIn && !onlyIn.includes(type)) return;
|
||||||
|
if (ignoreIn && ignoreIn.includes(type)) return;
|
||||||
|
|
||||||
|
const shouldClose = nodes.last().characters.takeLast(closeAfter).every(c => c.text === '\n');
|
||||||
|
if (closeAfter && shouldClose) {
|
||||||
|
const trimmed = state.transform().deleteBackward(closeAfter);
|
||||||
|
const unwrapped = unwrapBlocks
|
||||||
|
? unwrapBlocks.reduce((acc, blockType) => acc.unwrapBlock(blockType), trimmed)
|
||||||
|
: trimmed;
|
||||||
|
return unwrapped.insertBlock(defaultBlock).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.transform().insertText('\n').apply();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const SoftBreakOpts = {
|
||||||
|
onlyIn: ['quote', 'code'],
|
||||||
|
closeAfter: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SoftBreakConfigured = SoftBreak(SoftBreakOpts);
|
||||||
|
|
||||||
|
const BackspaceCloseBlock = (options = {}) => ({
|
||||||
|
onKeyDown(e, data, state) {
|
||||||
|
if (data.key != 'backspace') return;
|
||||||
|
|
||||||
|
const { defaultBlock = 'paragraph', ignoreIn, onlyIn } = options;
|
||||||
|
const { startBlock } = state;
|
||||||
|
const { type } = startBlock;
|
||||||
|
|
||||||
|
if (onlyIn && !onlyIn.includes(type)) return;
|
||||||
|
if (ignoreIn && ignoreIn.includes(type)) return;
|
||||||
|
|
||||||
|
const characters = startBlock.getFirstText().characters;
|
||||||
|
const isEmpty = !characters || characters.isEmpty();
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return state.transform().insertBlock(defaultBlock).focus().apply();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const BackspaceCloseBlockOpts = {
|
||||||
|
ignoreIn: [
|
||||||
|
'paragraph',
|
||||||
|
'list-item',
|
||||||
|
'bulleted-list',
|
||||||
|
'numbered-list',
|
||||||
|
'table',
|
||||||
|
'table-row',
|
||||||
|
'table-cell',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BackspaceCloseBlockConfigured = BackspaceCloseBlock(BackspaceCloseBlockOpts);
|
||||||
|
|
||||||
|
const EditListOpts = {
|
||||||
|
types: ['bulleted-list', 'numbered-list'],
|
||||||
|
typeItem: 'list-item',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditListConfigured = EditList(EditListOpts);
|
||||||
|
|
||||||
|
const EditTableOpts = {
|
||||||
|
typeTable: 'table',
|
||||||
|
typeRow: 'table-row',
|
||||||
|
typeCell: 'table-cell',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditTableConfigured = EditTable(EditTableOpts);
|
||||||
|
|
||||||
|
const plugins = [
|
||||||
|
SoftBreakConfigured,
|
||||||
|
BackspaceCloseBlockConfigured,
|
||||||
|
EditListConfigured,
|
||||||
|
EditTableConfigured,
|
||||||
|
];
|
||||||
|
|
||||||
|
export default plugins;
|
@ -0,0 +1,30 @@
|
|||||||
|
import { Block, Text } from 'slate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rules are used to validate the editor state each time it changes, to ensure
|
||||||
|
* it is never rendered in an undesirable state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the editor is ever in an empty state, insert an empty
|
||||||
|
* paragraph block.
|
||||||
|
*/
|
||||||
|
const enforceNeverEmpty = {
|
||||||
|
match: object => object.kind === 'document',
|
||||||
|
validate: doc => {
|
||||||
|
const hasBlocks = !doc.getBlocks().isEmpty();
|
||||||
|
return hasBlocks ? null : {};
|
||||||
|
},
|
||||||
|
normalize: transform => {
|
||||||
|
const block = Block.create({
|
||||||
|
type: 'paragraph',
|
||||||
|
nodes: [Text.createFromString('')],
|
||||||
|
});
|
||||||
|
const { key } = transform.state.document;
|
||||||
|
return transform.insertNodeByKey(key, 0, block).focus();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const rules = [ enforceNeverEmpty ];
|
||||||
|
|
||||||
|
export default rules;
|
@ -8,10 +8,10 @@ import { StickyContainer } from '../../../UI/Sticky/Sticky';
|
|||||||
const MODE_STORAGE_KEY = 'cms.md-mode';
|
const MODE_STORAGE_KEY = 'cms.md-mode';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slate can serialize to html, but we persist the value as markdown. Serializing
|
* The markdown field value is persisted as a markdown string, but stringifying
|
||||||
* the html to markdown on every keystroke is a big perf hit, so we'll register
|
* on every keystroke is a big perf hit, so we'll register functions to perform
|
||||||
* functions to perform those actions only when necessary, such as after loading
|
* those actions only when necessary, such as after loading and before
|
||||||
* and before persisting.
|
* persisting.
|
||||||
*/
|
*/
|
||||||
registry.registerWidgetValueSerializer('markdown', {
|
registry.registerWidgetValueSerializer('markdown', {
|
||||||
serialize: remarkToMarkdown,
|
serialize: remarkToMarkdown,
|
||||||
|
@ -392,8 +392,11 @@ const remarkToSlatePlugin = () => {
|
|||||||
|
|
||||||
if (node.type === 'linkReference') {
|
if (node.type === 'linkReference') {
|
||||||
const definition = getDefinition(node.identifier);
|
const definition = getDefinition(node.identifier);
|
||||||
const { title, url } = definition;
|
const data = {};
|
||||||
const data = { title, url };
|
if (definition) {
|
||||||
|
data.title = definition.title;
|
||||||
|
data.url = definition.url;
|
||||||
|
}
|
||||||
return { kind: 'inline', type: typeMap['link'], data, nodes };
|
return { kind: 'inline', type: typeMap['link'], data, nodes };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,8 +408,11 @@ const remarkToSlatePlugin = () => {
|
|||||||
|
|
||||||
if (node.type === 'imageReference') {
|
if (node.type === 'imageReference') {
|
||||||
const definition = getDefinition(node.identifier);
|
const definition = getDefinition(node.identifier);
|
||||||
const { title, url } = definition;
|
const data = {};
|
||||||
const data = { title, url };
|
if (definition) {
|
||||||
|
data.title = definition.title;
|
||||||
|
data.url = definition.url;
|
||||||
|
}
|
||||||
return { kind: 'block', type: typeMap['image'], data };
|
return { kind: 'block', type: typeMap['image'], data };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -536,6 +542,10 @@ export const slateToRemark = raw => {
|
|||||||
return u('html', { data: node.data }, node.data.shortcodeValue);
|
return u('html', { data: node.data }, node.data.shortcodeValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.type === 'shortcode-wrapper') {
|
||||||
|
return u('paragraph', children);
|
||||||
|
}
|
||||||
|
|
||||||
if (node.type.startsWith('heading')) {
|
if (node.type.startsWith('heading')) {
|
||||||
const depths = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
|
const depths = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
|
||||||
const depth = node.type.split('-')[1];
|
const depth = node.type.split('-')[1];
|
||||||
@ -597,22 +607,6 @@ export const remarkToHtml = (mdast, getAsset) => {
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
export const markdownToHtml = markdown => {
|
|
||||||
// Parse shortcodes from the raw markdown rather than via Unified plugin.
|
|
||||||
// This ensures against conflicts between shortcode syntax and Unified
|
|
||||||
// parsing rules.
|
|
||||||
const markdownWithParsedShortcodes = parseShortcodesFromMarkdown(markdown);
|
|
||||||
const result = unified()
|
|
||||||
.use(markdownToRemarkPlugin, { fences: true, pedantic: true, footnotes: true, commonmark: true })
|
|
||||||
.use(remarkToRehype, { allowDangerousHTML: true })
|
|
||||||
.use(rehypeRemoveEmpty)
|
|
||||||
.use(rehypeMinifyWhitespace)
|
|
||||||
.use(rehypeToHtml, { allowDangerousHTML: true })
|
|
||||||
.processSync(markdownWithParsedShortcodes)
|
|
||||||
.contents;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const htmlToSlate = html => {
|
export const htmlToSlate = html => {
|
||||||
const hast = unified()
|
const hast = unified()
|
||||||
.use(htmlToRehype, { fragment: true })
|
.use(htmlToRehype, { fragment: true })
|
||||||
|
Reference in New Issue
Block a user