diff --git a/package.json b/package.json index bda4fe47..d986a723 100644 --- a/package.json +++ b/package.json @@ -111,10 +111,12 @@ "prosemirror-inputrules": "^0.12.0", "prosemirror-keymap": "^0.12.0", "prosemirror-markdown": "^0.12.0", + "prosemirror-model": "^0.12.0", "prosemirror-schema-basic": "^0.12.0", "prosemirror-schema-list": "^0.12.0", "prosemirror-schema-table": "^0.12.0", "prosemirror-state": "^0.12.0", + "prosemirror-transform": "^0.12.1", "prosemirror-view": "^0.12.0", "react": "^15.1.0", "react-addons-css-transition-group": "^15.3.1", diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.css b/src/components/Widgets/MarkdownControlElements/BlockMenu.css similarity index 100% rename from src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.css rename to src/components/Widgets/MarkdownControlElements/BlockMenu.css diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.js b/src/components/Widgets/MarkdownControlElements/BlockMenu.js similarity index 92% rename from src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.js rename to src/components/Widgets/MarkdownControlElements/BlockMenu.js index 0e060a6d..ad068a95 100644 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.js +++ b/src/components/Widgets/MarkdownControlElements/BlockMenu.js @@ -1,7 +1,7 @@ import React, { Component, PropTypes } from 'react'; import { fromJS } from 'immutable'; import { Button } from 'react-toolbox/lib/button'; -import { resolveWidget } from '../../../Widgets'; +import { resolveWidget } from '../../Widgets'; import styles from './BlockMenu.css'; export default class BlockMenu extends Component { @@ -49,7 +49,7 @@ export default class BlockMenu extends Component { } buttonFor(plugin) { - return (
  • + return (
  • ); } @@ -57,8 +57,7 @@ export default class BlockMenu extends Component { handleSubmit = (e) => { e.preventDefault(); const { openPlugin, pluginData } = this.state; - const toBlock = openPlugin.get('toBlock'); - this.props.onBlock(toBlock.call(toBlock, pluginData.toJS())); + this.props.onBlock(openPlugin, pluginData); this.setState({ openPlugin: null, isExpanded: false }); }; @@ -74,7 +73,7 @@ export default class BlockMenu extends Component { const value = pluginData.get(field.get('name')); return ( -
    +
    { React.createElement(widget.control, { diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js index 3b6374a8..14bb1637 100644 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js @@ -7,7 +7,7 @@ import CaretPosition from 'textarea-caret-position'; import registry from '../../../../lib/registry'; import MediaProxy from '../../../../valueObjects/MediaProxy'; import Toolbar from '../Toolbar'; -import BlockMenu from './BlockMenu'; +import BlockMenu from '../BlockMenu'; import styles from './index.css'; const HAS_LINE_BREAK = /\n/m; @@ -92,9 +92,7 @@ export default class RawEditor extends React.Component { constructor(props) { super(props); const plugins = registry.getEditorComponents(); - this.state = { - plugins: plugins, - }; + this.state = { plugins }; this.shortcuts = { meta: { b: this.handleBold, @@ -271,8 +269,9 @@ export default class RawEditor extends React.Component { this.updateHeight(); }; - handleBlock = (chars) => { - this.replaceSelection(chars); + handleBlock = (plugin, data) => { + const toBlock = plugin.get('toBlock'); + this.replaceSelection(toBlock.call(toBlock, data.toJS())); this.setState({ showBlockMenu: false }); }; diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.css b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.css index 7e407aef..54764f89 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.css +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.css @@ -7,10 +7,6 @@ border-bottom: none; margin-bottom: 20px; line-height: 1.45; - &:before { - content: "# "; - color: #a5afad; - } } & h1 { font-size: 2.5rem; @@ -21,15 +17,15 @@ & h3 { font-size: 1.8rem; } - & h2:before { - content: "## "; - } - & h3:before { - content: "### "; - } & p { margin-bottom: 20px; } + & div[data-plugin] { + background: #fff; + border: 1px solid #aaa; + padding: 10px; + margin-bottom: 20px; + } } :global { diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js index 714f12d4..02d49046 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js @@ -1,4 +1,5 @@ import React, { Component } from 'react'; +import { Schema } from 'prosemirror-model'; import { EditorState } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import history from 'prosemirror-history'; @@ -7,10 +8,14 @@ import { inputRules, allInputRules, } from 'prosemirror-inputrules'; import { keymap } from 'prosemirror-keymap'; -import { schema, defaultMarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown'; +import { replaceWith } from 'prosemirror-transform'; +import { schema, defaultMarkdownSerializer } from 'prosemirror-markdown'; import { baseKeymap, setBlockType, toggleMark } from 'prosemirror-commands'; +import registry from '../../../../lib/registry'; import { buildKeymap } from './keymap'; +import createMarkdownParser from './parser'; import Toolbar from '../Toolbar'; +import BlockMenu from '../BlockMenu'; import styles from './index.css'; function processUrl(url) { @@ -41,16 +46,63 @@ function markActive(state, type) { return state.doc.rangeHasMark(from, to, type); } +function schemaWithPlugins(schema, plugins) { + let nodeSpec = schema.nodeSpec; + plugins.forEach((plugin) => { + const attrs = {}; + plugin.get('fields').forEach((field) => { + attrs[field.get('name')] = { default: null }; + }); + nodeSpec = nodeSpec.addToEnd(`plugin_${ plugin.get('id') }`, { + attrs, + 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 new Schema({ + nodes: nodeSpec, + marks: schema.markSpec, + }); +} + +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'); + state.write(toBlock.call(plugin, node.attrs)); + }; + }); + return serializer; +} + export default class Editor extends Component { constructor(props) { super(props); - this.state = {}; + const plugins = registry.getEditorComponents(); + const s = schemaWithPlugins(schema, plugins); + this.state = { + plugins, + schema: s, + parser: createMarkdownParser(s), + serializer: createSerializer(s, plugins), + }; } componentDidMount() { + const { schema, parser } = this.state; this.view = new EditorView(this.ref, { state: EditorState.create({ - doc: defaultMarkdownParser.parse(this.props.value || ''), + doc: parser.parse(this.props.value || ''), schema, plugins: [ inputRules({ @@ -70,27 +122,34 @@ export default class Editor extends Component { } handleAction = (action) => { + const { schema, serializer } = this.state; const newState = this.view.state.applyAction(action); - switch (action.type) { - case 'selection': - this.handleSelection(newState); - default: - const md = defaultMarkdownSerializer.serialize(newState.doc); - this.props.onChange(md); - } + const md = serializer.serialize(newState.doc); + this.props.onChange(md); this.view.updateState(newState); + if (newState.selection !== this.state.selection) { + this.handleSelection(newState); + } this.view.focus(); }; handleSelection = (state) => { - const { selection } = 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({ showToolbar: false, showBlockMenu: true, 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({ showToolbar: false, selectionPosition }); - } else { - this.setState({ showToolbar: true }); + this.setState({ showToolbar: true, showBlockMenu: false, selectionPosition }); } }; @@ -100,6 +159,7 @@ export default class Editor extends Component { handleHeader = level => ( () => { + const { schema } = this.state; const state = this.view.state; const { $from, to, node } = state.selection; let nodeType = schema.nodes.heading; @@ -119,30 +179,37 @@ export default class Editor extends Component { ); handleBold = () => { - const command = toggleMark(schema.marks.strong); + const command = toggleMark(this.state.schema.marks.strong); command(this.view.state, this.handleAction); }; handleItalic = () => { - const command = toggleMark(schema.marks.em); + const command = toggleMark(this.state.schema.marks.em); command(this.view.state, this.handleAction); }; handleLink = () => { let url = null; - if (!markActive(this.view.state, schema.marks.link)) { + if (!markActive(this.view.state, this.state.schema.marks.link)) { url = prompt('Link URL:'); } - const command = toggleMark(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); }; + handleBlock = (plugin, data) => { + const { schema } = this.state; + const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`]; + this.view.props.onAction(this.view.state.tr.replaceSelection(nodeType.create(data.toJS())).action()); + }; + handleToggle = () => { this.props.onMode('raw'); }; render() { - const { showToolbar, selectionPosition } = this.state; + const { onAddMedia, onRemoveMedia, getMedia } = this.props; + const { plugins, showToolbar, showBlockMenu, selectionPosition } = this.state; return (
    +
    ); } diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/parser.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/parser.js new file mode 100644 index 00000000..acfaf707 --- /dev/null +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/parser.js @@ -0,0 +1,30 @@ +import { MarkdownParser } from 'prosemirror-markdown'; +import markdownit from 'markdown-it'; + +export default function createMarkdownParser(schema) { + return new MarkdownParser(schema, markdownit("commonmark", {html: false}), { + blockquote: {block: "blockquote"}, + paragraph: {block: "paragraph"}, + list_item: {block: "list_item"}, + bullet_list: {block: "bullet_list"}, + ordered_list: {block: "ordered_list", attrs: tok => ({order: +tok.attrGet("order") || 1})}, + heading: {block: "heading", attrs: tok => ({level: +tok.tag.slice(1)})}, + code_block: {block: "code_block"}, + fence: {block: "code_block"}, + hr: {node: "horizontal_rule"}, + image: {node: "image", attrs: tok => ({ + src: tok.attrGet("src"), + title: tok.attrGet("title") || null, + alt: tok.children[0] && tok.children[0].content || null + })}, + hardbreak: {node: "hard_break"}, + + em: {mark: "em"}, + strong: {mark: "strong"}, + link: {mark: "link", attrs: tok => ({ + href: tok.attrGet("href"), + title: tok.attrGet("title") || null + })}, + code_inline: {mark: "code"} + }); +} diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/serializer.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/serializer.js new file mode 100644 index 00000000..e69de29b diff --git a/yarn.lock b/yarn.lock index 43b9452a..a7228b99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6611,7 +6611,7 @@ prosemirror-markdown: markdown-it "^6.0.4" prosemirror-model "~0.12.0" -prosemirror-model@^0.12.0, prosemirror-model@~0.12.0: +prosemirror-model, prosemirror-model@^0.12.0, prosemirror-model@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-0.12.0.tgz#5430c4056f2d3fe87d36de3f73aa9d9d07b0e8a7" @@ -6643,6 +6643,12 @@ prosemirror-state@^0.12.0: prosemirror-model "^0.12.0" prosemirror-transform "^0.12.0" +prosemirror-transform: + version "0.12.1" + resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-0.12.1.tgz#69bca7e55976815e59281fbd8af4518f5ab90844" + dependencies: + prosemirror-model "^0.12.0" + prosemirror-transform@^0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-0.12.0.tgz#298660a60e2069112469e0172e78be395762d263"