migrate visual editor from prosemirror to slate

This commit is contained in:
Shawn Erquhart 2017-06-19 17:15:59 -04:00
parent 49b3a62823
commit 9c869be8fa
8 changed files with 299 additions and 225 deletions

View File

@ -15,8 +15,6 @@ collections: # A list of collections the CMS should be able to edit
- {label: "Publish Date", name: "date", widget: "datetime", format: "YYYY-MM-DD hh:mma"} - {label: "Publish Date", name: "date", widget: "datetime", format: "YYYY-MM-DD hh:mma"}
- {label: "Cover Image", name: "image", widget: "image", required: false, tagname: ""} - {label: "Cover Image", name: "image", widget: "image", required: false, tagname: ""}
- {label: "Body", name: "body", widget: "markdown"} - {label: "Body", name: "body", widget: "markdown"}
- {label: "Body B", name: "bodyb", widget: "markdown"}
- {label: "Body C", name: "bodyc", widget: "markdown"}
meta: meta:
- {label: "SEO Description", name: "description", widget: "text"} - {label: "SEO Description", name: "description", widget: "text"}

View File

@ -169,7 +169,7 @@
"remark-stringify": "^3.0.1", "remark-stringify": "^3.0.1",
"selection-position": "^1.0.0", "selection-position": "^1.0.0",
"semaphore": "^1.0.5", "semaphore": "^1.0.5",
"slate": "^0.14.14", "slate": "^0.20.3",
"slate-drop-or-paste-images": "^0.2.0", "slate-drop-or-paste-images": "^0.2.0",
"slug": "^0.9.1", "slug": "^0.9.1",
"textarea-caret-position": "^0.1.1", "textarea-caret-position": "^0.1.1",

View File

