From ee2192aa6ebe9f37cb4a2c59639c9feae524b228 Mon Sep 17 00:00:00 2001 From: Ian Storm Taylor Date: Wed, 27 Jul 2016 12:54:04 -0700 Subject: [PATCH] refactor core plugin for readability --- lib/plugins/core.js | 693 ++++++++++++++++++++++++-------------------- 1 file changed, 384 insertions(+), 309 deletions(-) diff --git a/lib/plugins/core.js b/lib/plugins/core.js index e560fa45e..8661909e5 100644 --- a/lib/plugins/core.js +++ b/lib/plugins/core.js @@ -6,7 +6,6 @@ import React from 'react' import String from '../utils/string' import keycode from 'keycode' import { IS_WINDOWS, IS_MAC } from '../utils/environment' -import OffsetKey from '../utils/offset-key' /** * The default plugin. @@ -26,333 +25,409 @@ function Plugin(options = {}) { } = options /** - * Define a default block renderer. + * The default block renderer. * - * @type {Component} + * @param {Object} props + * @return {Element} */ - class DEFAULT_BLOCK extends React.Component { - render = () => { - const { attributes, children } = this.props - return ( -
- {this.renderPlaceholder()} - {children} -
- ) + function DEFAULT_BLOCK(props) { + return ( +
+ {props.children} + {placeholder + ? + {placeholder} + + : null} +
+ ) + } + + /** + * The default inline renderer. + * + * @param {Object} props + * @return {Element} + */ + + function DEFAULT_INLINE(props) { + return ( + + {props.children} + + ) + } + + /** + * On before input, see if we can let the browser continue with it's native + * input behavior, to avoid a re-render for performance. + * + * @param {Event} e + * @param {State} state + * @param {Editor} editor + * @return {State} + */ + + function onBeforeInput(e, state, editor) { + const { renderDecorations } = editor + const { startOffset, startText, startBlock } = state + + // Determine what the characters would be if natively inserted. + const prev = startText.getDecoratedCharacters(startBlock, renderDecorations) + const char = prev.get(startOffset) + const chars = prev + .slice(0, startOffset) + .push(Character.create({ text: e.data, marks: char && char.marks })) + .concat(prev.slice(startOffset)) + + // Determine what the characters should be, if not natively inserted. + let next = state + .transform() + .insertText(e.data) + .apply() + + const nextText = next.startText + const nextBlock = next.startBlock + const nextChars = nextText.getDecoratedCharacters(nextBlock, renderDecorations) + + // We do not have to re-render if the current selection is collapsed, the + // current node is not empty, there are no marks on the cursor, and the + // natively inserted characters would be the same as the non-native. + const isNative = ( + state.isCollapsed && + state.startText.text != '' && + state.cursorMarks == null && + chars.equals(nextChars) + ) + + // Add the `isNative` flag directly, so we don't have to re-transform. + if (isNative) { + next = next.merge({ isNative }) } - renderPlaceholder = () => { - if (!placeholder) return null - const { node, state } = this.props - return ( - - {placeholder} - - ) + // If not native, prevent default so that the DOM remains untouched. + if (!isNative) e.preventDefault() + + // Return the new state. + return next + } + + /** + * On drop. + * + * @param {Event} e + * @param {Object} data + * @param {State} state + * @return {State} + */ + + function onDrop(e, data, state) { + switch (data.type) { + case 'text': + case 'html': + return onDropText(e, data, state) + case 'fragment': + return onDropFragment(e, data, state) } } /** - * Define a default inline renderer. + * On drop fragment. * - * @type {Component} + * @param {Event} e + * @param {Object} data + * @param {State} state + * @return {State} */ - class DEFAULT_INLINE extends React.Component { - render = () => { - const { attributes, children } = this.props - return {children} + function onDropFragment(e, data, state) { + const { selection } = state + let { fragment, target, isInternal } = data + + // If the drag is internal and the target is after the selection, it + // needs to account for the selection's content being deleted. + if ( + isInternal && + selection.endKey == target.endKey && + selection.endOffset < target.endOffset + ) { + target = target.moveBackward(selection.startKey == selection.endKey + ? selection.endOffset - selection.startOffset + : selection.endOffset) + } + + let transform = state.transform() + + if (isInternal) transform = transform.delete() + + return transform + .moveTo(target) + .insertFragment(fragment) + .apply() + } + + /** + * On drop text, split the blocks at new lines. + * + * @param {Event} e + * @param {Object} data + * @param {State} state + * @return {State} + */ + + function onDropText(e, data, state) { + const { text, target } = data + let transform = state + .transform() + .moveTo(target) + + text + .split('\n') + .forEach((line, i) => { + if (i > 0) transform = transform.splitBlock() + transform = transform.insertText(line) + }) + + return transform.apply() + } + + /** + * On key down. + * + * @param {Event} e + * @param {State} state + * @return {State} + */ + + function onKeyDown(e, state) { + switch (keycode(e.which)) { + case 'enter': return onKeyDownEnter(e, state) + case 'backspace': return onKeyDownBackspace(e, state) + case 'delete': return onKeyDownDelete(e, state) + case 'y': return onKeyDownY(e, state) + case 'z': return onKeyDownZ(e, state) } } /** - * Return the plugin. + * On `enter` key down, split the current block in half. + * + * @param {Event} e + * @param {State} state + * @return {State} + */ + + function onKeyDownEnter(e, state) { + const { document, startKey, startBlock } = state + + // For void blocks, we don't want to split. Instead we just move to the + // start of the next text node if one exists. + if (startBlock && startBlock.isVoid) { + const text = document.getNextText(startKey) + if (!text) return + return state + .transform() + .collapseToStartOf(text) + .apply() + } + + return state + .transform() + .splitBlock() + .apply() + } + + /** + * On `backspace` key down, delete backwards. + * + * @param {Event} e + * @param {State} state + * @return {State} + */ + + function onKeyDownBackspace(e, state) { + // If expanded, delete regularly. + if (state.isExpanded) { + return state + .transform() + .delete() + .apply() + } + + const { startOffset, startBlock } = state + const text = startBlock.text + let n + + // Determine how far backwards to delete. + if (Key.isWord(e)) { + n = String.getWordOffsetBackward(text, startOffset) + } else if (Key.isLine(e)) { + n = startOffset + } else { + n = String.getCharOffsetBackward(text, startOffset) + } + + return state + .transform() + .deleteBackward(n) + .apply() + } + + /** + * On `delete` key down, delete forwards. + * + * @param {Event} e + * @param {State} state + * @return {State} + */ + + function onKeyDownDelete(e, state) { + // If expanded, delete regularly. + if (state.isExpanded) { + return state + .transform() + .delete() + .apply() + } + + const { startOffset, startBlock } = state + const text = startBlock.text + let n + + // Determine how far forwards to delete. + if (Key.isWord(e)) { + n = String.getWordOffsetForward(text, startOffset) + } else if (Key.isLine(e)) { + n = text.length - startOffset + } else { + n = String.getCharOffsetForward(text, startOffset) + } + + return state + .transform() + .deleteForward(n) + .apply() + } + + /** + * On `y` key down, redo. + * + * @param {Event} e + * @param {State} state + * @return {State} + */ + + function onKeyDownY(e, state) { + if (!Key.isWindowsCommand(e)) return + return state + .transform() + .redo() + } + + /** + * On `z` key down, undo or redo. + * + * @param {Event} e + * @param {State} state + * @return {State} + */ + + function onKeyDownZ(e, state) { + if (!Key.isCommand(e)) return + return state + .transform() + [IS_MAC && Key.isShift(e) ? 'redo' : 'undo']() + } + + /** + * On paste. + * + * @param {Event} e + * @param {Object} data + * @param {State} state + * @return {State} + */ + + function onPaste(e, data, state) { + switch (data.type) { + case 'text': + case 'html': + return onPasteText(e, data, state) + } + } + + /** + * On paste text, split blocks at new lines. + * + * @param {Event} e + * @param {Object} data + * @param {State} state + * @return {State} + */ + + function onPasteText(e, data, state) { + let transform = state.transform() + + data.text + .split('\n') + .forEach((line, i) => { + if (i > 0) transform = transform.splitBlock() + transform = transform.insertText(line) + }) + + return transform.apply() + } + + /** + * On select. + * + * @param {Event} e + * @param {Object} data + * @param {State} state + * @return {State} + */ + + function onSelect(e, data, state) { + const { selection, isNative } = data + return state + .transform() + .moveTo(selection) + .focus() + .apply({ isNative }) + } + + /** + * The core `node` renderer, which uses plain `
` or `` depending on + * what kind of node it is. + * + * @param {Node} node + * @return {Component} component + */ + + function renderNode(node) { + return node.kind == 'block' + ? DEFAULT_BLOCK + : DEFAULT_INLINE + } + + /** + * Return the core plugin. */ return { - - /** - * The core `onBeforeInput` handler. - * - * @param {Event} e - * @param {State} state - * @param {Editor} editor - * @return {State or Null} - */ - - onBeforeInput(e, state, editor) { - const { renderDecorations } = editor - const { startOffset, startText, startBlock } = state - - // Determine what the characters would be if natively inserted. - const prev = startText.getDecoratedCharacters(startBlock, renderDecorations) - const char = prev.get(startOffset) - const chars = prev - .slice(0, startOffset) - .push(Character.create({ text: e.data, marks: char && char.marks })) - .concat(prev.slice(startOffset)) - - // Determine what the characters should be, if not natively inserted. - let next = state - .transform() - .insertText(e.data) - .apply() - - const nextText = next.startText - const nextBlock = next.startBlock - const nextChars = nextText.getDecoratedCharacters(nextBlock, renderDecorations) - - // We do not have to re-render if the current selection is collapsed, the - // current node is not empty, there are no marks on the cursor, and the - // natively inserted characters would be the same as the non-native. - const isNative = ( - state.isCollapsed && - state.startText.text != '' && - state.cursorMarks == null && - chars.equals(nextChars) - ) - - // Add the `isNative` flag directly, so we don't have to re-transform. - if (isNative) { - next = next.merge({ isNative }) - } - - // If not native, prevent default so that the DOM remains untouched. - if (!isNative) e.preventDefault() - - // Return the new state. - return next - }, - - /** - * The core `onDrop` handler. - * - * @param {Event} e - * @param {Object} drop - * @param {State} state - * @param {Editor} editor - * @return {State or Null} - */ - - onDrop(e, drop, state, editor) { - switch (drop.type) { - case 'fragment': { - const { selection } = state - let { fragment, target, isInternal } = drop - - // If the drag is internal and the target is after the selection, it - // needs to account for the selection's content being deleted. - if ( - isInternal && - selection.endKey == target.endKey && - selection.endOffset < target.endOffset - ) { - target = target.moveBackward(selection.startKey == selection.endKey - ? selection.endOffset - selection.startOffset - : selection.endOffset) - } - - let transform = state.transform() - - if (isInternal) transform = transform.delete() - - return transform - .moveTo(target) - .insertFragment(fragment) - .apply() - } - - case 'text': - case 'html': { - const { text, target } = drop - let transform = state - .transform() - .moveTo(target) - - text - .split('\n') - .forEach((line, i) => { - if (i > 0) transform = transform.splitBlock() - transform = transform.insertText(line) - }) - - return transform.apply() - } - } - }, - - /** - * The core `onKeyDown` handler. - * - * @param {Event} e - * @param {State} state - * @param {Editor} editor - * @return {State or Null} - */ - - onKeyDown(e, state, editor) { - const key = keycode(e.which) - let transform = state.transform() - - switch (key) { - case 'enter': { - const { startBlock } = state - if (startBlock && !startBlock.isVoid) return transform.splitBlock().apply() - - const { document, startKey } = state - const text = document.getNextText(startKey) - if (!text) return - - return transform.collapseToStartOf(text).apply() - } - - case 'backspace': { - if (state.isExpanded) return transform.delete().apply() - const { startOffset, startBlock } = state - const text = startBlock.text - let n - - if (Key.isWord(e)) { - n = String.getWordOffsetBackward(text, startOffset) - } else if (Key.isLine(e)) { - n = startOffset - } else { - n = String.getCharOffsetBackward(text, startOffset) - } - - return transform.deleteBackward(n).apply() - } - - case 'delete': { - if (state.isExpanded) return transform.delete().apply() - const { startOffset, startBlock } = state - const text = startBlock.text - let n - - if (Key.isWord(e)) { - n = String.getWordOffsetForward(text, startOffset) - } else if (Key.isLine(e)) { - n = text.length - startOffset - } else { - n = String.getCharOffsetForward(text, startOffset) - } - - return transform.deleteForward(n).apply() - } - - case 'up': { - if (state.isExpanded) return - const first = state.blocks.first() - if (!first || !first.isVoid) return - e.preventDefault() - return transform.collapseToEndOfPreviousBlock().apply() - } - - case 'down': { - if (state.isExpanded) return - const first = state.blocks.first() - if (!first || !first.isVoid) return - e.preventDefault() - return transform.collapseToStartOfNextBlock().apply() - } - - case 'left': { - if (state.isExpanded) return - const node = state.blocks.first() || state.inlines.first() - if (!node || !node.isVoid) return - e.preventDefault() - return transform.collapseToEndOfPreviousText().apply() - } - - case 'right': { - if (state.isExpanded) return - const node = state.blocks.first() || state.inlines.first() - if (!node || !node.isVoid) return - e.preventDefault() - return transform.collapseToStartOfNextText().apply() - } - - case 'y': { - if (!Key.isWindowsCommand(e)) return - return transform.redo() - } - - case 'z': { - if (!Key.isCommand(e)) return - return IS_MAC && Key.isShift(e) - ? transform.redo() - : transform.undo() - } - } - }, - - /** - * The core `onPaste` handler, which treats everything as plain text. - * - * @param {Event} e - * @param {Object} paste - * @param {State} state - * @param {Editor} editor - * @return {State or Null} - */ - - onPaste(e, paste, state, editor) { - switch (paste.type) { - case 'text': - case 'html': { - let transform = state.transform() - - paste.text - .split('\n') - .forEach((line, i) => { - if (i > 0) transform = transform.splitBlock() - transform = transform.insertText(line) - }) - - return transform.apply() - } - } - }, - - /** - * The core `onSelect` handler. - * - * @param {Event} e - * @param {Object} select - * @param {State} state - * @param {Editor} editor - * @return {State or Null} - */ - - onSelect(e, select, state, editor) { - const { selection, isNative } = select - return state - .transform() - .moveTo(selection) - .focus() - .apply({ isNative }) - }, - - /** - * The core `node` renderer, which uses plain `
` or `` depending on - * what kind of node it is. - * - * @param {Node} node - * @return {Component} component - */ - - renderNode(node) { - return node.kind == 'block' - ? DEFAULT_BLOCK - : DEFAULT_INLINE - } + onBeforeInput, + onDrop, + onKeyDown, + onPaste, + onSelect, + renderNode } }