diff --git a/package.json b/package.json index 0cd64b56..87016e79 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,7 @@ "slate-drop-or-paste-images": "^0.2.0", "slug": "^0.9.1", "textarea-caret-position": "^0.1.1", + "unist-util-visit": "^1.1.1", "uuid": "^2.0.3", "whatwg-fetch": "^1.0.0" }, diff --git a/src/components/MarkupItReactRenderer/index.js b/src/components/MarkupItReactRenderer/index.js index 03d46637..462170c5 100644 --- a/src/components/MarkupItReactRenderer/index.js +++ b/src/components/MarkupItReactRenderer/index.js @@ -23,8 +23,8 @@ export default class MarkupItReactRenderer extends React.Component { render() { const { value } = this.props; - const mast = remark.parse(value); - const hast = toHAST(mast, { allowDangerousHTML: true }); + const mdast = remark.parse(value); + const hast = toHAST(mdast, { allowDangerousHTML: true }); const html = hastToHTML(hast, { allowDangerousHTML: true }); return
; // eslint-disable-line react/no-danger } diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/__tests__/__snapshots__/parser.spec.js.snap b/src/components/Widgets/MarkdownControlElements/VisualEditor/__tests__/__snapshots__/parser.spec.js.snap new file mode 100644 index 00000000..e434e6da --- /dev/null +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/__tests__/__snapshots__/parser.spec.js.snap @@ -0,0 +1,324 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Compile markdown to Prosemirror document structure should compile a markdown ordered list 1`] = ` +Object { + "content": Array [ + Object { + "attrs": Object { + "level": 1, + }, + "content": Array [ + Object { + "text": "H1", + "type": "text", + }, + ], + "type": "heading", + }, + Object { + "attrs": Object { + "order": 1, + "tight": true, + }, + "content": Array [ + Object { + "content": Array [ + Object { + "content": Array [ + Object { + "text": "yo", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "list_item", + }, + Object { + "content": Array [ + Object { + "content": Array [ + Object { + "text": "bro", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "list_item", + }, + Object { + "content": Array [ + Object { + "content": Array [ + Object { + "text": "fro", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "list_item", + }, + ], + "type": "ordered_list", + }, + ], + "type": "doc", +} +`; + +exports[`Compile markdown to Prosemirror document structure should compile bulleted lists 1`] = ` +Object { + "content": Array [ + Object { + "attrs": Object { + "level": 1, + }, + "content": Array [ + Object { + "text": "H1", + "type": "text", + }, + ], + "type": "heading", + }, + Object { + "attrs": Object { + "tight": false, + }, + "content": Array [ + Object { + "content": Array [ + Object { + "content": Array [ + Object { + "text": "yo", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "list_item", + }, + Object { + "content": Array [ + Object { + "content": Array [ + Object { + "text": "bro", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "list_item", + }, + Object { + "content": Array [ + Object { + "content": Array [ + Object { + "text": "fro", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "list_item", + }, + ], + "type": "bullet_list", + }, + ], + "type": "doc", +} +`; + +exports[`Compile markdown to Prosemirror document structure should compile hard breaks (double space) 1`] = ` +Object { + "content": Array [ + Object { + "content": Array [ + Object { + "text": "blue moon", + "type": "text", + }, + Object { + "type": "hard_break", + }, + Object { + "text": "footballs", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "doc", +} +`; + +exports[`Compile markdown to Prosemirror document structure should compile horizontal rules 1`] = ` +Object { + "content": Array [ + Object { + "attrs": Object { + "level": 1, + }, + "content": Array [ + Object { + "text": "H1", + "type": "text", + }, + ], + "type": "heading", + }, + Object { + "type": "horizontal_rule", + }, + Object { + "content": Array [ + Object { + "text": "blue moon", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "doc", +} +`; + +exports[`Compile markdown to Prosemirror document structure should compile horizontal rules 2`] = ` +Object { + "content": Array [ + Object { + "attrs": Object { + "level": 1, + }, + "content": Array [ + Object { + "text": "H1", + "type": "text", + }, + ], + "type": "heading", + }, + Object { + "type": "horizontal_rule", + }, + Object { + "content": Array [ + Object { + "text": "blue moon", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "doc", +} +`; + +exports[`Compile markdown to Prosemirror document structure should compile images 1`] = ` +Object { + "content": Array [ + Object { + "content": Array [ + Object { + "attrs": Object { + "alt": "super", + "src": "duper.jpg", + "title": null, + }, + "type": "image", + }, + ], + "type": "paragraph", + }, + ], + "type": "doc", +} +`; + +exports[`Compile markdown to Prosemirror document structure should compile multiple header levels 1`] = ` +Object { + "content": Array [ + Object { + "attrs": Object { + "level": 1, + }, + "content": Array [ + Object { + "text": "H1", + "type": "text", + }, + ], + "type": "heading", + }, + Object { + "attrs": Object { + "level": 2, + }, + "content": Array [ + Object { + "text": "H2", + "type": "text", + }, + ], + "type": "heading", + }, + Object { + "attrs": Object { + "level": 3, + }, + "content": Array [ + Object { + "text": "H3", + "type": "text", + }, + ], + "type": "heading", + }, + ], + "type": "doc", +} +`; + +exports[`Compile markdown to Prosemirror document structure should compile simple markdown 1`] = ` +Object { + "content": Array [ + Object { + "attrs": Object { + "level": 1, + }, + "content": Array [ + Object { + "text": "H1", + "type": "text", + }, + ], + "type": "heading", + }, + Object { + "content": Array [ + Object { + "text": "sweet body", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "doc", +} +`; diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/__tests__/parser.spec.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/__tests__/parser.spec.js new file mode 100644 index 00000000..cd188b8e --- /dev/null +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/__tests__/parser.spec.js @@ -0,0 +1,91 @@ +import { Schema } from "prosemirror-model"; +import { schema } from "prosemirror-markdown"; + +const makeParser = require("../parser"); + +const testSchema = new Schema({ + nodes: schema.nodeSpec, + marks: schema.markSpec, +}); +const parser = makeParser(testSchema); + +describe("Compile markdown to Prosemirror document structure", () => { + it("should compile simple markdown", () => { + const value = ` +# H1 + +sweet body +`; + expect(parser(value)).toMatchSnapshot(); + }); + + it("should compile a markdown ordered list", () => { + const value = ` +# H1 + +1. yo +2. bro +3. fro +`; + expect(parser(value)).toMatchSnapshot(); + }); + + it("should compile bulleted lists", () => { + const value = ` +# H1 + +* yo +* bro +* fro +`; + expect(parser(value)).toMatchSnapshot(); + }); + + it("should compile multiple header levels", () => { + const value = ` +# H1 + +## H2 + +### H3 +`; + expect(parser(value)).toMatchSnapshot(); + }); + + it("should compile horizontal rules", () => { + const value = ` +# H1 + +--- + +blue moon +`; + expect(parser(value)).toMatchSnapshot(); + }); + + it("should compile horizontal rules", () => { + const value = ` +# H1 + +--- + +blue moon +`; + expect(parser(value)).toMatchSnapshot(); + }); + + it("should compile hard breaks (double space)", () => { + const value = ` +blue moon +footballs +`; + expect(parser(value)).toMatchSnapshot(); + }); + + it("should compile images", () => { + const value = ` +![super](duper.jpg) +`; + expect(parser(value)).toMatchSnapshot(); + }); +}); diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js index a00620ca..775045c6 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js @@ -5,8 +5,13 @@ import { EditorState } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import history from 'prosemirror-history'; import { - blockQuoteRule, orderedListRule, bulletListRule, codeBlockRule, headingRule, - inputRules, allInputRules, + blockQuoteRule, + orderedListRule, + bulletListRule, + codeBlockRule, + headingRule, + inputRules, + allInputRules, } from 'prosemirror-inputrules'; import { keymap } from 'prosemirror-keymap'; import { schema as markdownSchema, defaultMarkdownSerializer } from 'prosemirror-markdown'; @@ -56,20 +61,26 @@ function schemaWithPlugins(schema, plugins) { let nodeSpec = schema.nodeSpec; plugins.forEach((plugin) => { const attrs = {}; - plugin.get('fields').forEach((field) => { - attrs[field.get('name')] = { default: null }; + plugin.get("fields").forEach((field) => { + attrs[field.get("name")] = { default: null }; }); - nodeSpec = nodeSpec.addToEnd(`plugin_${ plugin.get('id') }`, { + nodeSpec = nodeSpec.addToEnd(`plugin_${ plugin.get("id") }`, { attrs, - group: 'block', - parseDOM: [{ - tag: 'div[data-plugin]', - getAttrs(dom) { - return JSON.parse(dom.getAttribute('data-plugin')); + group: "block", + parseDOM: [ + { + tag: "div[data-plugin]", + getAttrs(dom) { + return JSON.parse(dom.getAttribute("data-plugin")); + }, }, - }], + ], toDOM(node) { - return ['div', { 'data-plugin': JSON.stringify(node.attrs) }, plugin.get('label')]; + return [ + "div", + { "data-plugin": JSON.stringify(node.attrs) }, + plugin.get("label"), + ]; }, }); }); @@ -83,8 +94,8 @@ function schemaWithPlugins(schema, plugins) { function createSerializer(schema, plugins) { const serializer = Object.create(defaultMarkdownSerializer); plugins.forEach((plugin) => { - serializer.nodes[`plugin_${ plugin.get('id') }`] = (state, node) => { - const toBlock = plugin.get('toBlock'); + serializer.nodes[`plugin_${ plugin.get("id") }`] = (state, node) => { + const toBlock = plugin.get("toBlock"); state.write(`${ toBlock.call(plugin, node.attrs) }\n\n`); }; }); @@ -159,17 +170,31 @@ export default class Editor extends Component { const { schema, selection } = state; if (selection.from === selection.to) { const { $from } = selection; - if ($from.parent && $from.parent.type === schema.nodes.paragraph && $from.parent.textContent === '') { + 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 }; + const selectionPosition = { + top: pos.top - editorPos.top, + left: pos.left - editorPos.left, + }; this.setState({ selectionPosition }); + } else { + this.setState({ showToolbar: false, showBlockMenu: false }); } } 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 }); + const selectionPosition = { + top: pos.top - editorPos.top, + left: pos.left - editorPos.left, + }; + this.setState({ selectionPosition }); } }; @@ -177,26 +202,24 @@ export default class Editor extends Component { this.ref = ref; }; - 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 (inHeader) { - nodeType = schema.nodes.paragraph; - attrs = {}; - } - - const command = setBlockType(nodeType, { level }); - command(state, this.handleAction); + 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 (inHeader) { + nodeType = schema.nodes.paragraph; + attrs = {}; + } + + const command = setBlockType(nodeType, { level }); + command(state, this.handleAction); + }; handleBold = () => { const command = toggleMark(this.state.schema.marks.strong); @@ -213,14 +236,20 @@ export default class Editor extends Component { if (!markActive(this.view.state, this.state.schema.marks.link)) { url = prompt('Link URL:'); // eslint-disable-line no-alert } - const command = toggleMark(this.state.schema.marks.link, { href: url ? processUrl(url) : null }); + const command = toggleMark(this.state.schema.marks.link, { + href: url ? processUrl(url) : null, + }); command(this.view.state, this.handleAction); }; 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()); + const nodeType = schema.nodes[`plugin_${ plugin.get("id") }`]; + this.view.props.onAction( + this.view.state.tr + .replaceSelectionWith(nodeType.create(data.toJS())) + .action() + ); }; handleDragEnter = (e) => { @@ -248,31 +277,40 @@ export default class Editor extends Component { if (e.dataTransfer.files && e.dataTransfer.files.length) { Array.from(e.dataTransfer.files).forEach((file) => { - createAssetProxy(file.name, file) - .then((assetProxy) => { + createAssetProxy(file.name, file).then((assetProxy) => { this.props.onAddAsset(assetProxy); - if (file.type.split('/')[0] === 'image') { + if (file.type.split("/")[0] === "image") { nodes.push( - schema.nodes.image.create({ src: assetProxy.public_path, alt: file.name }) + schema.nodes.image.create({ + src: assetProxy.public_path, + alt: file.name, + }) ); } else { nodes.push( - schema.marks.link.create({ href: assetProxy.public_path, title: file.name }) + schema.marks.link.create({ + href: assetProxy.public_path, + title: file.name, + }) ); } }); }); } else { - nodes.push(schema.nodes.paragraph.create({}, e.dataTransfer.getData('text/plain'))); + nodes.push( + schema.nodes.paragraph.create({}, e.dataTransfer.getData("text/plain")) + ); } 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() + ); }); }; handleToggle = () => { - this.props.onMode('raw'); + this.props.onMode("raw"); }; render() { @@ -283,36 +321,38 @@ export default class Editor extends Component { classNames.push(styles.dragging); } - return (