@ -332,11 +332,13 @@ export default class RawEditor extends React.Component {
> >
<Toolbar <Toolbar
selectionPosition={selectionPosition} selectionPosition={selectionPosition}
onH1={this.handleHeader('#')} buttons={{
onH2={this.handleHeader('##')} h1: { onAction: this.handleHeader('#') },
onBold={this.handleBold} h2: { onAction: this.handleHeader('##') },
onItalic={this.handleItalic} bold: { onAction: this.handleBold },
onLink={this.handleLink} italic: { onAction: this.handleItalic },
link: { onAction: this.handleLink },
}}
onToggleMode={this.handleToggle} onToggleMode={this.handleToggle}
plugins={plugins} plugins={plugins}
onSubmit={this.handlePluginSubmit} onSubmit={this.handlePluginSubmit}

View File

@ -10,12 +10,7 @@ import styles from './Toolbar.css';
export default class Toolbar extends React.Component { export default class Toolbar extends React.Component {
static propTypes = { static propTypes = {
selectionPosition: PropTypes.object, buttons: PropTypes.object.isRequired,
onH1: PropTypes.func.isRequired,
onH2: PropTypes.func.isRequired,
onBold: PropTypes.func.isRequired,
onItalic: PropTypes.func.isRequired,
onLink: PropTypes.func.isRequired,
onToggleMode: PropTypes.func.isRequired, onToggleMode: PropTypes.func.isRequired,
rawMode: PropTypes.bool, rawMode: PropTypes.bool,
plugins: ImmutablePropTypes.map, plugins: ImmutablePropTypes.map,
@ -47,11 +42,7 @@ export default class Toolbar extends React.Component {
render() { render() {
const { const {
onH1, buttons,
onH2,
onBold,
onItalic,
onLink,
onToggleMode, onToggleMode,
rawMode, rawMode,
plugins, plugins,
@ -62,13 +53,19 @@ export default class Toolbar extends React.Component {
const { activePlugin } = this.state; const { activePlugin } = this.state;
const buttonsConfig = [
{ label: 'Header 1', icon: 'h1', state: buttons.h1 },
{ label: 'Header 2', icon: 'h2', state: buttons.h2 },
{ label: 'Bold', icon: 'bold', state: buttons.bold },
{ label: 'Italic', icon: 'italic', state: buttons.italic },
{ label: 'Link', icon: 'link', state: buttons.link },
];
return ( return (
<div className={styles.Toolbar}> <div className={styles.Toolbar}>
<ToolbarButton label="Header 1" icon="h1" action={onH1}/> { buttonsConfig.map((btn, i) => (
<ToolbarButton label="Header 2" icon="h2" action={onH2}/> <ToolbarButton key={i} action={btn.state.onAction} active={btn.state.active} {...btn}/>
<ToolbarButton label="Bold" icon="bold" action={onBold}/> ))}
<ToolbarButton label="Italic" icon="italic" action={onItalic}/>
<ToolbarButton label="Link" icon="link" action={onLink}/>
<ToolbarComponentsMenu <ToolbarComponentsMenu
plugins={plugins} plugins={plugins}
onComponentMenuItemClick={this.handlePluginFormDisplay} onComponentMenuItemClick={this.handlePluginFormDisplay}

View File

@ -68,86 +68,38 @@
pointer-events: none; pointer-events: none;
} }
:global { .slateEditor {
& .ProseMirror { position: relative;
position: relative; background-color: var(--controlBGColor);
background-color: var(--controlBGColor); padding: 12px;
padding: 12px; overflow: hidden;
overflow: hidden; border-radius: var(--borderRadius);
border-radius: var(--borderRadius); overflow-x: auto;
overflow-x: auto; border: var(--textFieldBorder);
border: var(--textFieldBorder); min-height: var(--richTextEditorMinHeight);
min-height: var(--richTextEditorMinHeight);
& ul, & ul,
& ol { & ol {
padding-left: 20px; padding-left: 30px;
}
& pre > code {
display: block;
width: 100%;
overflow-y: auto;
background-color: #000;
color: #ccc;
border-radius: var(--borderRadius);
padding: 10px;
}
} }
& .ProseMirror-content { & pre {
white-space: pre-wrap; white-space: pre-wrap;
} }
& .ProseMirror-drop-target { & pre > code {
position: absolute; display: block;
width: 1px; width: 100%;
background: #666; overflow-y: auto;
pointer-events: none; background-color: #000;
color: #ccc;
border-radius: var(--borderRadius);
padding: 10px;
} }
& .ProseMirror-content ul, & .ProseMirror-content ol { & blockquote {
padding-left: 30px;
cursor: default;
}
& .ProseMirror-content blockquote {
padding-left: 1em; padding-left: 1em;
border-left: 3px solid #eee; border-left: 3px solid #eee;
margin-left: 0; margin-right: 0; margin-left: 0; margin-right: 0;
} }
& .ProseMirror-content pre {
white-space: pre-wrap;
}
& .ProseMirror-content li {
position: relative;
pointer-events: none; /* Don't do weird stuff with marker clicks */
}
& .ProseMirror-content li > * {
pointer-events: auto;
}
& .ProseMirror-nodeselection *::selection { background: transparent; }
& .ProseMirror-nodeselection *::-moz-selection { background: transparent; }
& .ProseMirror-selectednode {
outline: 2px solid #8cf;
}
/* Make sure li selections wrap around markers */
& li.ProseMirror-selectednode {
outline: none;
}
& li.ProseMirror-selectednode:after {
content: "";
position: absolute;
left: -32px;
right: -2px; top: -2px; bottom: -2px;
border: 2px solid #8cf;
pointer-events: none;
}
} }

View File

@ -1,19 +1,13 @@
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import { Map } from 'immutable'; import { Map, List } from 'immutable';
import { Schema } from 'prosemirror-model'; import { Editor as SlateEditor, Html as SlateHtml, Raw as SlateRaw} from 'slate';
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import history from 'prosemirror-history';
import {
blockQuoteRule, orderedListRule, bulletListRule, codeBlockRule, headingRule,
inputRules, allInputRules,
} from 'prosemirror-inputrules';
import { keymap } from 'prosemirror-keymap';
import { schema as markdownSchema, defaultMarkdownSerializer } from 'prosemirror-markdown';
import { baseKeymap, setBlockType, toggleMark } from 'prosemirror-commands';
import unified from 'unified'; import unified from 'unified';
import markdownToRemark from 'remark-parse'; import markdownToRemark from 'remark-parse';
import remarkToRehype from 'remark-rehype';
import rehypeToHtml from 'rehype-stringify';
import remarkToMarkdown from 'remark-stringify'; import remarkToMarkdown from 'remark-stringify';
import htmlToRehype from 'rehype-parse';
import rehypeToRemark from 'rehype-remark';
import registry from '../../../../lib/registry'; import registry from '../../../../lib/registry';
import { createAssetProxy } from '../../../../valueObjects/AssetProxy'; import { createAssetProxy } from '../../../../valueObjects/AssetProxy';
import { buildKeymap } from './keymap'; import { buildKeymap } from './keymap';
@ -32,28 +26,7 @@ function processUrl(url) {
return `/${ url }`; return `/${ url }`;
} }
const ruleset = { const DEFAULT_NODE = 'paragraph';
blockquote: [blockQuoteRule],
ordered_list: [orderedListRule],
bullet_list: [bulletListRule],
code_block: [codeBlockRule],
heading: [headingRule, 6],
};
function buildInputRules(schema) {
return Map(ruleset)
.filter(rule => schema.nodes[rule])
.map(rule => rule[0].apply(rule[0].slice(1)))
.toArray();
}
function markActive(state, type) {
const { from, to, empty, $from } = state.selection;
if (empty) {
return type.isInSet(state.storedMarks || $from.marks());
}
return state.doc.rangeHasMark(from, to, type);
}
function schemaWithPlugins(schema, plugins) { function schemaWithPlugins(schema, plugins) {
let nodeSpec = schema.nodeSpec; let nodeSpec = schema.nodeSpec;
@ -94,115 +67,229 @@ function createSerializer(schema, plugins) {
return serializer; return serializer;
} }
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',
code: 'code'
}
const NODE_COMPONENTS = {
'quote': props => <blockquote {...props.attributes}>{props.children}</blockquote>,
'bulleted-list': props => <ul {...props.attributes}>{props.children}</ul>,
'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>,
'list-item': props => <li {...props.attributes}>{props.children}</li>,
'numbered-list': props => <ol {...props.attributes}>{props.children}</ol>,
'code': props => <pre {...props.attributes}><code>{props.children}</code></pre>,
'link': props => <a href={props.node.data.href} {...props.attributes}>{props.children}</a>,
'paragraph': props => <p>{props.children}</p>,
};
const MARK_COMPONENTS = {
bold: props => <strong>{props.children}</strong>,
code: props => <code>{props.children}</code>,
italic: props => <em>{props.children}</em>,
underlined: props => <u>{props.children}</u>,
};
const RULES = [
{
deserialize(el, next) {
const block = BLOCK_TAGS[el.tagName]
if (!block) return
return {
kind: 'block',
type: block,
nodes: next(el.children)
}
},
serialize(entity, children) {
const component = NODE_COMPONENTS[entity.type]
if (!component) {
return;
}
return component({ children });
}
},
{
deserialize(el, next) {
const mark = MARK_TAGS[el.tagName]
if (!mark) return
return {
kind: 'mark',
type: mark,
nodes: next(el.children)
}
},
serialize(entity, children) {
const component = MARK_COMPONENTS[entity.type]
if (!component) {
return;
}
return component({ children });
}
},
{
// Special case for code blocks, which need to grab the nested children.
deserialize(el, next) {
if (el.tagName != 'pre') return
const code = el.children[0]
const children = code && code.tagName == 'code'
? code.children
: el.children
return {
kind: 'block',
type: 'code',
nodes: next(children)
}
},
},
{
// Special case for links, to grab their href.
deserialize(el, next) {
if (el.tagName != 'a') return
return {
kind: 'inline',
type: 'link',
nodes: next(el.children),
data: {
href: el.attribs.href
}
}
},
},
]
const serializer = new SlateHtml({ rules: RULES });
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 plugins = registry.getEditorComponents();
const schema = schemaWithPlugins(markdownSchema, plugins); const html = unified()
.use(markdownToRemark)
.use(remarkToRehype)
.use(rehypeToHtml)
.processSync(this.props.value || '')
.contents;
this.state = { this.state = {
editorState: serializer.deserialize(html),
schema: {
nodes: NODE_COMPONENTS,
marks: MARK_COMPONENTS,
},
plugins, plugins,
schema,
parser: createMarkdownParser(schema, plugins),
serializer: createSerializer(schema, plugins),
}; };
} }
componentDidMount() { handleDocumentChange = (doc, editorState) => {
this.view = new EditorView(this.ref, { const html = serializer.serialize(editorState);
state: this.createEditorState(), const markdown = unified()
onAction: this.handleAction, .use(htmlToRehype)
dispatchTransaction: this.handleTransaction, .use(rehypeToRemark)
}); .use(remarkToMarkdown)
} .processSync(html)
.contents;
createEditorState() { this.props.onChange(markdown);
const { schema, parser } = this.state;
const doc = parser.parse(this.props.value || '');
return EditorState.create({
doc,
schema,
});
}
componentDidUpdate(prevProps, prevState) {
const editorValue = this.state.serializer.serialize(this.view.state.doc);
// Check that the content of the editor is well synchronized with the props value after rendering.
// Sometimes the editor isn't well updated (eg. after items reordering)
if (editorValue !== this.props.value && editorValue !== prevProps.value) {
// If the content of the editor isn't correct, we update its state with a new one.
this.view.updateState(this.createEditorState());
}
}
handleTransaction = (transaction) => {
const { serializer } = this.state;
const newState = this.view.state.apply(transaction);
const md = serializer.serialize(newState.doc);
const processedMarkdown = unified()
.use(markdownToRemark)
.use(remarkToMarkdown, { fences: true, commonmark: true, footnotes: true, pedantic: true })
.processSync(md);
this.props.onChange(processedMarkdown.contents);
this.view.updateState(newState);
if (newState.selection !== this.state.selection) {
this.handleSelection(newState);
}
this.view.focus();
}; };
handleSelection = (state) => { hasMark = type => this.state.editorState.marks.some(mark => mark.type === type);
const { schema, selection } = state; hasBlock = type => this.state.editorState.blocks.some(node => node.type === type);
if (selection.from === selection.to) {
const { $from } = selection; handleKeyDown = (e, data, state) => {
if ($from.parent && $from.parent.type === schema.nodes.paragraph && $from.parent.textContent === '') { if (!data.isMod) {
const pos = this.view.coordsAtPos(selection.from); return;
const editorPos = this.view.content.getBoundingClientRect(); }
const selectionPosition = { top: pos.top - editorPos.top, left: pos.left - editorPos.left }; const marks = {
this.setState({ selectionPosition }); b: 'bold',
i: 'italic',
u: 'underlined',
'`': 'code',
};
const mark = marks[data.key];
if (mark) {
state = state.transform().toggleMark(mark).apply();
}
return;
};
handleMarkClick = (event, type) => {
event.preventDefault();
const resolvedState = this.state.editorState.transform().toggleMark(type).apply();
this.ref.onChange(resolvedState);
this.setState({ editorState: resolvedState });
};
handleBlockClick = (event, type) => {
event.preventDefault();
let { editorState } = this.state;
const transform = editorState.transform();
const doc = editorState.document;
const isList = this.hasBlock('list-item')
// Handle everything except list buttons.
if (!['bulleted-list', 'numbered-list'].includes(type)) {
const isActive = this.hasBlock(type);
const transformed = transform.setBlock(isActive ? DEFAULT_NODE : type);
if (isList) {
transformed
.unwrapBlock('bulleted-list')
.unwrapBlock('numbered-list');
} }
} 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({ selectionPosition });
} }
};
handleRef = (ref) => { // Handle the extra wrapping required for list buttons.
this.ref = ref; else {
}; const isType = editorState.blocks.some(block => {
return !!doc.getClosest(block.key, parent => parent.type === type);
});
handleHeader = level => ( if (isList && isType) {
() => { transform
const { schema } = this.state; .setBlock(DEFAULT_NODE)
const state = this.view.state; .unwrapBlock('bulleted-list')
const { $from, to, node } = state.selection; .unwrapBlock('numbered-list');
let nodeType = schema.nodes.heading; } else if (isList) {
let attrs = { level }; transform
let inHeader = node && node.hasMarkup(nodeType, attrs); .unwrapBlock(type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list')
if (!inHeader) { .wrapBlock(type);
inHeader = to <= $from.end() && $from.parent.hasMarkup(nodeType, attrs); } else {
transform
.setBlock('list-item')
.wrapBlock(type);
} }
if (inHeader) {
nodeType = schema.nodes.paragraph;
attrs = {};
}
const command = setBlockType(nodeType, { level });
command(state, this.handleAction);
} }
);
handleBold = () => { const resolvedState = transform.focus().apply();
const command = toggleMark(this.state.schema.marks.strong); this.ref.onChange(resolvedState);
command(this.view.state, this.handleAction); this.setState({ editorState: resolvedState });
}; };
handleItalic = () => {
const command = toggleMark(this.state.schema.marks.em);
command(this.view.state, this.handleAction);
};
handleLink = () => { handleLink = () => {
let url = null; let url = null;
@ -216,7 +303,7 @@ export default class Editor extends Component {
handlePluginSubmit = (plugin, data) => { handlePluginSubmit = (plugin, data) => {
const { schema } = this.state; const { schema } = this.state;
const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`]; const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`];
this.view.props.onAction(this.view.state.tr.replaceSelectionWith(nodeType.create(data.toJS())).action()); //this.view.props.onAction(this.view.state.tr.replaceSelectionWith(nodeType.create(data.toJS())).action());
}; };
handleDragEnter = (e) => { handleDragEnter = (e) => {
@ -263,7 +350,7 @@ export default class Editor extends Component {
} }
nodes.forEach((node) => { nodes.forEach((node) => {
this.view.props.onAction(this.view.state.tr.replaceSelectionWith(node).action()); //this.view.props.onAction(this.view.state.tr.replaceSelectionWith(node).action());
}); });
}; };
@ -271,6 +358,12 @@ export default class Editor extends Component {
this.props.onMode('raw'); this.props.onMode('raw');
}; };
getButtonProps = (type, isBlock) => {
const handler = isBlock ? this.handleBlockClick: this.handleMarkClick;
const isActive = isBlock ? this.hasBlock : this.hasMark;
return { onAction: e => handler(e, type), active: isActive(type) };
};
render() { render() {
const { onAddAsset, onRemoveAsset, getAsset } = this.props; const { onAddAsset, onRemoveAsset, getAsset } = this.props;
const { plugins, selectionPosition, dragging } = this.state; const { plugins, selectionPosition, dragging } = this.state;
@ -293,11 +386,13 @@ export default class Editor extends Component {
> >
<Toolbar <Toolbar
selectionPosition={selectionPosition} selectionPosition={selectionPosition}
onH1={this.handleHeader(1)} buttons={{
onH2={this.handleHeader(2)} h1: this.getButtonProps('heading-one', true),
onBold={this.handleBold} h2: this.getButtonProps('heading-two', true),
onItalic={this.handleItalic} bold: this.getButtonProps('bold'),
onLink={this.handleLink} italic: this.getButtonProps('italic'),
link: this.getButtonProps('link'),
}}
onToggleMode={this.handleToggle} onToggleMode={this.handleToggle}
plugins={plugins} plugins={plugins}
onSubmit={this.handlePluginSubmit} onSubmit={this.handlePluginSubmit}
@ -306,7 +401,16 @@ export default class Editor extends Component {
getAsset={getAsset} getAsset={getAsset}
/> />
</Sticky> </Sticky>
<div ref={this.handleRef} /> <SlateEditor
className={styles.slateEditor}
state={this.state.editorState}
schema={this.state.schema}
onChange={editorState => this.setState({ editorState })}
onDocumentChange={this.handleDocumentChange}
onKeyDown={this.onKeyDown}
ref={ref => this.ref = ref}
spellCheck
/>
<div className={styles.shim} /> <div className={styles.shim} />
</div>); </div>);
} }

View File

@ -0,0 +1,12 @@
function remarkToSlate(opts) {
console.log(1);
return transform;
function transform(node) {
console.log(2);
console.log(node);
}
}
export default remarkToSlate;

View File

@ -0,0 +1,9 @@
function slateToRemark(opts) {
return transform;
function transform(node) {
console.log(node);
}
}
export default slateToRemark;