From 6377d8c73e72380f9d6a50b4eb921a67e558a74f Mon Sep 17 00:00:00 2001 From: Shawn Erquhart Date: Thu, 27 Jul 2017 18:03:13 -0400 Subject: [PATCH] initial refactor, some bugfixes --- .../MarkdownControl/RawEditor/index.css | 4 +- .../MarkdownControl/RawEditor/index.js | 38 ++- .../VisualEditor/components.js | 49 +++ .../MarkdownControl/VisualEditor/index.css | 23 +- .../MarkdownControl/VisualEditor/index.js | 304 ++---------------- .../MarkdownControl/VisualEditor/keys.js | 67 ++++ .../MarkdownControl/VisualEditor/plugins.js | 90 ++++++ .../MarkdownControl/VisualEditor/rules.js | 30 ++ .../Widgets/Markdown/MarkdownControl/index.js | 8 +- src/components/Widgets/Markdown/unified.js | 34 +- 10 files changed, 313 insertions(+), 334 deletions(-) create mode 100644 src/components/Widgets/Markdown/MarkdownControl/VisualEditor/components.js create mode 100644 src/components/Widgets/Markdown/MarkdownControl/VisualEditor/keys.js create mode 100644 src/components/Widgets/Markdown/MarkdownControl/VisualEditor/plugins.js create mode 100644 src/components/Widgets/Markdown/MarkdownControl/VisualEditor/rules.js diff --git a/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.css b/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.css index aa2fec15..30b75f9c 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.css +++ b/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.css @@ -1,6 +1,6 @@ @import "../../../../UI/theme"; -.root { +.rawWrapper { position: relative; } @@ -12,7 +12,7 @@ composes: editorControlBarSticky from "../VisualEditor/index.css"; } -.SlateEditor { +.rawEditor { position: relative; overflow: hidden; overflow-x: auto; diff --git a/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.js b/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.js index 15bef1b6..6615a1f2 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.js +++ b/src/components/Widgets/Markdown/MarkdownControl/RawEditor/index.js @@ -1,5 +1,5 @@ import React, { PropTypes } from 'react'; -import { Editor as SlateEditor, Plain as SlatePlain } from 'slate'; +import { Editor as Slate, Plain } from 'slate'; import { markdownToRemark, remarkToMarkdown } from '../../unified'; import Toolbar from '../Toolbar/Toolbar'; import { Sticky } from '../../../../UI/Sticky/Sticky'; @@ -8,32 +8,44 @@ import styles from './index.css'; export default class RawEditor extends React.Component { constructor(props) { super(props); + /** + * The value received is a Remark AST (MDAST), and must be stringified + * to plain text before Slate's Plain serializer can convert it to the + * Slate AST. + */ const value = remarkToMarkdown(this.props.value); this.state = { - editorState: SlatePlain.deserialize(value || ''), + editorState: Plain.deserialize(value || ''), }; } shouldComponentUpdate(nextProps, nextState) { - if (this.state.editorState.equals(nextState.editorState)) { - return false - } - return true; + return !this.state.editorState.equals(nextState.editorState); } handleChange = editorState => { this.setState({ editorState }); } + /** + * When the document value changes, serialize from Slate's AST back to plain + * text (which is Markdown), and then deserialize from that to a Remark MDAST, + * before passing up as the new value. + */ handleDocumentChange = (doc, editorState) => { - const value = SlatePlain.serialize(editorState); - const html = markdownToRemark(value); - this.props.onChange(html); + const value = Plain.serialize(editorState); + const mdast = markdownToRemark(value); + this.props.onChange(mdast); }; + /** + * If a paste contains plain text, deserialize it to Slate's AST and insert + * to the document. Selection logic (where to insert, whether to replace) is + * handled by Slate. + */ handlePaste = (e, data, state) => { if (data.text) { - const fragment = SlatePlain.deserialize(data.text).document; + const fragment = Plain.deserialize(data.text).document; return state.transform().insertFragment(fragment).apply(); } }; @@ -44,7 +56,7 @@ export default class RawEditor extends React.Component { render() { return ( -
+
- {props.children}, + italic: props => {props.children}, + strikethrough: props => {props.children}, + code: props => {props.children}, +}; + +export const NODE_COMPONENTS = { + paragraph: props =>

{props.children}

, + 'list-item': props =>
  • {props.children}
  • , + 'bulleted-list': props =>
      {props.children}
    , + 'numbered-list': props => +
      {props.children}
    , + quote: props =>
    {props.children}
    , + code: props =>
    {props.children}
    , + 'heading-one': props =>

    {props.children}

    , + 'heading-two': props =>

    {props.children}

    , + 'heading-three': props =>

    {props.children}

    , + 'heading-four': props =>

    {props.children}

    , + 'heading-five': props =>
    {props.children}
    , + 'heading-six': props =>
    {props.children}
    , + table: props => {props.children}
    , + 'table-row': props => {props.children}, + 'table-cell': props => {props.children}, + 'thematic-break': props =>
    , + 'shortcode-wrapper': props =>
    {props.children}
    , + link: props => { + const data = props.node.get('data'); + const url = data.get('url'); + const title = data.get('title'); + return {props.children}; + }, + shortcode: props => { + const { attributes, node, state: editorState } = props; + const isSelected = editorState.selection.hasFocusIn(node); + const className = cn(styles.shortcode, { [styles.shortcodeSelected]: isSelected }); + return {node.data.get('shortcode')}; + }, +}; diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css index b7a3aafb..6c40e7c4 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css +++ b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.css @@ -11,7 +11,7 @@ border-color: var(--textFieldBorderColor); } -.editor { +.wrapper { position: relative; & h1, & h2, & h3 { padding: 0; @@ -49,26 +49,7 @@ } } -.dragging { } - -.shim { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: none; - border: 2px dashed #aaa; - background: rgba(0,0,0,0.2); -} - -.dragging .shim { - z-index: 1000; - display: block; - pointer-events: none; -} - -.slateEditor { +.editor { position: relative; overflow: hidden; overflow-x: auto; diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js index 75f3072f..70c067e2 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js +++ b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/index.js @@ -1,215 +1,37 @@ import React, { Component, PropTypes } from 'react'; -import ReactDOMServer from 'react-dom/server'; -import { Map, List, fromJS } from 'immutable'; -import { get, reduce, mapValues } from 'lodash'; -import cn from 'classnames'; -import { Editor as SlateEditor, Raw as SlateRaw, Text as SlateText, Block as SlateBlock, Selection as SlateSelection} from 'slate'; -import EditList from 'slate-edit-list'; -import EditTable from 'slate-edit-table'; -import { markdownToRemark, remarkToMarkdown, slateToRemark, remarkToSlate, markdownToHtml, htmlToSlate } from '../../unified'; +import { get, isEmpty } from 'lodash'; +import { Editor as Slate, Raw, Block, Text } from 'slate'; +import { slateToRemark, remarkToSlate, htmlToSlate } from '../../unified'; import registry from '../../../../../lib/registry'; -import { createAssetProxy } from '../../../../../valueObjects/AssetProxy'; import Toolbar from '../Toolbar/Toolbar'; import { Sticky } from '../../../../UI/Sticky/Sticky'; +import { MARK_COMPONENTS, NODE_COMPONENTS } from './components'; +import RULES from './rules'; +import plugins, { EditListConfigured } from './plugins'; +import onKeyDown from './keys'; import styles from './index.css'; -const DEFAULT_NODE = 'paragraph'; - -const BLOCK_TAGS = { - p: 'paragraph', - li: 'list-item', - ul: 'bulleted-list', - ol: 'numbered-list', - blockquote: 'quote', - pre: 'code', - h1: 'heading-one', - h2: 'heading-two', - h3: 'heading-three', - h4: 'heading-four', - h5: 'heading-five', - h6: 'heading-six', -} - -const MARK_TAGS = { - strong: 'bold', - em: 'italic', - u: 'underline', - s: 'strikethrough', - del: 'strikethrough', - code: 'code' -} - -const BLOCK_COMPONENTS = { - 'container': props =>
    {props.children}
    , - 'paragraph': props =>

    {props.children}

    , - 'list-item': props =>
  • {props.children}
  • , - 'numbered-list': props => { - const { data } = props.node; - const start = data.get('start') || 1; - return
      {props.children}
    ; - }, - 'bulleted-list': props =>
      {props.children}
    , - 'quote': props =>
    {props.children}
    , - 'code': props =>
    {props.children}
    , - 'heading-one': props =>

    {props.children}

    , - 'heading-two': props =>

    {props.children}

    , - 'heading-three': props =>

    {props.children}

    , - 'heading-four': props =>

    {props.children}

    , - 'heading-five': props =>
    {props.children}
    , - 'heading-six': props =>
    {props.children}
    , - 'image': props => { - const data = props.node && props.node.get('data'); - const src = data.get('url'); - const alt = data.get('alt'); - const title = data.get('title'); - return
    {alt}
    ; - }, - 'table': props => {props.children}
    , - 'table-row': props => {props.children}, - 'table-cell': props => {props.children}, - 'thematic-break': props =>
    , -}; -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, - 'link': props => { - const data = props.node.get('data'); - const href = data.get('url'); - const title = data.get('title'); - return {props.children}; - }, - 'shortcode': props => { - const { attributes, node, state: editorState } = props; - const { data } = node; - const isSelected = editorState.selection.hasFocusIn(node); - return ( -
    - {data.get('shortcode')} -
    - ); - }, -}; - -const MARK_COMPONENTS = { - bold: props => {props.children}, - italic: props => {props.children}, - strikethrough: props => {props.children}, - code: props => {props.children}, -}; - -const SoftBreak = (options = {}) => ({ - onKeyDown(e, data, state) { - if (data.key != 'enter') return; - if (options.shift && e.shiftKey == false) return; - - const { onlyIn, ignoreIn, closeAfter, unwrapBlocks, defaultBlock = 'paragraph' } = options; - const { type, nodes } = state.startBlock; - if (onlyIn && !onlyIn.includes(type)) return; - if (ignoreIn && ignoreIn.includes(type)) return; - - const shouldClose = nodes.last().characters.takeLast(closeAfter).every(c => c.text === '\n'); - if (closeAfter && shouldClose) { - const trimmed = state.transform().deleteBackward(closeAfter); - const unwrapped = unwrapBlocks - ? unwrapBlocks.reduce((acc, blockType) => acc.unwrapBlock(blockType), trimmed) - : trimmed; - return unwrapped.insertBlock(defaultBlock).apply(); - } - - return state.transform().insertText('\n').apply(); - } -}); - -const BackspaceCloseBlock = (options = {}) => ({ - onKeyDown(e, data, state) { - if (data.key != 'backspace') return; - - const { defaultBlock = 'paragraph', ignoreIn, onlyIn } = options; - const { startBlock } = state; - const { type } = startBlock; - - if (onlyIn && !onlyIn.includes(type)) return; - if (ignoreIn && ignoreIn.includes(type)) return; - - const characters = startBlock.getFirstText().characters; - const isEmpty = !characters || characters.isEmpty(); - - if (isEmpty) { - return state.transform().insertBlock(defaultBlock).focus().apply(); - } - } -}); - -const EditListPlugin = EditList({ types: ['bulleted-list', 'numbered-list'], typeItem: 'list-item' }); - -const slatePlugins = [ - SoftBreak({ ignoreIn: ['paragraph', 'list-item', 'numbered-list', 'bulleted-list', 'table', 'table-row', 'table-cell'], closeAfter: 1 }), - BackspaceCloseBlock({ ignoreIn: ['paragraph', 'list-item', 'bulleted-list', 'numbered-list', 'table', 'table-row', 'table-cell'] }), - EditListPlugin, - EditTable({ typeTable: 'table', typeRow: 'table-row', typeCell: 'table-cell' }), -]; - export default class Editor extends Component { constructor(props) { super(props); - const plugins = registry.getEditorComponents(); - const emptyRaw = { - nodes: [{ kind: 'block', type: 'paragraph', nodes: [ - { kind: 'text', ranges: [{ text: '' }] } - ]}], - }; - const remark = this.props.value && remarkToSlate(this.props.value); - const initialValue = get(remark, ['nodes', 'length']) ? remark : emptyRaw; - const editorState = SlateRaw.deserialize(initialValue, { terse: true }); + const emptyBlock = Block.create({ kind: 'block', type: 'paragraph'}); + const emptyRaw = { nodes: [emptyBlock] }; + const mdast = this.props.value && remarkToSlate(this.props.value); + const mdastHasNodes = !isEmpty(get(mdast, 'nodes')) + const editorState = Raw.deserialize(mdastHasNodes ? mdast : emptyRaw, { terse: true }); this.state = { editorState, schema: { nodes: NODE_COMPONENTS, marks: MARK_COMPONENTS, - rules: [ - /** - * If the editor is ever in an empty state, insert an empty - * paragraph block. - */ - { - match: object => object.kind === 'document', - validate: doc => { - const hasBlocks = !doc.getBlocks().isEmpty(); - return hasBlocks ? null : {}; - }, - normalize: transform => { - const block = SlateBlock.create({ - type: 'paragraph', - nodes: [SlateText.createFromString('')], - }); - const { key } = transform.state.document; - return transform.insertNodeByKey(key, 0, block).focus(); - }, - }, - ], + rules: RULES, }, - plugins, + shortcodes: registry.getEditorComponents(), }; } shouldComponentUpdate(nextProps, nextState) { - if (this.state.editorState.equals(nextState.editorState)) { - return false - } - return true; + return !this.state.editorState.equals(nextState.editorState); } handlePaste = (e, data, state) => { @@ -217,12 +39,12 @@ export default class Editor extends Component { return; } const ast = htmlToSlate(data.html); - const { document: doc } = SlateRaw.deserialize(ast, { terse: true }); + const { document: doc } = Raw.deserialize(ast, { terse: true }); return state.transform().insertFragment(doc).apply(); } handleDocumentChange = (doc, editorState) => { - const raw = SlateRaw.serialize(editorState, { terse: true }); + const raw = Raw.serialize(editorState, { terse: true }); const mdast = slateToRemark(raw); this.props.onChange(mdast); }; @@ -230,70 +52,6 @@ export default class Editor extends Component { hasMark = type => this.state.editorState.marks.some(mark => mark.type === type); hasBlock = type => this.state.editorState.blocks.some(node => node.type === type); - handleKeyDown = (e, data, state) => { - const createDefaultBlock = () => { - return SlateBlock.create({ - type: 'paragraph', - nodes: [SlateText.createFromString('')] - }); - }; - if (data.key === 'enter') { - /** - * If "Enter" is pressed while a single void block is selected, a new - * paragraph should be added above or below it, and the current selection - * should be collapsed to the start of the new paragraph. - * - * If the selected block is the first block in the document, create the - * new block above it. If not, create the new block below it. - */ - const { document: doc, selection, anchorBlock, focusBlock } = state; - const singleBlockSelected = anchorBlock === focusBlock; - if (!singleBlockSelected || !focusBlock.isVoid) return; - - e.preventDefault(); - - const focusBlockParent = doc.getParent(focusBlock.key); - const focusBlockIndex = focusBlockParent.nodes.indexOf(focusBlock); - const focusBlockIsFirstChild = focusBlockIndex === 0; - - const newBlock = createDefaultBlock(); - const newBlockIndex = focusBlockIsFirstChild ? 0 : focusBlockIndex + 1; - - return state.transform() - .insertNodeByKey(focusBlockParent.key, newBlockIndex, newBlock) - .collapseToStartOf(newBlock) - .apply(); - } - - if (data.isMod) { - - if (data.key === 'y') { - e.preventDefault(); - return state.transform().redo().focus().apply({ save: false }); - } - - if (data.key === 'z') { - e.preventDefault(); - return state.transform()[data.isShift ? 'redo' : 'undo']().focus().apply({ save: false }); - } - - const marks = { - b: 'bold', - i: 'italic', - u: 'underlined', - s: 'strikethrough', - '`': 'code', - }; - - const mark = marks[data.key]; - - if (mark) { - e.preventDefault(); - return state.transform().toggleMark(mark).apply(); - } - } - }; - handleMarkClick = (event, type) => { event.preventDefault(); const resolvedState = this.state.editorState.transform().focus().toggleMark(type).apply(); @@ -310,7 +68,7 @@ export default class Editor extends Component { // Handle everything except list buttons. if (!['bulleted-list', 'numbered-list'].includes(type)) { const isActive = this.hasBlock(type); - const transformed = transform.setBlock(isActive ? DEFAULT_NODE : type); + const transformed = transform.setBlock(isActive ? 'paragraph' : type); } // Handle the extra wrapping required for list buttons. @@ -318,16 +76,16 @@ export default class Editor extends Component { const isSameListType = editorState.blocks.some(block => { return !!doc.getClosest(block.key, parent => parent.type === type); }); - const isInList = EditListPlugin.utils.isSelectionInList(editorState); + const isInList = EditListConfigured.utils.isSelectionInList(editorState); if (isInList && isSameListType) { - EditListPlugin.transforms.unwrapList(transform, type); + EditListConfigured.transforms.unwrapList(transform, type); } else if (isInList) { const currentListType = type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list'; - EditListPlugin.transforms.unwrapList(transform, currentListType); - EditListPlugin.transforms.wrapInList(transform, type); + EditListConfigured.transforms.unwrapList(transform, currentListType); + EditListConfigured.transforms.wrapInList(transform, type); } else { - EditListPlugin.transforms.wrapInList(transform, type); + EditListConfigured.transforms.wrapInList(transform, type); } } @@ -381,7 +139,7 @@ export default class Editor extends Component { shortcodeValue: plugin.toBlock(shortcodeData.toJS()), shortcodeData, }; - const nodes = [SlateText.createFromString('')]; + const nodes = [Text.createFromString('')]; const block = { kind: 'block', type: 'shortcode', data, isVoid: true, nodes }; const resolvedState = editorState.transform().insertBlock(block).apply(); this.ref.onChange(resolvedState); @@ -401,17 +159,15 @@ export default class Editor extends Component { render() { const { onAddAsset, onRemoveAsset, getAsset } = this.props; - const { plugins, selectionPosition, dragging } = this.state; return ( -
    +
    - this.setState({ editorState })} onDocumentChange={this.handleDocumentChange} - onKeyDown={this.handleKeyDown} + onKeyDown={onKeyDown} onPaste={this.handlePaste} ref={ref => this.ref = ref} spellCheck diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/keys.js b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/keys.js new file mode 100644 index 00000000..3a5f839d --- /dev/null +++ b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/keys.js @@ -0,0 +1,67 @@ +import { Block, Text } from 'slate'; + +export default onKeyDown; + +function onKeyDown(e, data, state) { + const createDefaultBlock = () => { + return Block.create({ + type: 'paragraph', + nodes: [Text.createFromString('')] + }); + }; + if (data.key === 'enter') { + /** + * If "Enter" is pressed while a single void block is selected, a new + * paragraph should be added above or below it, and the current selection + * should be collapsed to the start of the new paragraph. + * + * If the selected block is the first block in the document, create the + * new block above it. If not, create the new block below it. + */ + const { document: doc, selection, anchorBlock, focusBlock } = state; + const singleBlockSelected = anchorBlock === focusBlock; + if (!singleBlockSelected || !focusBlock.isVoid) return; + + e.preventDefault(); + + const focusBlockParent = doc.getParent(focusBlock.key); + const focusBlockIndex = focusBlockParent.nodes.indexOf(focusBlock); + const focusBlockIsFirstChild = focusBlockIndex === 0; + + const newBlock = createDefaultBlock(); + const newBlockIndex = focusBlockIsFirstChild ? 0 : focusBlockIndex + 1; + + return state.transform() + .insertNodeByKey(focusBlockParent.key, newBlockIndex, newBlock) + .collapseToStartOf(newBlock) + .apply(); + } + + if (data.isMod) { + + if (data.key === 'y') { + e.preventDefault(); + return state.transform().redo().focus().apply({ save: false }); + } + + if (data.key === 'z') { + e.preventDefault(); + return state.transform()[data.isShift ? 'redo' : 'undo']().focus().apply({ save: false }); + } + + const marks = { + b: 'bold', + i: 'italic', + u: 'underlined', + s: 'strikethrough', + '`': 'code', + }; + + const mark = marks[data.key]; + + if (mark) { + e.preventDefault(); + return state.transform().toggleMark(mark).apply(); + } + } +}; diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/plugins.js b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/plugins.js new file mode 100644 index 00000000..308a403f --- /dev/null +++ b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/plugins.js @@ -0,0 +1,90 @@ +import EditList from 'slate-edit-list'; +import EditTable from 'slate-edit-table'; + +const SoftBreak = (options = {}) => ({ + onKeyDown(e, data, state) { + if (data.key != 'enter') return; + if (options.shift && e.shiftKey == false) return; + + const { onlyIn, ignoreIn, closeAfter, unwrapBlocks, defaultBlock = 'paragraph' } = options; + const { type, nodes } = state.startBlock; + if (onlyIn && !onlyIn.includes(type)) return; + if (ignoreIn && ignoreIn.includes(type)) return; + + const shouldClose = nodes.last().characters.takeLast(closeAfter).every(c => c.text === '\n'); + if (closeAfter && shouldClose) { + const trimmed = state.transform().deleteBackward(closeAfter); + const unwrapped = unwrapBlocks + ? unwrapBlocks.reduce((acc, blockType) => acc.unwrapBlock(blockType), trimmed) + : trimmed; + return unwrapped.insertBlock(defaultBlock).apply(); + } + + return state.transform().insertText('\n').apply(); + } +}); + +const SoftBreakOpts = { + onlyIn: ['quote', 'code'], + closeAfter: 1 +}; + +export const SoftBreakConfigured = SoftBreak(SoftBreakOpts); + +const BackspaceCloseBlock = (options = {}) => ({ + onKeyDown(e, data, state) { + if (data.key != 'backspace') return; + + const { defaultBlock = 'paragraph', ignoreIn, onlyIn } = options; + const { startBlock } = state; + const { type } = startBlock; + + if (onlyIn && !onlyIn.includes(type)) return; + if (ignoreIn && ignoreIn.includes(type)) return; + + const characters = startBlock.getFirstText().characters; + const isEmpty = !characters || characters.isEmpty(); + + if (isEmpty) { + return state.transform().insertBlock(defaultBlock).focus().apply(); + } + } +}); + +const BackspaceCloseBlockOpts = { + ignoreIn: [ + 'paragraph', + 'list-item', + 'bulleted-list', + 'numbered-list', + 'table', + 'table-row', + 'table-cell', + ], +}; + +export const BackspaceCloseBlockConfigured = BackspaceCloseBlock(BackspaceCloseBlockOpts); + +const EditListOpts = { + types: ['bulleted-list', 'numbered-list'], + typeItem: 'list-item', +}; + +export const EditListConfigured = EditList(EditListOpts); + +const EditTableOpts = { + typeTable: 'table', + typeRow: 'table-row', + typeCell: 'table-cell', +}; + +export const EditTableConfigured = EditTable(EditTableOpts); + +const plugins = [ + SoftBreakConfigured, + BackspaceCloseBlockConfigured, + EditListConfigured, + EditTableConfigured, +]; + +export default plugins; diff --git a/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/rules.js b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/rules.js new file mode 100644 index 00000000..261f9e43 --- /dev/null +++ b/src/components/Widgets/Markdown/MarkdownControl/VisualEditor/rules.js @@ -0,0 +1,30 @@ +import { Block, Text } from 'slate'; + +/** + * Rules are used to validate the editor state each time it changes, to ensure + * it is never rendered in an undesirable state. + */ + +/** + * If the editor is ever in an empty state, insert an empty + * paragraph block. + */ +const enforceNeverEmpty = { + match: object => object.kind === 'document', + validate: doc => { + const hasBlocks = !doc.getBlocks().isEmpty(); + return hasBlocks ? null : {}; + }, + normalize: transform => { + const block = Block.create({ + type: 'paragraph', + nodes: [Text.createFromString('')], + }); + const { key } = transform.state.document; + return transform.insertNodeByKey(key, 0, block).focus(); + }, +}; + +const rules = [ enforceNeverEmpty ]; + +export default rules; diff --git a/src/components/Widgets/Markdown/MarkdownControl/index.js b/src/components/Widgets/Markdown/MarkdownControl/index.js index 6ed3df10..e2063020 100644 --- a/src/components/Widgets/Markdown/MarkdownControl/index.js +++ b/src/components/Widgets/Markdown/MarkdownControl/index.js @@ -8,10 +8,10 @@ import { StickyContainer } from '../../../UI/Sticky/Sticky'; const MODE_STORAGE_KEY = 'cms.md-mode'; /** - * Slate can serialize to html, but we persist the value as markdown. Serializing - * the html to markdown on every keystroke is a big perf hit, so we'll register - * functions to perform those actions only when necessary, such as after loading - * and before persisting. + * The markdown field value is persisted as a markdown string, but stringifying + * on every keystroke is a big perf hit, so we'll register functions to perform + * those actions only when necessary, such as after loading and before + * persisting. */ registry.registerWidgetValueSerializer('markdown', { serialize: remarkToMarkdown, diff --git a/src/components/Widgets/Markdown/unified.js b/src/components/Widgets/Markdown/unified.js index b5f5dfc7..60ea8abd 100644 --- a/src/components/Widgets/Markdown/unified.js +++ b/src/components/Widgets/Markdown/unified.js @@ -392,8 +392,11 @@ const remarkToSlatePlugin = () => { if (node.type === 'linkReference') { const definition = getDefinition(node.identifier); - const { title, url } = definition; - const data = { title, url }; + const data = {}; + if (definition) { + data.title = definition.title; + data.url = definition.url; + } return { kind: 'inline', type: typeMap['link'], data, nodes }; } @@ -405,8 +408,11 @@ const remarkToSlatePlugin = () => { if (node.type === 'imageReference') { const definition = getDefinition(node.identifier); - const { title, url } = definition; - const data = { title, url }; + const data = {}; + if (definition) { + data.title = definition.title; + data.url = definition.url; + } return { kind: 'block', type: typeMap['image'], data }; } }; @@ -536,6 +542,10 @@ export const slateToRemark = raw => { return u('html', { data: node.data }, node.data.shortcodeValue); } + if (node.type === 'shortcode-wrapper') { + return u('paragraph', children); + } + if (node.type.startsWith('heading')) { const depths = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 }; const depth = node.type.split('-')[1]; @@ -597,22 +607,6 @@ export const remarkToHtml = (mdast, getAsset) => { return output } -export const markdownToHtml = markdown => { - // Parse shortcodes from the raw markdown rather than via Unified plugin. - // This ensures against conflicts between shortcode syntax and Unified - // parsing rules. - const markdownWithParsedShortcodes = parseShortcodesFromMarkdown(markdown); - const result = unified() - .use(markdownToRemarkPlugin, { fences: true, pedantic: true, footnotes: true, commonmark: true }) - .use(remarkToRehype, { allowDangerousHTML: true }) - .use(rehypeRemoveEmpty) - .use(rehypeMinifyWhitespace) - .use(rehypeToHtml, { allowDangerousHTML: true }) - .processSync(markdownWithParsedShortcodes) - .contents; - return result; -} - export const htmlToSlate = html => { const hast = unified() .use(htmlToRehype, { fragment: true })