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: "Cover Image", name: "image", widget: "image", required: false, tagname: ""}
- {label: "Body", name: "body", widget: "markdown"}
- {label: "Body B", name: "bodyb", widget: "markdown"}
- {label: "Body C", name: "bodyc", widget: "markdown"}
meta:
- {label: "SEO Description", name: "description", widget: "text"}

View File

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

View File

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

View File

@ -10,12 +10,7 @@ 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,
buttons: PropTypes.object.isRequired,
onToggleMode: PropTypes.func.isRequired,
rawMode: PropTypes.bool,
plugins: ImmutablePropTypes.map,
@ -47,11 +42,7 @@ export default class Toolbar extends React.Component {
render() {
const {
onH1,
onH2,
onBold,
onItalic,
onLink,
buttons,
onToggleMode,
rawMode,
plugins,
@ -62,13 +53,19 @@ export default class Toolbar extends React.Component {
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 (
<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}/>
{ buttonsConfig.map((btn, i) => (
<ToolbarButton key={i} action={btn.state.onAction} active={btn.state.active} {...btn}/>
))}
<ToolbarComponentsMenu
plugins={plugins}
onComponentMenuItemClick={this.handlePluginFormDisplay}

View File

@ -68,86 +68,38 @@
pointer-events: none;
}
:global {
& .ProseMirror {
position: relative;
background-color: var(--controlBGColor);
padding: 12px;
overflow: hidden;
border-radius: var(--borderRadius);
overflow-x: auto;
border: var(--textFieldBorder);
min-height: var(--richTextEditorMinHeight);
.slateEditor {
position: relative;
background-color: var(--controlBGColor);
padding: 12px;
overflow: hidden;
border-radius: var(--borderRadius);
overflow-x: auto;
border: var(--textFieldBorder);
min-height: var(--richTextEditorMinHeight);
& ul,
& ol {
padding-left: 20px;
}
& pre > code {
display: block;
width: 100%;
overflow-y: auto;
background-color: #000;
color: #ccc;
border-radius: var(--borderRadius);
padding: 10px;
}
& ul,
& ol {
padding-left: 30px;
}
& .ProseMirror-content {
& pre {
white-space: pre-wrap;
}
& .ProseMirror-drop-target {
position: absolute;
width: 1px;
background: #666;
pointer-events: none;
& pre > code {
display: block;
width: 100%;
overflow-y: auto;
background-color: #000;
color: #ccc;
border-radius: var(--borderRadius);
padding: 10px;
}
& .ProseMirror-content ul, & .ProseMirror-content ol {
padding-left: 30px;
cursor: default;
}
& .ProseMirror-content blockquote {
& blockquote {
padding-left: 1em;
border-left: 3px solid #eee;
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 { Map } from 'immutable';
import { Schema } from 'prosemirror-model';
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 { Map, List } from 'immutable';
import { Editor as SlateEditor, Html as SlateHtml, Raw as SlateRaw} from 'slate';
import unified from 'unified';
import markdownToRemark from 'remark-parse';
import remarkToRehype from 'remark-rehype';
import rehypeToHtml from 'rehype-stringify';
import remarkToMarkdown from 'remark-stringify';
import htmlToRehype from 'rehype-parse';
import rehypeToRemark from 'rehype-remark';
import registry from '../../../../lib/registry';
import { createAssetProxy } from '../../../../valueObjects/AssetProxy';
import { buildKeymap } from './keymap';
@ -32,28 +26,7 @@ function processUrl(url) {
return `/${ url }`;
}
const ruleset = {
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);
}
const DEFAULT_NODE = 'paragraph';
function schemaWithPlugins(schema, plugins) {
let nodeSpec = schema.nodeSpec;
@ -94,115 +67,229 @@ function createSerializer(schema, plugins) {
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 {
constructor(props) {
super(props);
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 = {
editorState: serializer.deserialize(html),
schema: {
nodes: NODE_COMPONENTS,
marks: MARK_COMPONENTS,
},
plugins,
schema,
parser: createMarkdownParser(schema, plugins),
serializer: createSerializer(schema, plugins),
};
}
componentDidMount() {
this.view = new EditorView(this.ref, {
state: this.createEditorState(),
onAction: this.handleAction,
dispatchTransaction: this.handleTransaction,
});
}
createEditorState() {
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();
handleDocumentChange = (doc, editorState) => {
const html = serializer.serialize(editorState);
const markdown = unified()
.use(htmlToRehype)
.use(rehypeToRemark)
.use(remarkToMarkdown)
.processSync(html)
.contents;
this.props.onChange(markdown);
};
handleSelection = (state) => {
const { schema, selection } = state;
if (selection.from === selection.to) {
const { $from } = selection;
if ($from.parent && $from.parent.type === schema.nodes.paragraph && $from.parent.textContent === '') {
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 });
hasMark = type => this.state.editorState.marks.some(mark => mark.type === type);
hasBlock = type => this.state.editorState.blocks.some(node => node.type === type);
handleKeyDown = (e, data, state) => {
if (!data.isMod) {
return;
}
const marks = {
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) => {
this.ref = ref;
};
// Handle the extra wrapping required for list buttons.
else {
const isType = editorState.blocks.some(block => {
return !!doc.getClosest(block.key, parent => parent.type === type);
});
handleHeader = level => (
() => {
const { schema } = this.state;
const state = this.view.state;
const { $from, to, node } = state.selection;
let nodeType = schema.nodes.heading;
let attrs = { level };
let inHeader = node && node.hasMarkup(nodeType, attrs);
if (!inHeader) {
inHeader = to <= $from.end() && $from.parent.hasMarkup(nodeType, attrs);
if (isList && isType) {
transform
.setBlock(DEFAULT_NODE)
.unwrapBlock('bulleted-list')
.unwrapBlock('numbered-list');
} else if (isList) {
transform
.unwrapBlock(type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list')
.wrapBlock(type);
} 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 command = toggleMark(this.state.schema.marks.strong);
command(this.view.state, this.handleAction);
const resolvedState = transform.focus().apply();
this.ref.onChange(resolvedState);
this.setState({ editorState: resolvedState });
};
handleItalic = () => {
const command = toggleMark(this.state.schema.marks.em);
command(this.view.state, this.handleAction);
};
handleLink = () => {
let url = null;
@ -216,7 +303,7 @@ export default class Editor extends Component {
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());
//this.view.props.onAction(this.view.state.tr.replaceSelectionWith(nodeType.create(data.toJS())).action());
};
handleDragEnter = (e) => {
@ -263,7 +350,7 @@ export default class Editor extends Component {
}
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');
};
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() {
const { onAddAsset, onRemoveAsset, getAsset } = this.props;
const { plugins, selectionPosition, dragging } = this.state;
@ -293,11 +386,13 @@ export default class Editor extends Component {
>
<Toolbar
selectionPosition={selectionPosition}
onH1={this.handleHeader(1)}
onH2={this.handleHeader(2)}
onBold={this.handleBold}
onItalic={this.handleItalic}
onLink={this.handleLink}
buttons={{
h1: this.getButtonProps('heading-one', true),
h2: this.getButtonProps('heading-two', true),
bold: this.getButtonProps('bold'),
italic: this.getButtonProps('italic'),
link: this.getButtonProps('link'),
}}
onToggleMode={this.handleToggle}
plugins={plugins}
onSubmit={this.handlePluginSubmit}
@ -306,7 +401,16 @@ export default class Editor extends Component {
getAsset={getAsset}
/>
</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>);
}

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;