diff --git a/.gitignore b/.gitignore index 76dc6e31..83bbe49f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ npm-debug.log .DS_Store .tern-project +yarn-error.log diff --git a/package.json b/package.json index 81573aa6..9abcbbf1 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,18 @@ "normalize.css": "^4.2.0", "pluralize": "^3.0.0", "prismjs": "^1.5.1", + "prosemirror-commands": "^0.12.0", + "prosemirror-history": "^0.12.0", + "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", "react-datetime": "^2.6.0", diff --git a/src/components/UI/icon/Icon.js b/src/components/UI/icon/Icon.js index 7368cd12..a93a9f89 100644 --- a/src/components/UI/icon/Icon.js +++ b/src/components/UI/icon/Icon.js @@ -5,7 +5,7 @@ const availableIcons = [ // Font Awesome Editor Icons 'bold', 'italic', 'list', 'font', 'text-height', 'text-width', 'align-left', 'align-center', 'align-right', 'align-justify', 'indent-left', 'indent-right', 'list-bullet', 'list-numbered', 'strike', 'underline', 'table', - 'superscript', 'subscript', 'header', 'h1', 'h2', 'paragraph', 'link', 'unlink', 'quote-left', 'quote-right', 'code', + 'superscript', 'subscript', 'header', 'h1', 'h2', 'h3', 'paragraph', 'link', 'unlink', 'quote-left', 'quote-right', 'code', 'picture', 'video', // Entypo 'note', 'note-beamed', @@ -183,7 +183,7 @@ const availableIcons = [ 'smashing', 'sweden', 'db-shape', - 'logo-db' + 'logo-db', ]; const iconPropType = (props, propName) => { @@ -191,16 +191,16 @@ const iconPropType = (props, propName) => { const value = props[propName]; if (typeof value !== 'string' || availableIcons.indexOf(value) === -1) { return new Error( - `Invalid type "${value}" supplied to Icon Component.` + `Invalid type "${ value }" supplied to Icon Component.` ); } } }; -const noop = function() {}; +const noop = function () {}; export default function Icon({ style, className = '', type, onClick = noop }) { - return ; + return ; } Icon.propTypes = { diff --git a/src/components/Widgets/MarkdownControl.js b/src/components/Widgets/MarkdownControl.js index a63c98cb..a04ea6da 100644 --- a/src/components/Widgets/MarkdownControl.js +++ b/src/components/Widgets/MarkdownControl.js @@ -16,48 +16,49 @@ class MarkdownControl extends React.Component { value: PropTypes.node, }; + constructor(props) { + super(props); + this.state = { mode: 'visual' }; + } + componentWillMount() { - this.useRawEditor(); processEditorPlugins(registry.getEditorComponents()); } - useVisualEditor = () => { - this.props.switchVisualMode(true); - }; - - useRawEditor = () => { - this.props.switchVisualMode(false); + handleMode = (mode) => { + this.setState({ mode }); }; render() { - const { editor, onChange, onAddMedia, onRemoveMedia, getMedia, value } = this.props; - if (editor.get('useVisualMode')) { + const { onChange, onAddMedia, onRemoveMedia, getMedia, value } = this.props; + const { mode } = this.state; + if (mode === 'visual') { return (
- {null && } -
- ); - } else { - return ( -
- {null && } -
); } + + return ( +
+ +
+ ); } } 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..39e0f010 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/Toolbar.css b/src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.css deleted file mode 100644 index 2d91b9f7..00000000 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.css +++ /dev/null @@ -1,28 +0,0 @@ -.Toolbar { - position: absolute; - z-index: 1000; - display: none; - margin: none; - padding: none; - box-shadow: 1px 1px 5px; - list-style: none; -} - -.Button { - display: inline-block; - - & button { - padding: 5px; - border: none; - border-right: 1px solid #eee; - background: #fff; - } -} - -.Button:last-child button { - border-right: none; -} - -.Visible { - display: block; -} diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js index d8ad8eb3..36234c8d 100644 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js @@ -6,8 +6,8 @@ import htmlSyntax from 'markup-it/syntaxes/html'; 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 Toolbar from '../Toolbar'; +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, @@ -238,15 +236,16 @@ export default class RawEditor extends React.Component { const selection = this.getSelection(); if (selection.start !== selection.end && !HAS_LINE_BREAK.test(selection.selected)) { try { - const position = this.caretPosition.get(selection.start, selection.end); - this.setState({ showToolbar: true, showBlockMenu: false, selectionPosition: position }); + const selectionPosition = this.caretPosition.get(selection.start, selection.end); + console.log('pos: %o', selectionPosition); + this.setState({ showToolbar: true, showBlockMenu: false, selectionPosition }); } catch (e) { this.setState({ showToolbar: false, showBlockMenu: false }); } } else if (selection.start === selection.end) { const newBlock = ( - (selection.start === 0 && value.substr(0,1).match(/^\n?$/)) || + (selection.start === 0 && value.substr(0, 1).match(/^\n?$/)) || value.substr(selection.start - 2, 2) === '\n\n') && ( selection.end === (value.length - 1) || @@ -270,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 }); }; @@ -313,7 +313,11 @@ export default class RawEditor extends React.Component { this.newSelection = newSelection; onChange(beforeSelection + paste + afterSelection); }); - } + }; + + handleToggle = () => { + this.props.onMode('visual'); + }; render() { const { onAddMedia, onRemoveMedia, getMedia } = this.props; @@ -327,6 +331,7 @@ export default class RawEditor extends React.Component { onBold={this.handleBold} onItalic={this.handleItalic} onLink={this.handleLink} + onToggleMode={this.handleToggle} /> * { + 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 { + position: absolute; + top: -2px; + right: -2px; + bottom: -2px; + left: -32px; + border: 2px solid #8cf; + content: ''; + pointer-events: none; + } +} diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.js b/src/components/Widgets/MarkdownControlElements/Toolbar.js similarity index 88% rename from src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.js rename to src/components/Widgets/MarkdownControlElements/Toolbar.js index fcfc2b23..686e79d6 100644 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/Toolbar.js +++ b/src/components/Widgets/MarkdownControlElements/Toolbar.js @@ -1,5 +1,5 @@ import React, { Component, PropTypes } from 'react'; -import { Icon } from '../../../UI'; +import { Icon } from '../../UI'; import styles from './Toolbar.css'; function button(label, icon, action) { @@ -19,6 +19,7 @@ export default class Toolbar extends Component { onBold: PropTypes.func.isRequired, onItalic: PropTypes.func.isRequired, onLink: PropTypes.func.isRequired, + onToggleMode: PropTypes.func.isRequired, }; componentDidUpdate() { @@ -41,7 +42,7 @@ export default class Toolbar extends Component { }; render() { - const { isOpen, onH1, onH2, onBold, onItalic, onLink } = this.props; + const { isOpen, onH1, onH2, onBold, onItalic, onLink, onToggleMode } = this.props; const classNames = [styles.Toolbar]; if (isOpen) { @@ -55,6 +56,7 @@ export default class Toolbar extends Component { {button('Bold', 'bold', onBold)} {button('Italic', 'italic', onItalic)} {button('Link', 'link', onLink)} + {button('View Code', 'code', onToggleMode)} ); } diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/Block.css b/src/components/Widgets/MarkdownControlElements/VisualEditor/Block.css deleted file mode 100644 index df0af926..00000000 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/Block.css +++ /dev/null @@ -1,71 +0,0 @@ -.root { - border: dotted 1px #ddd; - position: relative; - margin: 9px 0 15px 0; -} - - -.type:after { - content: attr(data-type); - font-size: 10px; - color: #aaa; - position: absolute; - top : -7px;; - margin-left: 1em; - padding: 0 3px; - display: inline; - background-color: #fafafa; - pointer-events: none; -} - -.body { - padding: 8px; -} - -.body img{ - max-width: 100%; - height: auto; -} - -.Paragraph { - -} - -.Heading1, .Heading2, .Heading3, .Heading4, .Heading5, .Heading6 { - margin: 0; - font-weight: bold -} - -.Heading1 { - font-size: 1.2em; -} - -.Heading2 { - font-size: 1.15em; -} - -.Heading3 { - font-size: 1.1em; -} - -.Heading4 { - font-size: 1.07em; -} - -.Heading5 { - font-size: 1.05em; -} - -.Heading6 { - font-size: 1.03em; -} - -.blockquote { - padding-left: 5px; - border-left: solid 3px #ccc; -} - -.body ul { - padding-left: 20px; - margin: 0; -} diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/Block.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/Block.js deleted file mode 100644 index 0b1a3649..00000000 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/Block.js +++ /dev/null @@ -1,32 +0,0 @@ -import React, { PropTypes } from 'react'; -import styles from './Block.css'; - -const AVAILABLE_TYPES = [ - 'Paragraph', - 'Heading1', - 'Heading2', - 'Heading3', - 'Heading4', - 'Heading5', - 'Heading6', - 'List', - 'blockquote' -]; - -export function Block({ type, children }) { - return ( -
    -
    -
    - {children} -
    -
    - ); -} - -Block.propTypes = { - children: PropTypes.node.isRequired, - type: PropTypes.oneOf(AVAILABLE_TYPES).isRequired -}; - -export default Block; diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.css b/src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.css deleted file mode 100644 index 9868af79..00000000 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.css +++ /dev/null @@ -1,32 +0,0 @@ -.root { - position: absolute; -} - -.button { - margin-top: 2px; - color: #ddd; - transition: color 0.5s ease; - cursor: pointer; -} -.button:hover { - color: #aaa; -} - -.menu { - position: absolute; - top: -5px; - left: 20px; - height: 32px; - white-space: nowrap; - background-color: rgba(126, 126, 126, 0.1); -} - -.icon { - margin: 8px; - cursor: pointer; - color: #555; -} - -.input { - display: none; -} diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.js deleted file mode 100644 index 36ab5166..00000000 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/BlockTypesMenu.js +++ /dev/null @@ -1,115 +0,0 @@ -import React, { Component, PropTypes } from 'react'; -import withPortalAtCursorPosition from './withPortalAtCursorPosition'; -import { Icon } from '../../../UI'; -import MediaProxy from '../../../../valueObjects/MediaProxy'; -import styles from './BlockTypesMenu.css'; - -class BlockTypesMenu extends Component { - - static propTypes = { - plugins: PropTypes.array.isRequired, - onClickBlock: PropTypes.func.isRequired, - onClickPlugin: PropTypes.func.isRequired, - onClickImage: PropTypes.func.isRequired, - }; - - state = { - expanded: false, - }; - - componentWillUpdate() { - if (this.state.expanded) { - this.setState({ expanded: false }); - } - } - - toggleMenu = () => { - this.setState({ expanded: !this.state.expanded }); - }; - - handleBlockTypeClick = (e, type) => { - this.props.onClickBlock(type); - }; - - handlePluginClick = (e, plugin) => { - const data = {}; - plugin.fields.forEach((field) => { - data[field.name] = window.prompt(field.label); // eslint-disable-line - }); - this.props.onClickPlugin(plugin.id, data); - }; - - handleFileUploadClick = () => { - this._fileInput.click(); - }; - - handleFileUploadChange = (e) => { - e.stopPropagation(); - e.preventDefault(); - - const fileList = e.dataTransfer ? e.dataTransfer.files : e.target.files; - const files = [...fileList]; - const imageType = /^image\//; - - // Iterate through the list of files and return the first image on the list - const file = files.find((currentFile) => { - if (imageType.test(currentFile.type)) { - return currentFile; - } - }); - - if (file) { - const mediaProxy = new MediaProxy(file.name, file); - this.props.onClickImage(mediaProxy); - } - }; - - renderBlockTypeButton = (type, icon) => { - const onClick = e => this.handleBlockTypeClick(e, type); - return ( - - ); - }; - - renderPluginButton = (plugin) => { - const onClick = e => this.handlePluginClick(e, plugin); - return ( - - ); - }; - - renderMenu() { - const { plugins } = this.props; - if (this.state.expanded) { - return ( -
    - {this.renderBlockTypeButton('hr', 'dot-3')} - {plugins.map(plugin => this.renderPluginButton(plugin))} - - { - this._fileInput = el; - }} - /> -
    - ); - } else { - return null; - } - } - - render() { - return ( -
    - - {this.renderMenu()} -
    - ); - } -} - -export default withPortalAtCursorPosition(BlockTypesMenu); diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.css b/src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.css deleted file mode 100644 index c87888af..00000000 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.css +++ /dev/null @@ -1,39 +0,0 @@ - -.button { - color: #ccc; - cursor: pointer; -} - -.button[data-active="true"] { - color: black; -} - - -.menu > * { - display: inline-block; -} - -.menu > * + * { - margin-left: 10px; -} - -.hoverMenu { - padding: 8px 7px 6px; - position: absolute; - z-index: 1; - top: -10000px; - left: -10000px; - margin-top: -6px; - opacity: 0; - background-color: #222; - border-radius: 4px; - transition: opacity .75s; -} - -.hoverMenu .button { - color: #aaa; -} - -.hoverMenu .button[data-active="true"] { - color: #fff; -} diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.js deleted file mode 100644 index 5d141098..00000000 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/StylesMenu.js +++ /dev/null @@ -1,99 +0,0 @@ -import React, { Component, PropTypes } from 'react'; -import withPortalAtCursorPosition from './withPortalAtCursorPosition'; -import { Icon } from '../../../UI'; -import styles from './StylesMenu.css'; - -class StylesMenu extends Component { - - static propTypes = { - marks: PropTypes.object.isRequired, - blocks: PropTypes.object.isRequired, - inlines: PropTypes.object.isRequired, - onClickBlock: PropTypes.func.isRequired, - onClickMark: PropTypes.func.isRequired, - onClickInline: PropTypes.func.isRequired, - }; - - /** - * Used to set toolbar buttons to active state - */ - hasMark = (type) => { - const { marks } = this.props; - return marks.some(mark => mark.type == type); - }; - - hasBlock = (type) => { - const { blocks } = this.props; - return blocks.some(node => node.type == type); - }; - - hasLinks = (type) => { - const { inlines } = this.props; - return inlines.some(inline => inline.type == 'link'); - }; - - handleMarkClick = (e, type) => { - e.preventDefault(); - this.props.onClickMark(type); - }; - - renderMarkButton = (type, icon) => { - const isActive = this.hasMark(type); - const onMouseDown = e => this.handleMarkClick(e, type); - return ( - - - - ); - }; - - handleInlineClick = (e, type, isActive) => { - e.preventDefault(); - this.props.onClickInline(type, isActive); - }; - - renderLinkButton = () => { - const isActive = this.hasLinks(); - const onMouseDown = e => this.handleInlineClick(e, 'link', isActive); - return ( - - - - ); - }; - - handleBlockClick = (e, type) => { - e.preventDefault(); - const isActive = this.hasBlock(type); - const isList = this.hasBlock('list-item'); - this.props.onClickBlock(type, isActive, isList); - }; - - renderBlockButton = (type, icon, checkType) => { - checkType = checkType || type; - const isActive = this.hasBlock(checkType); - const onMouseDown = e => this.handleBlockClick(e, type); - return ( - - - - ); - }; - - render() { - return ( -
    - {this.renderMarkButton('BOLD', 'bold')} - {this.renderMarkButton('ITALIC', 'italic')} - {this.renderMarkButton('CODE', 'code')} - {this.renderLinkButton()} - {this.renderBlockButton('header_one', 'h1')} - {this.renderBlockButton('header_two', 'h2')} - {this.renderBlockButton('blockquote', 'quote-left')} - {this.renderBlockButton('unordered_list', 'list-bullet', 'list_item')} -
    - ); - } -} - -export default withPortalAtCursorPosition(StylesMenu); diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.css b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.css index 6eb82211..54764f89 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.css +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.css @@ -1,26 +1,91 @@ -.active { - box-shadow: 0 0 0 2px blue; +.editor { + position: relative; + & h1, & h2, & h3 { + padding: 0; + color: #7c8382; + text-decoration: none; + border-bottom: none; + margin-bottom: 20px; + line-height: 1.45; + } + & h1 { + font-size: 2.5rem; + } + & h2 { + font-size: 2rem; + } + & h3 { + font-size: 1.8rem; + } + & p { + margin-bottom: 20px; + } + & div[data-plugin] { + background: #fff; + border: 1px solid #aaa; + padding: 10px; + margin-bottom: 20px; + } } -:global .plugin { - background-color: #ddd; - color: #555; - text-align: center; - width: 200px; - padding: 8px; - border-radius: 2px; -} +:global { + & .ProseMirror { + position: relative; + } -:global .plugin_icon { - font-size: 50px; - margin: 12px 0; -} + & .ProseMirror-content { + white-space: pre-wrap; + } -:global .plugin_fields { - font-size: 11px; - outline:none; -} + & .ProseMirror-drop-target { + position: absolute; + width: 1px; + background: #666; + pointer-events: none; + } -:global .active { - box-shadow: 0 0 0 2px blue; + & .ProseMirror-content ul, & .ProseMirror-content ol { + padding-left: 30px; + cursor: default; + } + + & .ProseMirror-content 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; + } } diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js index 943948e0..f269e0c9 100644 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/index.js @@ -1,298 +1,256 @@ -import React, { PropTypes } from 'react'; -import _ from 'lodash'; -import { Editor, Raw } from 'slate'; -import PluginDropImages from 'slate-drop-or-paste-images'; -import MarkupIt, { SlateUtils } from 'markup-it'; -import MediaProxy from '../../../../valueObjects/MediaProxy'; -import { emptyParagraphBlock, mediaproxyBlock } from '../constants'; -import { DEFAULT_NODE, SCHEMA } from './schema'; -import { getNodes, getSyntaxes, getPlugins } from '../../richText'; -import StylesMenu from './StylesMenu'; -import BlockTypesMenu from './BlockTypesMenu'; +import React, { Component, PropTypes } from 'react'; +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, 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'; -/** - * Slate Render Configuration - */ -export default class VisualEditor extends React.Component { +function processUrl(url) { + if (url.match(/^(https?:\/\/|mailto:|\/)/)) { + return url; + } + if (url.match(/^[^\/]+\.[^\/]+/)) { + return `https://${ url }`; + } + return `/${ url }`; +} - static propTypes = { - onChange: PropTypes.func.isRequired, - onAddMedia: PropTypes.func.isRequired, - getMedia: PropTypes.func.isRequired, - value: PropTypes.string, - }; +const ruleset = { + blockquote: [blockQuoteRule], + ordered_list: [orderedListRule], + bullet_list: [bulletListRule], + code_block: [codeBlockRule], + heading: [headingRule, 6], +}; +function buildInputRules(schema) { + const result = []; + for (const rule in ruleset) { + const type = schema.nodes[rule]; + if (type) { + const fn = ruleset[rule]; + result.push(fn[0].apply(fn.slice(1))); + } + } + return result; +} + +function markActive(state, type) { + const { from, to, empty } = state.selection; + if (empty) { + return type.isInSet(state.storedMarks || state.doc.marksAt(from)); + } + 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); - - const MarkdownSyntax = getSyntaxes(this.getMedia).markdown; - this.markdown = new MarkupIt(MarkdownSyntax); - - SCHEMA.nodes = _.merge(SCHEMA.nodes, getNodes()); - - this.blockEdit = false; - - let rawJson; - if (props.value !== undefined) { - const content = this.markdown.toContent(props.value); - rawJson = SlateUtils.encode(content, null, ['mediaproxy'].concat(getPlugins().map(plugin => plugin.id))); - } else { - rawJson = emptyParagraphBlock; - } + const plugins = registry.getEditorComponents(); + const s = schemaWithPlugins(schema, plugins); this.state = { - state: Raw.deserialize(rawJson, { terse: true }), + plugins, + schema: s, + parser: createMarkdownParser(s, plugins), + serializer: createSerializer(s, plugins), }; + } - this.plugins = [ - PluginDropImages({ - applyTransform: (transform, file) => { - const mediaProxy = new MediaProxy(file.name, file); - props.onAddMedia(mediaProxy); - return transform - .insertBlock(mediaproxyBlock(mediaProxy)); - }, + componentDidMount() { + const { schema, parser } = this.state; + const doc = parser.parse(this.props.value || ''); + this.view = new EditorView(this.ref, { + state: EditorState.create({ + doc, + schema, + plugins: [ + inputRules({ + rules: allInputRules.concat(buildInputRules(schema)), + }), + keymap(buildKeymap(schema)), + keymap(baseKeymap), + history.history(), + keymap({ + 'Mod-z': history.undo, + 'Mod-y': history.redo, + }), + ], }), - ]; + onAction: this.handleAction, + }); } - getMedia = (src) => { - return this.props.getMedia(src); - }; - - /** - * Slate keeps track of selections, scroll position etc. - * So, onChange gets dispatched on every interaction (click, arrows, everything...) - * It also have an onDocumentChange, that get's dispatched only when the actual - * content changes - */ - handleChange = (state) => { - if (this.blockEdit) { - this.blockEdit = false; - } else { - this.setState({ state }); + handleAction = (action) => { + const { schema, serializer } = this.state; + const newState = this.view.state.applyAction(action); + 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(); }; - handleDocumentChange = (document, state) => { - const rawJson = Raw.serialize(state, { terse: true }); - const content = SlateUtils.decode(rawJson); - this.props.onChange(this.markdown.toText(content)); - }; - - /** - * Toggle marks / blocks when button is clicked - */ - handleMarkStyleClick = (type) => { - let { state } = this.state; - - state = state - .transform() - .toggleMark(type) - .apply(); - - this.setState({ state }); - }; - - handleBlockStyleClick = (type, isActive, isList) => { - let { state } = this.state; - let transform = state.transform(); - const { document } = state; - - // Handle everything but list buttons. - if (type != 'unordered_list' && type != 'ordered_list') { - if (isList) { - transform = transform - .setBlock(isActive ? DEFAULT_NODE : type) - .unwrapBlock('unordered_list') - .unwrapBlock('ordered_list'); - } - - else { - transform = transform - .setBlock(isActive ? DEFAULT_NODE : type); - } - } - - // Handle the extra wrapping required for list buttons. - else { - const isType = state.blocks.some((block) => { - return !!document.getClosest(block, parent => parent.type == type); - }); - - if (isList && isType) { - transform = transform - .setBlock(DEFAULT_NODE) - .unwrapBlock('unordered_list'); - } else if (isList) { - transform = transform - .unwrapBlock(type == 'unordered_list') - .wrapBlock(type); + 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({ showToolbar: false, showBlockMenu: true, selectionPosition }); } else { - transform = transform - .setBlock('list_item') - .wrapBlock(type); + this.setState({ showToolbar: false, showBlockMenu: false }); } - } - - state = transform.apply(); - this.setState({ state }); - }; - - /** - * When clicking a link, if the selection has a link in it, remove the link. - * Otherwise, add a new link with an href and text. - * - * @param {Event} e - */ - - handleInlineClick = (type, isActive) => { - let { state } = this.state; - - if (type === 'link') { - if (!state.isExpanded) return; - - if (isActive) { - state = state - .transform() - .unwrapInline('link') - .apply(); - } - - else { - const href = window.prompt('Enter the URL of the link:', 'http://www.'); // eslint-disable-line - state = state - .transform() - .wrapInline({ - type: 'link', - data: { href }, - }) - .collapseToEnd() - .apply(); - } - } - this.setState({ state }); - }; - - handleBlockTypeClick = (type) => { - let { state } = this.state; - - state = state - .transform() - .insertBlock({ - type, - isVoid: true, - }) - .apply(); - - this.setState({ state }, this.focusAndAddParagraph); - }; - - handlePluginClick = (type, data) => { - let { state } = this.state; - - state = state - .transform() - .insertInline({ - type, - data, - isVoid: true, - }) - .collapseToEnd() - .insertBlock(DEFAULT_NODE) - .focus() - .apply(); - - this.setState({ state }); - }; - - handleImageClick = (mediaProxy) => { - let { state } = this.state; - this.props.onAddMedia(mediaProxy); - - state = state - .transform() - .insertBlock(mediaproxyBlock(mediaProxy)) - .apply(); - - this.setState({ state }); - }; - - focusAndAddParagraph = () => { - const { state } = this.state; - const blocks = state.document.getBlocks(); - const last = blocks.last(); - const normalized = state - .transform() - .focus() - .collapseToEndOf(last) - .splitBlock() - .setBlock(DEFAULT_NODE) - .apply({ - snapshot: false, - }); - this.setState({ state: normalized }); - }; - - handleKeyDown = (evt) => { - if (evt.shiftKey && evt.key === 'Enter') { - this.blockEdit = true; - let { state } = this.state; - state = state - .transform() - .insertText('\n') - .apply(); - - this.setState({ state }); + } 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: true, showBlockMenu: false, selectionPosition }); } }; - renderBlockTypesMenu = () => { - const currentBlock = this.state.state.blocks.get(0); - const isOpen = (this.props.value !== undefined && currentBlock.isEmpty && currentBlock.type !== 'horizontal-rule'); - - return ( - - ); + handleRef = (ref) => { + this.ref = ref; }; - renderStylesMenu() { - const { state } = this.state; - const isOpen = !(state.isBlurred || state.isCollapsed); + 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 = {}; + } - return ( - - ); - } + const command = setBlockType(nodeType, { level }); + command(state, this.handleAction); + } + ); + + handleBold = () => { + const command = toggleMark(this.state.schema.marks.strong); + command(this.view.state, this.handleAction); + }; + + handleItalic = () => { + const command = toggleMark(this.state.schema.marks.em); + command(this.view.state, this.handleAction); + }; + + handleLink = () => { + let url = null; + if (!markActive(this.view.state, this.state.schema.marks.link)) { + url = prompt('Link URL:'); + } + 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() { - return ( -
    - {this.renderStylesMenu()} - {this.renderBlockTypesMenu()} - -
    - ); + const { onAddMedia, onRemoveMedia, getMedia } = this.props; + const { plugins, showToolbar, showBlockMenu, selectionPosition } = this.state; + + return (
    + + +
    +
    ); } } + +Editor.propTypes = { + onAddMedia: PropTypes.func.isRequired, + onRemoveMedia: PropTypes.func.isRequired, + getMedia: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onMode: PropTypes.func.isRequired, + value: PropTypes.node, +}; diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/keymap.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/keymap.js new file mode 100644 index 00000000..bc0e1a22 --- /dev/null +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/keymap.js @@ -0,0 +1,92 @@ +const { wrapIn, setBlockType, chainCommands, newlineInCode, toggleMark } = require('prosemirror-commands'); +const { selectNextCell, selectPreviousCell } = require('prosemirror-schema-table'); +const { wrapInList, splitListItem, liftListItem, sinkListItem } = require('prosemirror-schema-list'); +const { undo, redo } = require('prosemirror-history'); + +const mac = typeof navigator != 'undefined' ? /Mac/.test(navigator.platform) : false; + +// :: (Schema, ?Object) → Object +// Inspect the given schema looking for marks and nodes from the +// basic schema, and if found, add key bindings related to them. +// This will add: +// +// * **Mod-b** for toggling [strong](#schema-basic.StrongMark) +// * **Mod-i** for toggling [emphasis](#schema-basic.EmMark) +// * **Mod-`** for toggling [code font](#schema-basic.CodeMark) +// * **Ctrl-Shift-0** for making the current textblock a paragraph +// * **Ctrl-Shift-1** to **Ctrl-Shift-Digit6** for making the current +// textblock a heading of the corresponding level +// * **Ctrl-Shift-Backslash** to make the current textblock a code block +// * **Ctrl-Shift-8** to wrap the selection in an ordered list +// * **Ctrl-Shift-9** to wrap the selection in a bullet list +// * **Ctrl->** to wrap the selection in a block quote +// * **Enter** to split a non-empty textblock in a list item while at +// the same time splitting the list item +// * **Mod-Enter** to insert a hard break +// * **Mod-_** to insert a horizontal rule +// +// You can suppress or map these bindings by passing a `mapKeys` +// argument, which maps key names (say `"Mod-B"` to either `false`, to +// remove the binding, or a new key name string. +function buildKeymap(schema, mapKeys) { + let keys = {}, type; + function bind(key, cmd) { + if (mapKeys) { + const mapped = mapKeys[key]; + if (mapped === false) return; + if (mapped) key = mapped; + } + keys[key] = cmd; + } + + bind('Mod-z', undo); + bind('Mod-y', redo); + + if (type = schema.marks.strong) + bind('Mod-b', toggleMark(type)); + if (type = schema.marks.em) + bind('Mod-i', toggleMark(type)); + if (type = schema.marks.code) + bind('Mod-`', toggleMark(type)); + + if (type = schema.nodes.bullet_list) + bind('Shift-Ctrl-8', wrapInList(type)); + if (type = schema.nodes.ordered_list) + bind('Shift-Ctrl-9', wrapInList(type)); + if (type = schema.nodes.blockquote) + bind('Ctrl->', wrapIn(type)); + if (type = schema.nodes.hard_break) { + let br = type, cmd = chainCommands(newlineInCode, (state, onAction) => { + onAction(state.tr.replaceSelection(br.create()).scrollAction()); + return true; + }); + bind('Mod-Enter', cmd); + bind('Shift-Enter', cmd); + if (mac) bind('Ctrl-Enter', cmd); + } + if (type = schema.nodes.list_item) { + bind('Enter', splitListItem(type)); + bind('Mod-[', liftListItem(type)); + bind('Mod-]', sinkListItem(type)); + } + if (type = schema.nodes.paragraph) + bind('Shift-Ctrl-0', setBlockType(type)); + if (type = schema.nodes.code_block) + bind('Shift-Ctrl-\\', setBlockType(type)); + if (type = schema.nodes.heading) + for (let i = 1; i <= 6; i++) bind(`Shift-Ctrl-${ i }`, setBlockType(type, { level: i })); + if (type = schema.nodes.horizontal_rule) { + const hr = type; + bind('Mod-_', (state, onAction) => { + onAction(state.tr.replaceSelection(hr.create()).scrollAction()); + return true; + }); + } + + if (schema.nodes.table_row) { + bind('Tab', selectNextCell); + bind('Shift-Tab', selectPreviousCell); + } + return keys; +} +exports.buildKeymap = buildKeymap; diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/parser.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/parser.js new file mode 100644 index 00000000..a3d438cc --- /dev/null +++ b/src/components/Widgets/MarkdownControlElements/VisualEditor/parser.js @@ -0,0 +1,254 @@ +/* eslint-disable */ +/* + Based closely on + https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/from_markdown.js + + Adds a bit of logic allowing editor plugins to hook into the parsing. +*/ + +const markdownit = require("markdown-it") +const {Mark} = require("prosemirror-model") + +function maybeMerge(a, b) { + if (a.isText && b.isText && Mark.sameSet(a.marks, b.marks)) + return a.copy(a.text + b.text) +} + +function pluginHandler(schema, plugins) { + return (type, attrs, content) => { + if (type.name === 'paragraph' && content.length === 1 && content[0].type.name === 'text') { + const text = content[0].text; + const plugin = plugins.find(plugin => plugin.get('pattern').test(text)); + if (plugin) { + const nodeType = schema.nodes[`plugin_${ plugin.get('id') }`]; + const data = plugin.get('fromBlock').call(plugin, text.match(plugin.get('pattern'))); + return nodeType.create(data); + } + } + return null; + }; +} + +// Object used to track the context of a running parse. +class MarkdownParseState { + constructor(schema, plugins, tokenHandlers) { + this.schema = schema + this.stack = [{type: schema.nodes.doc, content: []}] + this.marks = Mark.none + this.tokenHandlers = tokenHandlers + this.pluginHandler = pluginHandler(schema, plugins); + } + + top() { + return this.stack[this.stack.length - 1] + } + + push(elt) { + if (this.stack.length) this.top().content.push(elt) + } + + // : (string) + // Adds the given text to the current position in the document, + // using the current marks as styling. + addText(text) { + if (!text) return + let nodes = this.top().content, last = nodes[nodes.length - 1] + let node = this.schema.text(text, this.marks), merged + if (last && (merged = maybeMerge(last, node))) nodes[nodes.length - 1] = merged + else nodes.push(node) + } + + // : (Mark) + // Adds the given mark to the set of active marks. + openMark(mark) { + this.marks = mark.addToSet(this.marks) + } + + // : (Mark) + // Removes the given mark from the set of active marks. + closeMark(mark) { + this.marks = mark.removeFromSet(this.marks) + } + + parseTokens(toks) { + for (let i = 0; i < toks.length; i++) { + let tok = toks[i] + let handler = this.tokenHandlers[tok.type] + if (!handler) + throw new Error("Token type `" + tok.type + "` not supported by Markdown parser") + handler(this, tok) + } + } + + // : (NodeType, ?Object, ?[Node]) → ?Node + // Add a node at the current position. + addNode(type, attrs, content) { + const node = this.pluginHandler(type, attrs, content) || type.createAndFill(attrs, content, this.marks); + if (!node) return null + this.push(node) + return node + } + + // : (NodeType, ?Object) + // Wrap subsequent content in a node of the given type. + openNode(type, attrs) { + this.stack.push({type: type, attrs: attrs, content: []}) + } + + // : () → ?Node + // Close and return the node that is currently on top of the stack. + closeNode() { + if (this.marks.length) this.marks = Mark.none + let info = this.stack.pop() + return this.addNode(info.type, info.attrs, info.content) + } +} + +function attrs(given, token) { + return given instanceof Function ? given(token) : given +} + +// Code content is represented as a single token with a `content` +// property in Markdown-it. +function noOpenClose(type) { + return type == "code_inline" || type == "code_block" || type == "fence" +} + +function withoutTrailingNewline(str) { + return str[str.length - 1] == "\n" ? str.slice(0, str.length - 1) : str +} + +function tokenHandlers(schema, tokens) { + let handlers = Object.create(null) + for (let type in tokens) { + let spec = tokens[type] + if (spec.block) { + let nodeType =schema.nodeType(spec.block); + if (noOpenClose(type)) { + handlers[type] = (state, tok) => { + state.openNode(nodeType, attrs(spec.attrs, tok)) + state.addText(withoutTrailingNewline(tok.content)) + state.closeNode() + } + } else { + handlers[type + "_open"] = (state, tok) => state.openNode(nodeType, attrs(spec.attrs, tok)) + handlers[type + "_close"] = state => state.closeNode() + } + } else if (spec.node) { + let nodeType = schema.nodeType(spec.node) + handlers[type] = (state, tok) => state.addNode(nodeType, attrs(spec.attrs, tok)) + } else if (spec.mark) { + let markType = schema.marks[spec.mark] + if (noOpenClose(type)) { + handlers[type] = (state, tok) => { + state.openMark(markType.create(attrs(spec.attrs, tok))) + state.addText(withoutTrailingNewline(tok.content)) + state.closeMark(markType) + } + } else { + handlers[type + "_open"] = (state, tok) => state.openMark(markType.create(attrs(spec.attrs, tok))) + handlers[type + "_close"] = state => state.closeMark(markType) + } + } else { + throw new RangeError("Unrecognized parsing spec " + JSON.stringify(spec)) + } + } + + handlers.text = (state, tok) => state.addText(tok.content) + handlers.inline = (state, tok) => state.parseTokens(tok.children) + handlers.softbreak = state => state.addText("\n") + + return handlers +} + +// ;; A configuration of a Markdown parser. Such a parser uses +// [markdown-it](https://github.com/markdown-it/markdown-it) to +// tokenize a file, and then runs the custom rules it is given over +// the tokens to create a ProseMirror document tree. +class MarkdownParser { + // :: (Schema, MarkdownIt, Object) + // Create a parser with the given configuration. You can configure + // the markdown-it parser to parse the dialect you want, and provide + // a description of the ProseMirror entities those tokens map to in + // the `tokens` object, which maps token names to descriptions of + // what to do with them. Such a description is an object, and may + // have the following properties: + // + // **`node`**`: ?string` + // : This token maps to a single node, whose type can be looked up + // in the schema under the given name. Exactly one of `node`, + // `block`, or `mark` must be set. + // + // **`block`**`: ?string` + // : This token comes in `_open` and `_close` variants (which are + // appended to the base token name provides a the object + // property), and wraps a block of content. The block should be + // wrapped in a node of the type named to by the property's + // value. + // + // **`mark`**`: ?string` + // : This token also comes in `_open` and `_close` variants, but + // should add a mark (named by the value) to its content, rather + // than wrapping it in a node. + // + // **`attrs`**`: ?union` + // : If the mark or node to be created needs attributes, they can + // be either given directly, or as a function that takes a + // [markdown-it + // token](https://markdown-it.github.io/markdown-it/#Token) and + // returns an attribute object. + constructor(schema, plugins, tokenizer, tokens) { + // :: Object The value of the `tokens` object used to construct + // this parser. Can be useful to copy and modify to base other + // parsers on. + this.tokens = tokens + this.schema = schema + this.tokenizer = tokenizer + this.plugins = plugins + this.tokenHandlers = tokenHandlers(schema, tokens) + } + + // :: (string) → Node + // Parse a string as [CommonMark](http://commonmark.org/) markup, + // and create a ProseMirror document as prescribed by this parser's + // rules. + parse(text) { + let state = new MarkdownParseState(this.schema, this.plugins, this.tokenHandlers), doc + state.parseTokens(this.tokenizer.parse(text, {})) + do { doc = state.closeNode() } while (state.stack.length) + return doc + } +} + +// :: MarkdownParser +// A parser parsing unextended [CommonMark](http://commonmark.org/), +// without inline HTML, and producing a document in the basic schema. +export default function createMarkdownParser(schema, plugins) { + const tokens = { + 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"} + }; + + return new MarkdownParser(schema, plugins, markdownit("commonmark", {html: false}), tokens); +} diff --git a/src/components/Widgets/MarkdownControlElements/VisualEditor/schema.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/schema.js deleted file mode 100644 index 412dae5e..00000000 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/schema.js +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import Block from './Block'; -import styles from './index.css'; - -/* eslint react/prop-types: 0, react/no-multi-comp: 0 */ - -// Define the default node type. -export const DEFAULT_NODE = 'paragraph'; - -/** - * Define a schema. - * - * @type {Object} - */ - -export const SCHEMA = { - nodes: { - 'blockquote': (props) => {props.children}, - 'unordered_list': props =>
      {props.children}
    , - 'header_one': props => {props.children}, - 'header_two': props => {props.children}, - 'header_three': props => {props.children}, - 'header_four': props => {props.children}, - 'header_five': props => {props.children}, - 'header_six': props => {props.children}, - 'list_item': props =>
  • {props.children}
  • , - 'paragraph': props => {props.children}, - 'hr': props => { - const { node, state } = props; - const isFocused = state.selection.hasEdgeIn(node); - const className = isFocused ? styles.active : null; - return ( -
    - ); - }, - 'link': (props) => { - const { data } = props.node; - const href = data.get('href'); - return {props.children}; - }, - 'image': (props) => { - const { node, state } = props; - const isFocused = state.selection.hasEdgeIn(node); - const className = isFocused ? styles.active : null; - const src = node.data.get('src'); - return ( - - ); - } - }, - marks: { - BOLD: { - fontWeight: 'bold' - }, - ITALIC: { - fontStyle: 'italic' - }, - CODE: { - fontFamily: 'monospace', - backgroundColor: '#eee', - padding: '3px', - borderRadius: '4px' - } - } -}; 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/src/components/Widgets/MarkdownControlElements/VisualEditor/withPortalAtCursorPosition.js b/src/components/Widgets/MarkdownControlElements/VisualEditor/withPortalAtCursorPosition.js deleted file mode 100644 index 4ff68cb0..00000000 --- a/src/components/Widgets/MarkdownControlElements/VisualEditor/withPortalAtCursorPosition.js +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import Portal from 'react-portal'; -import position from 'selection-position'; - -export default function withPortalAtCursorPosition(WrappedComponent) { - return class extends React.Component { - - static propTypes = { - isOpen: React.PropTypes.bool.isRequired, - }; - - state = { - menu: null, - cursorPosition: null, - }; - - componentDidMount() { - this.adjustPosition(); - } - - componentDidUpdate() { - this.adjustPosition(); - } - - adjustPosition = () => { - const { menu } = this.state; - - if (!menu) return; - - const cursorPosition = position(); // TODO: Results aren't determenistic - const centerX = Math.ceil( - cursorPosition.left - + cursorPosition.width / 2 - + window.scrollX - - menu.offsetWidth / 2 - ); - const centerY = cursorPosition.top + window.scrollY; - menu.style.opacity = 1; - menu.style.top = `${ centerY }px`; - menu.style.left = `${ centerX }px`; - }; - - /** - * When the portal opens, cache the menu element. - */ - handleOpen = (portal) => { - this.setState({ menu: portal.firstChild }); - }; - - render() { - const { isOpen, ...rest } = this.props; - return ( - - - - ); - } - }; -} diff --git a/yarn.lock b/yarn.lock index b2db4520..2782d87c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2872,6 +2872,10 @@ extend@^3.0.0, extend@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.0.tgz#5a474353b9f3353ddd8176dfd37b91c83a46f1d4" +extending-char@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/extending-char/-/extending-char-1.0.1.tgz#4c6c0eee3658a49df1600b32fc73876f418c7c6c" + extglob@^0.3.1: version "0.3.2" resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" @@ -4733,6 +4737,12 @@ liftoff@^2.2.0: rechoir "^0.6.2" resolve "^1.1.7" +linkify-it@~1.2.2: + version "1.2.4" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-1.2.4.tgz#0773526c317c8fd13bd534ee1d180ff88abf881a" + dependencies: + uc.micro "^1.0.1" + lint-staged@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-3.1.0.tgz#4bb3da3b98135b0a076606c5e4f129af034bfe48" @@ -5271,6 +5281,16 @@ map-obj@^1.0.0, map-obj@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" +markdown-it@^6.0.4: + version "6.1.1" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-6.1.1.tgz#ced037f4473ee9f5153ac414f77dc83c91ba927c" + dependencies: + argparse "^1.0.7" + entities "~1.1.1" + linkify-it "~1.2.2" + mdurl "~1.0.1" + uc.micro "^1.0.1" + marked-terminal@^1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/marked-terminal/-/marked-terminal-1.6.2.tgz#44c128d69b5d9776c848314cdf69d4ec96322973" @@ -5310,6 +5330,10 @@ math-expression-evaluator@^1.2.14: dependencies: lodash.indexof "^4.0.5" +mdurl@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -6711,6 +6735,89 @@ proper-lockfile@^1.1.2: graceful-fs "^4.1.2" retry "^0.10.0" +prosemirror-commands@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-0.12.0.tgz#d790fe3dbabb5221e4d87e82834835e0f65881b2" + dependencies: + extending-char "^1.0.0" + prosemirror-model "^0.12.0" + prosemirror-state "^0.12.0" + prosemirror-transform "^0.12.0" + +prosemirror-history@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-0.12.1.tgz#cbcdb536455b6af36bd2ba3ccced5387e5cfbfe1" + dependencies: + prosemirror-state "^0.12.0" + prosemirror-transform "^0.12.0" + rope-sequence "^1.2.0" + +prosemirror-inputrules@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-0.12.0.tgz#2e07b5cb1bfc7007c2b51ea5394303204b4b34df" + dependencies: + prosemirror-state "^0.12.0" + prosemirror-transform "^0.12.0" + +prosemirror-keymap@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-0.12.0.tgz#b70645b5d3f5ff4843bc6d26a74fa0022b504221" + dependencies: + prosemirror-state "^0.12.0" + w3c-keyname "^1.1.0" + +prosemirror-markdown@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-0.12.0.tgz#7ff8557c159168dcb532833c0b23b5b2866715c8" + dependencies: + markdown-it "^6.0.4" + prosemirror-model "~0.12.0" + +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" + +prosemirror-schema-basic@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/prosemirror-schema-basic/-/prosemirror-schema-basic-0.12.0.tgz#9af876f8a915e75ba65847c794eebfc0df9f274e" + dependencies: + prosemirror-model "^0.12.0" + +prosemirror-schema-list@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-0.12.0.tgz#d93ba425ed202fc113d7b3388e5d9be1f698c276" + dependencies: + prosemirror-model "^0.12.0" + prosemirror-transform "^0.12.0" + +prosemirror-schema-table@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/prosemirror-schema-table/-/prosemirror-schema-table-0.12.0.tgz#a665dcb66bbd4c0ff2eac492d82991c6c410b5f3" + dependencies: + prosemirror-model "^0.12.0" + prosemirror-state "^0.12.0" + prosemirror-transform "^0.12.0" + +prosemirror-state@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-0.12.0.tgz#16e13d57d91840d0c3c340d47694efabeb77e987" + dependencies: + prosemirror-model "^0.12.0" + prosemirror-transform "^0.12.0" + +prosemirror-transform@^0.12.0, prosemirror-transform@^0.12.1: + 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-view@^0.12.0: + version "0.12.2" + resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-0.12.2.tgz#4a48bfe2ae3119b8c0c79166d7cd73e82284c99d" + dependencies: + prosemirror-model "^0.12.0" + prosemirror-state "^0.12.0" + proxy-addr@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.2.tgz#b4cc5f22610d9535824c123aef9d3cf73c40ba37" @@ -7391,6 +7498,10 @@ rollup@^0.36.0: dependencies: source-map-support "^0.4.0" +rope-sequence@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.2.1.tgz#7da14c04fdc06f60bacdb9d26936c56265ffee2e" + rsvp@^3.0.13, rsvp@^3.0.18: version "3.3.3" resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.3.3.tgz#34633caaf8bc66ceff4be3c2e1dffd032538a813" @@ -8297,6 +8408,10 @@ ua-parser-js@^0.7.10, ua-parser-js@^0.7.9: version "0.7.10" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.10.tgz#917559ddcce07cbc09ece7d80495e4c268f4ef9f" +uc.micro@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.3.tgz#7ed50d5e0f9a9fb0a573379259f2a77458d50192" + uglify-js@^2.6, uglify-js@^2.6.1: version "2.7.3" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.7.3.tgz#39b3a7329b89f5ec507e344c6e22568698ef4868" @@ -8468,6 +8583,10 @@ vm-browserify@0.0.4: dependencies: indexof "0.0.1" +w3c-keyname@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-1.1.1.tgz#0bb8566fbba0e414c2b798b696a71e1726967661" + walkdir@0.0.11: version "0.0.11" resolved "https://registry.yarnpkg.com/walkdir/-/walkdir-0.0.11.tgz#a16d025eb931bd03b52f308caed0f40fcebe9532"