diff --git a/package.json b/package.json index 5ba48323..3ba523ce 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,7 @@ "remark-stringify": "^3.0.1", "selection-position": "^1.0.0", "semaphore": "^1.0.5", - "slate": "^0.20.3", + "slate": "^0.20.6", "slate-drop-or-paste-images": "^0.2.0", "slate-edit-list": "^0.7.1", "slug": "^0.9.1", diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css index 9a1ec80b..81ab24db 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css +++ b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css @@ -100,3 +100,15 @@ margin-left: 0; margin-right: 0; } } + +.shortcode { + border: 2px solid black; + padding: 8px; + margin: 2px 0; + cursor: pointer; +} + +.shortcodeSelected { + border-color: var(--primaryColor); + color: var(--primaryColor); +} diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js index af58117a..f0871fd3 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js +++ b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js @@ -1,6 +1,9 @@ import React, { Component, PropTypes } from 'react'; -import { Map, List } from 'immutable'; -import { Editor as SlateEditor, Html as SlateHtml, Raw as SlateRaw} from 'slate'; +import ReactDOMServer from 'react-dom/server'; +import { Map, List, fromJS } from 'immutable'; +import { reduce, mapValues } from 'lodash'; +import cn from 'classnames'; +import { Editor as SlateEditor, Html as SlateHtml, Raw as SlateRaw, Text as SlateText, Block as SlateBlock, Selection as SlateSelection} from 'slate'; import EditList from 'slate-edit-list'; import { markdownToHtml, htmlToMarkdown } from '../../unified'; import registry from '../../../../../lib/registry'; @@ -96,7 +99,8 @@ const MARK_TAGS = { } const BLOCK_COMPONENTS = { - 'paragraph': props =>

{props.children}

, + 'container': props =>
{props.children}
, + 'paragraph': props =>

{props.children}

, 'list-item': props =>
  • {props.children}
  • , 'bulleted-list': props => , 'numbered-list': props =>
      {props.children}
    , @@ -110,11 +114,20 @@ const BLOCK_COMPONENTS = { 'heading-six': props =>
    {props.children}
    , 'image': props => { const data = props.node && props.node.get('data'); - const src = data && data.get('src', props.src); - const alt = data && data.get('alt', props.alt); + const src = data && data.get('src') || props.src; + const alt = data && data.get('alt') || props.alt; return {alt}; }, }; +const getShortcodeId = props => { + if (props.node) { + const result = props.node.getIn(['data', 'shortcode', 'shortcodeId']); + return result || props.node.getIn(['data', 'shortcode']).shortcodeId; + } + return null; +} + +const shortcodeStyles = {border: '2px solid black', padding: '8px', margin: '2px 0', cursor: 'pointer'}; const NODE_COMPONENTS = { ...BLOCK_COMPONENTS, @@ -122,6 +135,19 @@ const NODE_COMPONENTS = { const href = props.node && props.node.getIn(['data', 'href']) || props.href; return {props.children}; }, + 'shortcode': props => { + const { attributes, node, state: editorState } = props; + const isSelected = editorState.selection.hasFocusIn(node); + return ( +
    + {getShortcodeId(props)} +
    + ); + }, }; const MARK_COMPONENTS = { @@ -133,6 +159,50 @@ const MARK_COMPONENTS = { }; const RULES = [ + { + deserialize(el, next) { + const shortcodeId = el.attribs && el.attribs['data-ncp']; + if (!shortcodeId) { + return; + } + const plugin = registry.getEditorComponents().get(shortcodeId); + if (!plugin) { + return; + } + const shortcodeData = Map(el.attribs).reduce((acc, value, key) => { + if (key.startsWith('data-ncp-')) { + const dataKey = key.slice('data-ncp-'.length).toLowerCase(); + if (dataKey) { + return acc.set(dataKey, value); + } + } + return acc; + }, Map({ shortcodeId })); + + const result = { + kind: 'block', + isVoid: true, + type: 'shortcode', + data: { shortcode: shortcodeData }, + }; + return result; + }, + serialize(entity, children) { + if (entity.type !== 'shortcode') { + return; + } + + const data = Map(entity.data.get('shortcode')); + const shortcodeId = data.get('shortcodeId'); + const plugin = registry.getEditorComponents().get(shortcodeId); + const dataAttrs = data.delete('shortcodeId').mapKeys(key => `data-ncp-${key}`).set('data-ncp', shortcodeId); + const preview = plugin.toPreview(data.toJS()); + const component = typeof preview === 'string' + ?
    + :
    {preview}
    ; + return component; + }, + }, { deserialize(el, next) { const block = BLOCK_TAGS[el.tagName] @@ -216,9 +286,9 @@ const RULES = [ const props = { src: data.get('src'), alt: data.get('alt'), - attributes: data.get('attributes'), }; - return NODE_COMPONENTS.image(props); + const result = NODE_COMPONENTS.image(props); + return result; } }, { @@ -313,11 +383,44 @@ export default class Editor extends Component { constructor(props) { super(props); const plugins = registry.getEditorComponents(); + // Wrap value in div to ensure against trailing text outside of top level html element + const initialValue = this.props.value ? `
    ${this.props.value}
    ` : '

    '; this.state = { - editorState: serializer.deserialize(this.props.value || '

    '), + editorState: serializer.deserialize(initialValue), schema: { nodes: NODE_COMPONENTS, marks: MARK_COMPONENTS, + rules: [ + { + match: object => object.kind === 'document', + validate: doc => { + const blocks = doc.getBlocks(); + const firstBlock = blocks.first(); + const lastBlock = blocks.last(); + const firstBlockIsVoid = firstBlock.isVoid; + const lastBlockIsVoid = lastBlock.isVoid; + + if (firstBlockIsVoid || lastBlockIsVoid) { + return { blocks, firstBlock, lastBlock, firstBlockIsVoid, lastBlockIsVoid }; + } + }, + normalize: (transform, doc, { blocks, firstBlock, lastBlock, firstBlockIsVoid, lastBlockIsVoid }) => { + const block = SlateBlock.create({ + type: 'paragraph', + nodes: [SlateText.createFromString('')], + }); + if (firstBlockIsVoid) { + const { key } = transform.state.document; + transform.insertNodeByKey(key, 0, block); + } + if (lastBlockIsVoid) { + const { key, nodes } = transform.state.document; + transform.insertNodeByKey(key, nodes.size, block); + } + return transform; + }, + } + ], }, plugins, }; @@ -425,57 +528,13 @@ 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()); - }; - - handleDragEnter = (e) => { - e.preventDefault(); - this.setState({ dragging: true }); - }; - - handleDragLeave = (e) => { - e.preventDefault(); - this.setState({ dragging: false }); - }; - - handleDragOver = (e) => { - e.preventDefault(); - }; - - handleDrop = (e) => { - e.preventDefault(); - - this.setState({ dragging: false }); - - const { schema } = this.state; - - const nodes = []; - - if (e.dataTransfer.files && e.dataTransfer.files.length) { - Array.from(e.dataTransfer.files).forEach((file) => { - createAssetProxy(file.name, file) - .then((assetProxy) => { - this.props.onAddAsset(assetProxy); - if (file.type.split('/')[0] === 'image') { - nodes.push( - 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 }) - ); - } - }); - }); - } else { - 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()); - }); + const { editorState } = this.state; + const markdown = plugin.toBlock(data.toJS()); + const html = markdownToHtml(markdown); + const block = serializer.deserialize(html).document.getBlocks().first(); + const resolvedState = editorState.transform().insertBlock(block).apply(); + this.ref.onChange(resolvedState); + this.setState({ editorState: resolvedState }); }; handleToggle = () => { @@ -491,58 +550,49 @@ export default class Editor extends Component { render() { const { onAddAsset, onRemoveAsset, getAsset } = this.props; const { plugins, selectionPosition, dragging } = this.state; - const classNames = [styles.editor]; - if (dragging) { - classNames.push(styles.dragging); - } - return (
    - - + + + + this.setState({ editorState })} + onDocumentChange={this.handleDocumentChange} + onKeyDown={this.onKeyDown} + onPaste={this.handlePaste} + ref={ref => this.ref = ref} + spellCheck /> - - this.setState({ editorState })} - onDocumentChange={this.handleDocumentChange} - onKeyDown={this.onKeyDown} - onPaste={this.handlePaste} - ref={ref => this.ref = ref} - spellCheck - /> -
    -
    ); +
    + ); } } diff --git a/src/components/Widgets/Markdown/unified.js b/src/components/Widgets/Markdown/unified.js index 046ee2c5..ba011474 100644 --- a/src/components/Widgets/Markdown/unified.js +++ b/src/components/Widgets/Markdown/unified.js @@ -103,6 +103,9 @@ const rehypeShortcodes = () => { const plugins = registry.getEditorComponents(); const transform = node => { const { properties } = node; + + // Convert this logic into a parseShortcodeDataFromHtml shared function, as + // this is also used in the visual editor serializer const dataPrefix = `data${capitalize(shortcodeAttributePrefix)}`; const pluginId = properties && properties[dataPrefix]; const plugin = plugins.get(pluginId); @@ -131,6 +134,15 @@ const rehypeShortcodes = () => { return transform; } +/** + * we can't escape the less than symbol + * which means how do we know {{}} from ? + * maybe we escape nothing + * then we can check for shortcodes in a unified plugin + * and only check against text nodes + * and maybe narrow the target text nodes even further somehow + * and make shortcode parsing faster + */ function remarkPrecompileShortcodes() { const Compiler = this.Compiler; const visitors = Compiler.prototype.visitors;