From 617fba2ac0149c2eb2dd75d15c6e287ea4bdace3 Mon Sep 17 00:00:00 2001 From: Ian Storm Taylor Date: Mon, 16 Oct 2017 17:31:43 -0700 Subject: [PATCH] Split core plugin (#1242) * split core into before/after, add onBeforeInput to before * migrate handlers to before plugin, add event handlers constants * cleanup * refactor hotkeys into constants file * fix serializer, disable core plugin tests * fix linter --- .eslintrc | 1 - packages/slate-react/package.json | 1 + .../slate-react/src/components/content.js | 700 +++--------------- packages/slate-react/src/components/editor.js | 76 +- .../src/constants/event-handlers.js | 33 + packages/slate-react/src/constants/hotkeys.js | 56 ++ .../src/plugins/{core.js => after.js} | 373 +++++----- packages/slate-react/src/plugins/before.js | 591 +++++++++++++++ packages/slate-react/test/plugins/index.js | 7 +- packages/slate-simulator/src/index.js | 27 +- packages/slate/src/models/stack.js | 64 +- yarn.lock | 4 + 12 files changed, 1044 insertions(+), 889 deletions(-) create mode 100644 packages/slate-react/src/constants/event-handlers.js create mode 100644 packages/slate-react/src/constants/hotkeys.js rename packages/slate-react/src/plugins/{core.js => after.js} (74%) create mode 100644 packages/slate-react/src/plugins/before.js diff --git a/.eslintrc b/.eslintrc index 864afbfef..c908235ad 100644 --- a/.eslintrc +++ b/.eslintrc @@ -31,7 +31,6 @@ "dot-notation": ["error", { "allowKeywords": true }], "eol-last": "error", "func-call-spacing": ["error", "never"], - "func-style": ["error", "declaration"], "import/default": "error", "import/export": "error", "import/first": "error", diff --git a/packages/slate-react/package.json b/packages/slate-react/package.json index e630a696f..29e10ca81 100644 --- a/packages/slate-react/package.json +++ b/packages/slate-react/package.json @@ -9,6 +9,7 @@ "debug": "^2.3.2", "get-window": "^1.1.1", "is-in-browser": "^1.1.3", + "is-hotkey": "^0.0.3", "is-window": "^1.0.2", "keycode": "^2.1.2", "prop-types": "^15.5.8", diff --git a/packages/slate-react/src/components/content.js b/packages/slate-react/src/components/content.js index 6957a4378..ab16a4b37 100644 --- a/packages/slate-react/src/components/content.js +++ b/packages/slate-react/src/components/content.js @@ -1,25 +1,19 @@ -import Base64 from 'slate-base64-serializer' import Debug from 'debug' import React from 'react' import SlateTypes from 'slate-prop-types' import Types from 'prop-types' import getWindow from 'get-window' -import keycode from 'keycode' import logger from 'slate-dev-logger' -import TRANSFER_TYPES from '../constants/transfer-types' +import EVENT_HANDLERS from '../constants/event-handlers' import Node from './node' import findClosestNode from '../utils/find-closest-node' -import findDOMNode from '../utils/find-dom-node' import findDOMRange from '../utils/find-dom-range' -import findPoint from '../utils/find-point' import findRange from '../utils/find-range' import getHtmlFromNativePaste from '../utils/get-html-from-native-paste' -import getTransferData from '../utils/get-transfer-data' import scrollToSelection from '../utils/scroll-to-selection' -import setTransferData from '../utils/set-transfer-data' -import { IS_FIREFOX, IS_MAC, IS_IE, SUPPORTED_EVENTS } from '../constants/environment' +import { IS_FIREFOX, IS_IE, SUPPORTED_EVENTS } from '../constants/environment' /** * Debug. @@ -49,16 +43,6 @@ class Content extends React.Component { children: Types.array.isRequired, className: Types.string, editor: Types.object.isRequired, - onBeforeInput: Types.func.isRequired, - onBlur: Types.func.isRequired, - onCopy: Types.func.isRequired, - onCut: Types.func.isRequired, - onDrop: Types.func.isRequired, - onFocus: Types.func.isRequired, - onKeyDown: Types.func.isRequired, - onKeyUp: Types.func.isRequired, - onPaste: Types.func.isRequired, - onSelect: Types.func.isRequired, readOnly: Types.bool.isRequired, role: Types.string, schema: SlateTypes.schema.isRequired, @@ -89,8 +73,14 @@ class Content extends React.Component { constructor(props) { super(props) this.tmp = {} - this.tmp.compositions = 0 - this.tmp.forces = 0 + this.tmp.key = 0 + this.tmp.isUpdatingSelection = false + + EVENT_HANDLERS.forEach((handler) => { + this[handler] = (event) => { + this.onEvent(handler, event) + } + }) } /** @@ -178,18 +168,18 @@ class Content extends React.Component { return } - // Otherwise, set the `isSelecting` flag and update the selection. - this.tmp.isSelecting = true + // Otherwise, set the `isUpdatingSelection` flag and update the selection. + this.tmp.isUpdatingSelection = true native.removeAllRanges() native.addRange(range) scrollToSelection(native) - // Then unset the `isSelecting` flag after a delay. + // Then unset the `isUpdatingSelection` flag after a delay. setTimeout(() => { // COMPAT: In Firefox, it's not enough to create a range, you also need to // focus the contenteditable element too. (2016/11/16) if (IS_FIREFOX) this.element.focus() - this.tmp.isSelecting = false + this.tmp.isUpdatingSelection = false }) debug('updateSelection', { selection, native }) @@ -226,19 +216,76 @@ class Content extends React.Component { } /** - * On before input, bubble up. + * On `event` with `handler`. * + * @param {String} handler * @param {Event} event */ - onBeforeInput = (event) => { - if (this.props.readOnly) return - if (!this.isInEditor(event.target)) return + onEvent(handler, event) { + // COMPAT: Composition events can change the DOM out of under React, so we + // increment this key to ensure that a full re-render happens. (2017/10/16) + if (handler == 'onCompositionEnd') { + this.tmp.key++ + } - const data = {} + // COMPAT: In IE 11, only plain text can be retrieved from the event's + // `clipboardData`. To get HTML, use the browser's native paste action which + // can only be handled synchronously. (2017/06/23) + if (handler == 'onPaste' && IS_IE) { + getHtmlFromNativePaste(event.target, (html) => { + const data = html ? { html, type: 'html' } : {} + this.props.onPaste(event, data) + }) - debug('onBeforeInput', { event, data }) - this.props.onBeforeInput(event, data) + return + } + + // If the `onSelect` handler fires while the `isUpdatingSelection` flag is + // set it's a result of updating the selection manually, so skip it. + if (handler == 'onSelect' && this.tmp.isUpdatingSelection) { + return + } + + // COMPAT: There are situations where a select event will fire with a new + // native selection that resolves to the same internal position. In those + // cases we don't need to trigger any changes, since our internal model is + // already up to date, but we do want to update the native selection again + // to make sure it is in sync. (2017/10/16) + if (handler == 'onSelect') { + const { state } = this.props + const { selection } = state + const window = getWindow(event.target) + const native = window.getSelection() + const range = findRange(native, state) + + if (range && range.equals(selection)) { + this.updateSelection() + return + } + } + + // Some events require being in editable in the editor, so if the event + // target isn't, ignore them. + if ( + handler == 'onBeforeInput' || + handler == 'onBlur' || + handler == 'onCompositionEnd' || + handler == 'onCompositionStart' || + handler == 'onCopy' || + handler == 'onCut' || + handler == 'onDragStart' || + handler == 'onFocus' || + handler == 'onInput' || + handler == 'onKeyDown' || + handler == 'onKeyUp' || + handler == 'onPaste' || + handler == 'onSelect' + ) { + if (!this.isInEditor(event.target)) return + } + + this.props[handler](event, {}) } /** @@ -279,554 +326,6 @@ class Content extends React.Component { }) } - /** - * On blur, update the selection to be not focused. - * - * @param {Event} event - */ - - onBlur = (event) => { - if (this.props.readOnly) return - if (this.tmp.isCopying) return - if (!this.isInEditor(event.target)) return - - // If the active element is still the editor, the blur event is due to the - // window itself being blurred (eg. when changing tabs) so we should ignore - // the event, since we want to maintain focus when returning. - const window = getWindow(this.element) - if (window.document.activeElement == this.element) return - - const data = {} - - debug('onBlur', { event, data }) - this.props.onBlur(event, data) - } - - /** - * On focus, update the selection to be focused. - * - * @param {Event} event - */ - - onFocus = (event) => { - if (this.props.readOnly) return - if (this.tmp.isCopying) return - if (!this.isInEditor(event.target)) return - - // COMPAT: If the editor has nested editable elements, the focus can go to - // those elements. In Firefox, this must be prevented because it results in - // issues with keyboard navigation. (2017/03/30) - if (IS_FIREFOX && event.target != this.element) { - this.element.focus() - return - } - - const data = {} - - debug('onFocus', { event, data }) - this.props.onFocus(event, data) - } - - /** - * On composition start, set the `isComposing` flag. - * - * @param {Event} event - */ - - onCompositionStart = (event) => { - if (!this.isInEditor(event.target)) return - - this.tmp.isComposing = true - this.tmp.compositions++ - - debug('onCompositionStart', { event }) - } - - /** - * On composition end, remove the `isComposing` flag on the next tick. Also - * increment the `forces` key, which will force the contenteditable element - * to completely re-render, since IME puts React in an unreconcilable state. - * - * @param {Event} event - */ - - onCompositionEnd = (event) => { - if (!this.isInEditor(event.target)) return - - this.tmp.forces++ - const count = this.tmp.compositions - - // The `count` check here ensures that if another composition starts - // before the timeout has closed out this one, we will abort unsetting the - // `isComposing` flag, since a composition in still in affect. - setTimeout(() => { - if (this.tmp.compositions > count) return - this.tmp.isComposing = false - }) - - debug('onCompositionEnd', { event }) - } - - /** - * On copy, defer to `onCutCopy`, then bubble up. - * - * @param {Event} event - */ - - onCopy = (event) => { - if (!this.isInEditor(event.target)) return - const window = getWindow(event.target) - - this.tmp.isCopying = true - window.requestAnimationFrame(() => { - this.tmp.isCopying = false - }) - - const { state } = this.props - const data = {} - data.type = 'fragment' - data.fragment = state.fragment - - debug('onCopy', { event, data }) - this.props.onCopy(event, data) - } - - /** - * On cut, defer to `onCutCopy`, then bubble up. - * - * @param {Event} event - */ - - onCut = (event) => { - if (this.props.readOnly) return - if (!this.isInEditor(event.target)) return - const window = getWindow(event.target) - - this.tmp.isCopying = true - window.requestAnimationFrame(() => { - this.tmp.isCopying = false - }) - - const { state } = this.props - const data = {} - data.type = 'fragment' - data.fragment = state.fragment - - debug('onCut', { event, data }) - this.props.onCut(event, data) - } - - /** - * On drag end, unset the `isDragging` flag. - * - * @param {Event} event - */ - - onDragEnd = (event) => { - event.stopPropagation() - - this.tmp.isDragging = false - this.tmp.isInternalDrag = null - - debug('onDragEnd', { event }) - } - - /** - * On drag over, set the `isDragging` flag and the `isInternalDrag` flag. - * - * @param {Event} event - */ - - onDragOver = (event) => { - event.stopPropagation() - - if (this.tmp.isDragging) return - this.tmp.isDragging = true - this.tmp.isInternalDrag = false - - debug('onDragOver', { event }) - } - - /** - * On drag start, set the `isDragging` flag and the `isInternalDrag` flag. - * - * @param {Event} event - */ - - onDragStart = (event) => { - if (!this.isInEditor(event.target)) return - - this.tmp.isDragging = true - this.tmp.isInternalDrag = true - const { dataTransfer } = event.nativeEvent - const data = getTransferData(dataTransfer) - - // If it's a node being dragged, the data type is already set. - if (data.type == 'node') return - - const { state } = this.props - const { fragment } = state - const encoded = Base64.serializeNode(fragment) - - setTransferData(dataTransfer, TRANSFER_TYPES.FRAGMENT, encoded) - - debug('onDragStart', { event }) - } - - /** - * On drop. - * - * @param {Event} event - */ - - onDrop = (event) => { - event.stopPropagation() - event.preventDefault() - - if (this.props.readOnly) return - - const { state } = this.props - const { nativeEvent } = event - const { dataTransfer, x, y } = nativeEvent - const data = getTransferData(dataTransfer) - - // Resolve a range from the caret position where the drop occured. - const window = getWindow(event.target) - let range - - // COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25) - if (window.document.caretRangeFromPoint) { - range = window.document.caretRangeFromPoint(x, y) - } else { - const position = window.document.caretPositionFromPoint(x, y) - range = window.document.createRange() - range.setStart(position.offsetNode, position.offset) - range.setEnd(position.offsetNode, position.offset) - } - - // Resolve a Slate range from the DOM range. - let selection = findRange(range, state) - if (!selection) return - - const { document } = state - const node = document.getNode(selection.anchorKey) - const parent = document.getParent(node.key) - const el = findDOMNode(parent) - - // If the drop target is inside a void node, move it into either the next or - // previous node, depending on which side the `x` and `y` coordinates are - // closest to. - if (parent.isVoid) { - const rect = el.getBoundingClientRect() - const isPrevious = parent.kind == 'inline' - ? x - rect.left < rect.left + rect.width - x - : y - rect.top < rect.top + rect.height - y - - selection = isPrevious - ? selection.moveToEndOf(document.getPreviousText(node.key)) - : selection.moveToStartOf(document.getNextText(node.key)) - } - - // Add drop-specific information to the data. - data.target = selection - - // COMPAT: Edge throws "Permission denied" errors when - // accessing `dropEffect` or `effectAllowed` (2017/7/12) - try { - data.effect = dataTransfer.dropEffect - } catch (err) { - data.effect = null - } - - if (data.type == 'fragment' || data.type == 'node') { - data.isInternal = this.tmp.isInternalDrag - } - - debug('onDrop', { event, data }) - this.props.onDrop(event, data) - } - - /** - * On input, handle spellcheck and other similar edits that don't go trigger - * the `onBeforeInput` and instead update the DOM directly. - * - * @param {Event} event - */ - - onInput = (event) => { - if (this.tmp.isComposing) return - if (this.props.state.isBlurred) return - if (!this.isInEditor(event.target)) return - debug('onInput', { event }) - - const window = getWindow(event.target) - const { state, editor } = this.props - - // Get the selection point. - const native = window.getSelection() - const { anchorNode, anchorOffset } = native - const point = findPoint(anchorNode, anchorOffset, state) - if (!point) return - - // Get the text node and leaf in question. - const { document, selection } = state - const node = document.getDescendant(point.key) - const leaves = node.getLeaves() - let start = 0 - let end = 0 - - const leaf = leaves.find((r) => { - end += r.text.length - if (end >= point.offset) return true - start = end - }) - - // Get the text information. - const { text } = leaf - let { textContent } = anchorNode - const block = document.getClosestBlock(node.key) - const lastText = block.getLastText() - const lastLeaf = leaves.last() - const lastChar = textContent.charAt(textContent.length - 1) - const isLastText = node == lastText - const isLastLeaf = leaf == lastLeaf - - // COMPAT: If this is the last leaf, and the DOM text ends in a new line, - // we will have added another new line in 's render method to account - // for browsers collapsing a single trailing new lines, so remove it. - if (isLastText && isLastLeaf && lastChar == '\n') { - textContent = textContent.slice(0, -1) - } - - // If the text is no different, abort. - if (textContent == text) return - - // Determine what the selection should be after changing the text. - const delta = textContent.length - text.length - const corrected = selection.collapseToEnd().move(delta) - const entire = selection.moveAnchorTo(point.key, start).moveFocusTo(point.key, end) - - // Change the current state to have the leaf's text replaced. - editor.change((change) => { - change - .select(entire) - .delete() - .insertText(textContent, leaf.marks) - .select(corrected) - }) - } - - /** - * On key down, prevent the default behavior of certain commands that will - * leave the editor in an out-of-sync state, then bubble up. - * - * @param {Event} event - */ - - onKeyDown = (event) => { - if (this.props.readOnly) return - if (!this.isInEditor(event.target)) return - - const { key, metaKey, ctrlKey } = event - const data = {} - const modKey = IS_MAC ? metaKey : ctrlKey - - // COMPAT: add the deprecated keyboard event properties. - addDeprecatedKeyProperties(data, event) - - // Keep track of an `isShifting` flag, because it's often used to trigger - // "Paste and Match Style" commands, but isn't available on the event in a - // normal paste event. - if (key == 'Shift') { - this.tmp.isShifting = true - } - - // When composing, these characters commit the composition but also move the - // selection before we're able to handle it, so prevent their default, - // selection-moving behavior. - if ( - this.tmp.isComposing && - (key == 'ArrowLeft' || key == 'ArrowRight' || key == 'ArrowUp' || key == 'ArrowDown') - ) { - event.preventDefault() - return - } - - // These key commands have native behavior in contenteditable elements which - // will cause our state to be out of sync, so prevent them. - if ( - (key == 'Enter') || - (key == 'Backspace') || - (key == 'Delete') || - (key == 'b' && modKey) || - (key == 'i' && modKey) || - (key == 'y' && modKey) || - (key == 'z' && modKey) || - (key == 'Z' && modKey) - ) { - event.preventDefault() - } - - debug('onKeyDown', { event, data }) - this.props.onKeyDown(event, data) - } - - /** - * On key up, unset the `isShifting` flag. - * - * @param {Event} event - */ - - onKeyUp = (event) => { - const data = {} - - // COMPAT: add the deprecated keyboard event properties. - addDeprecatedKeyProperties(data, event) - - if (event.key == 'Shift') { - this.tmp.isShifting = false - } - - debug('onKeyUp', { event, data }) - this.props.onKeyUp(event, data) - } - - /** - * On paste, determine the type and bubble up. - * - * @param {Event} event - */ - - onPaste = (event) => { - if (this.props.readOnly) return - if (!this.isInEditor(event.target)) return - - const data = getTransferData(event.clipboardData) - - // COMPAT: Attach the `isShift` flag, so that people can use it to trigger - // "Paste and Match Style" logic. - Object.defineProperty(data, 'isShift', { - enumerable: true, - get() { - logger.deprecate('0.28.0', 'The `data.isShift` property of paste events has been deprecated. If you need this functionality, you\'ll need to keep track of that state with `onKeyDown` and `onKeyUp` events instead') - return !!this.tmp.isShifting - } - }) - - debug('onPaste', { event, data }) - - // COMPAT: In IE 11, only plain text can be retrieved from the event's - // `clipboardData`. To get HTML, use the browser's native paste action which - // can only be handled synchronously. (2017/06/23) - if (IS_IE) { - // Do not use `event.preventDefault()` as we need the native paste action. - getHtmlFromNativePaste(event.target, (html) => { - // If pasted HTML can be retreived, it is added to the `data` object, - // setting the `type` to `html`. - this.props.onPaste(event, html === undefined ? data : { ...data, html, type: 'html' }) - }) - } else { - event.preventDefault() - this.props.onPaste(event, data) - } - } - - /** - * On select, update the current state's selection. - * - * @param {Event} event - */ - - onSelect = (event) => { - if (this.props.readOnly) return - if (this.tmp.isCopying) return - if (this.tmp.isComposing) return - if (this.tmp.isSelecting) return - if (!this.isInEditor(event.target)) return - - const window = getWindow(event.target) - const { state } = this.props - const { document, selection } = state - const native = window.getSelection() - const data = {} - - // If there are no ranges, the editor was blurred natively. - if (!native.rangeCount) { - data.selection = selection.set('isFocused', false) - } - - // Otherwise, determine the Slate selection from the native one. - else { - let range = findRange(native, state) - if (!range) return - - // There are situations where a select event will fire with a new native - // selection that resolves to the same internal position. In those cases - // we don't need to trigger any changes, since our internal model is - // already up to date, but we do want to update the native selection again - // to make sure it is in sync. - if (range.equals(selection)) { - this.updateSelection() - return - } - - const { anchorKey, anchorOffset, focusKey, focusOffset } = range - const anchorText = document.getNode(anchorKey) - const focusText = document.getNode(focusKey) - const anchorInline = document.getClosestInline(anchorKey) - const focusInline = document.getClosestInline(focusKey) - const focusBlock = document.getClosestBlock(focusKey) - const anchorBlock = document.getClosestBlock(anchorKey) - - // COMPAT: If the anchor point is at the start of a non-void, and the - // focus point is inside a void node with an offset that isn't `0`, set - // the focus offset to `0`. This is due to void nodes 's being - // positioned off screen, resulting in the offset always being greater - // than `0`. Since we can't know what it really should be, and since an - // offset of `0` is less destructive because it creates a hanging - // selection, go with `0`. (2017/09/07) - if ( - anchorBlock && - !anchorBlock.isVoid && - anchorOffset == 0 && - focusBlock && - focusBlock.isVoid && - focusOffset != 0 - ) { - range = range.set('focusOffset', 0) - } - - // COMPAT: If the selection is at the end of a non-void inline node, and - // there is a node after it, put it in the node after instead. This - // standardizes the behavior, since it's indistinguishable to the user. - if ( - anchorInline && - !anchorInline.isVoid && - anchorOffset == anchorText.text.length - ) { - const block = document.getClosestBlock(anchorKey) - const next = block.getNextText(anchorKey) - if (next) range = range.moveAnchorTo(next.key, 0) - } - - if ( - focusInline && - !focusInline.isVoid && - focusOffset == focusText.text.length - ) { - const block = document.getClosestBlock(focusKey) - const next = block.getNextText(focusKey) - if (next) range = range.moveFocusTo(next.key, 0) - } - - range = range.normalize(document) - data.selection = range - } - - debug('onSelect', { event, data }) - this.props.onSelect(event, data) - } - /** * Render the editor content. * @@ -844,6 +343,11 @@ class Content extends React.Component { return this.renderNode(child, isSelected) }) + const handlers = EVENT_HANDLERS.reduce((obj, handler) => { + obj[handler] = this[handler] + return obj + }, {}) + const style = { // Prevent the default outline styles. outline: 'none', @@ -868,14 +372,14 @@ class Content extends React.Component { return ( { + Content.propTypes[handler] = Types.func.isRequired +}) /** * Export. diff --git a/packages/slate-react/src/components/editor.js b/packages/slate-react/src/components/editor.js index 1ae9ad959..1aec2c38b 100644 --- a/packages/slate-react/src/components/editor.js +++ b/packages/slate-react/src/components/editor.js @@ -7,7 +7,9 @@ import Types from 'prop-types' import logger from 'slate-dev-logger' import { Stack, State } from 'slate' -import CorePlugin from '../plugins/core' +import EVENT_HANDLERS from '../constants/event-handlers' +import AfterPlugin from '../plugins/after' +import BeforePlugin from '../plugins/before' import noop from '../utils/noop' /** @@ -18,25 +20,6 @@ import noop from '../utils/noop' const debug = Debug('slate:editor') -/** - * Event handlers to mix in to the editor. - * - * @type {Array} - */ - -const EVENT_HANDLERS = [ - 'onBeforeInput', - 'onBlur', - 'onFocus', - 'onCopy', - 'onCut', - 'onDrop', - 'onKeyDown', - 'onKeyUp', - 'onPaste', - 'onSelect', -] - /** * Plugin-related properties of the editor. * @@ -121,22 +104,18 @@ class Editor extends React.Component { // Run `onBeforeChange` on the passed-in state because we need to ensure // that it is normalized, and queue the resulting change. const change = props.state.change() - stack.onBeforeChange(change, this) + stack.handle('onBeforeChange', change, this) const { state } = change this.queueChange(change) this.cacheState(state) this.state.state = state // Create a bound event handler for each event. - for (let i = 0; i < EVENT_HANDLERS.length; i++) { - const method = EVENT_HANDLERS[i] - this[method] = (...args) => { - const stk = this.state.stack - const c = this.state.state.change() - stk[method](c, this, ...args) - this.onChange(c) + EVENT_HANDLERS.forEach((handler) => { + this[handler] = (...args) => { + this.onEvent(handler, ...args) } - } + }) if (props.onDocumentChange) { logger.deprecate('0.22.10', 'The `onDocumentChange` prop is deprecated because it led to confusing UX issues, see https://github.com/ianstormtaylor/slate/issues/614#issuecomment-327868679') @@ -169,7 +148,7 @@ class Editor extends React.Component { // Run `onBeforeChange` on the passed-in state because we need to ensure // that it is normalized, and queue the resulting change. const change = props.state.change() - stack.onBeforeChange(change, this) + stack.handle('onBeforeChange', change, this) const { state } = change this.queueChange(change) this.cacheState(state) @@ -239,7 +218,7 @@ class Editor extends React.Component { */ blur = () => { - this.change(t => t.blur()) + this.change(c => c.blur()) } /** @@ -247,7 +226,7 @@ class Editor extends React.Component { */ focus = () => { - this.change(t => t.focus()) + this.change(c => c.focus()) } /** @@ -277,12 +256,27 @@ class Editor extends React.Component { */ change = (fn) => { - const change = this.state.state.change() + const { state } = this.state + const change = state.change() fn(change) debug('change', { change }) this.onChange(change) } + /** + * On event. + * + * @param {String} handler + * @param {Mixed} ...args + */ + + onEvent = (handler, ...args) => { + const { stack, state } = this.state + const change = state.change() + stack.handle(handler, change, this, ...args) + this.onChange(change) + } + /** * On change. * @@ -296,8 +290,8 @@ class Editor extends React.Component { const { stack } = this.state - stack.onBeforeChange(change, this) - stack.onChange(change, this) + stack.handle('onBeforeChange', change, this) + stack.handle('onChange', change, this) const { state } = change const { document, selection } = this.tmp @@ -326,7 +320,11 @@ class Editor extends React.Component { debug('render', { props, state }) - const tree = stack.render(state.state, this, { ...props, children }) + const tree = stack.render(state.state, this, { + ...props, + children, + }) + return tree } @@ -352,11 +350,13 @@ class Editor extends React.Component { function resolvePlugins(props) { // eslint-disable-next-line no-unused-vars const { state, onChange, plugins = [], ...overridePlugin } = props - const corePlugin = CorePlugin(props) + const beforePlugin = BeforePlugin(props) + const afterPlugin = AfterPlugin(props) return [ + beforePlugin, overridePlugin, ...plugins, - corePlugin + afterPlugin ] } diff --git a/packages/slate-react/src/constants/event-handlers.js b/packages/slate-react/src/constants/event-handlers.js new file mode 100644 index 000000000..b6473ece0 --- /dev/null +++ b/packages/slate-react/src/constants/event-handlers.js @@ -0,0 +1,33 @@ + +/** + * Event handlers used by Slate plugins. + * + * @type {Array} + */ + +const EVENT_HANDLERS = [ + 'onBeforeInput', + 'onBlur', + 'onCompositionEnd', + 'onCompositionStart', + 'onCopy', + 'onCut', + 'onDragEnd', + 'onDragOver', + 'onDragStart', + 'onDrop', + 'onInput', + 'onFocus', + 'onKeyDown', + 'onKeyUp', + 'onPaste', + 'onSelect', +] + +/** + * Export. + * + * @type {Array} + */ + +export default EVENT_HANDLERS diff --git a/packages/slate-react/src/constants/hotkeys.js b/packages/slate-react/src/constants/hotkeys.js new file mode 100644 index 000000000..026f9cd77 --- /dev/null +++ b/packages/slate-react/src/constants/hotkeys.js @@ -0,0 +1,56 @@ + +import isHotkey from 'is-hotkey' + +import { IS_MAC } from './environment' + +/** + * Hotkeys. + * + * @type {Function} + */ + +const BOLD = isHotkey('mod+b') +const ITALIC = isHotkey('mod+i') + +const UNDO = isHotkey('mod+z') +const REDO_MAC = isHotkey('mod+shift+z') +const REDO_OTHER = isHotkey('mod+y') +const REDO = e => IS_MAC ? REDO_MAC(e) : REDO_OTHER(e) + +const DELETE_CHAR_BACKWARD_MAC = isHotkey('ctrl+h') +const DELETE_CHAR_FORWARD_MAC = isHotkey('ctrl+d') +const DELETE_LINE_FORWARD_MAC = isHotkey('ctrl+k') +const DELETE_CHAR_BACKWARD = e => IS_MAC ? DELETE_CHAR_BACKWARD_MAC(e) : false +const DELETE_CHAR_FORWARD = e => IS_MAC ? DELETE_CHAR_FORWARD_MAC(e) : false +const DELETE_LINE_FORWARD = e => IS_MAC ? DELETE_LINE_FORWARD_MAC(e) : false + +const CONTENTEDITABLE = e => ( + e.key == 'Backspace' || + e.key == 'Delete' || + e.key == 'Enter' || + e.key == 'Insert' || + BOLD(e) || + DELETE_CHAR_BACKWARD(e) || + DELETE_CHAR_FORWARD(e) || + DELETE_LINE_FORWARD(e) || + ITALIC(e) || + REDO(e) || + UNDO(e) +) + +/** + * Export. + * + * @type {Object} + */ + +export default { + BOLD, + CONTENTEDITABLE, + DELETE_CHAR_BACKWARD, + DELETE_CHAR_FORWARD, + DELETE_LINE_FORWARD, + ITALIC, + REDO, + UNDO, +} diff --git a/packages/slate-react/src/plugins/core.js b/packages/slate-react/src/plugins/after.js similarity index 74% rename from packages/slate-react/src/plugins/core.js rename to packages/slate-react/src/plugins/after.js index 1f8d5fb18..aabee402c 100644 --- a/packages/slate-react/src/plugins/core.js +++ b/packages/slate-react/src/plugins/after.js @@ -6,10 +6,13 @@ import React from 'react' import getWindow from 'get-window' import { Block, Inline, coreSchema } from 'slate' +import EVENT_HANDLERS from '../constants/event-handlers' +import HOTKEYS from '../constants/hotkeys' import Content from '../components/content' import Placeholder from '../components/placeholder' import findDOMNode from '../utils/find-dom-node' -import { IS_CHROME, IS_MAC, IS_SAFARI, SUPPORTED_EVENTS } from '../constants/environment' +import findPoint from '../utils/find-point' +import { IS_CHROME, IS_MAC, IS_SAFARI } from '../constants/environment' /** * Debug. @@ -17,10 +20,10 @@ import { IS_CHROME, IS_MAC, IS_SAFARI, SUPPORTED_EVENTS } from '../constants/env * @type {Function} */ -const debug = Debug('slate:core') +const debug = Debug('slate:core:after') /** - * The default plugin. + * The after plugin. * * @param {Object} options * @property {Element} placeholder @@ -29,7 +32,7 @@ const debug = Debug('slate:core') * @return {Object} */ -function Plugin(options = {}) { +function AfterPlugin(options = {}) { const { placeholder, placeholderClassName, @@ -40,7 +43,7 @@ function Plugin(options = {}) { * On before change, enforce the editor's schema. * * @param {Change} change - * @param {Editor} schema + * @param {Editor} editor */ function onBeforeChange(change, editor) { @@ -60,35 +63,26 @@ function Plugin(options = {}) { /** * On before input, correct any browser inconsistencies. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onBeforeInput(e, data, change) { + function onBeforeInput(event, data, change) { debug('onBeforeInput', { data }) - - // React's `onBeforeInput` synthetic event is based on the native `keypress` - // and `textInput` events. In browsers that support the native `beforeinput` - // event, we instead use that event to trigger text insertion, since it - // provides more useful information about the range being affected and also - // preserves compatibility with iOS autocorrect, which would be broken if we - // called `preventDefault()` on React's synthetic event here. - if (SUPPORTED_EVENTS.beforeinput) return - - e.preventDefault() - change.insertText(e.data) + event.preventDefault() + change.insertText(event.data) } /** * On blur. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onBlur(e, data, change) { + function onBlur(event, data, change) { debug('onBlur', { data }) change.blur() } @@ -96,48 +90,47 @@ function Plugin(options = {}) { /** * On copy. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onCopy(e, data, change) { + function onCopy(event, data, change) { debug('onCopy', data) - onCutOrCopy(e, data, change) + onCutOrCopy(event, data, change) } /** * On cut. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change * @param {Editor} editor */ - function onCut(e, data, change, editor) { + function onCut(event, data, change, editor) { debug('onCut', data) - onCutOrCopy(e, data, change) - const window = getWindow(e.target) + onCutOrCopy(event, data, change) + const window = getWindow(event.target) // Once the fake cut content has successfully been added to the clipboard, // delete the content in the current selection. window.requestAnimationFrame(() => { - editor.change(t => t.delete()) + editor.change(c => c.delete()) }) } /** - * On cut or copy, create a fake selection so that we can add a Base 64 - * encoded copy of the fragment to the HTML, to decode on future pastes. + * On cut or copy. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onCutOrCopy(e, data, change) { - const window = getWindow(e.target) + function onCutOrCopy(event, data, change) { + const window = getWindow(event.target) const native = window.getSelection() const { state } = change const { startKey, endKey, startText, endBlock, endInline } = state @@ -148,6 +141,8 @@ function Plugin(options = {}) { // If the selection is collapsed, and it isn't inside a void node, abort. if (native.isCollapsed && !isVoid) return + // Create a fake selection so that we can add a Base64-encoded copy of the + // fragment to the HTML, to decode on future pastes. const { fragment } = data const encoded = Base64.serializeNode(fragment) const range = native.getRangeAt(0) @@ -241,34 +236,34 @@ function Plugin(options = {}) { /** * On drop. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onDrop(e, data, change) { + function onDrop(event, data, change) { debug('onDrop', { data }) switch (data.type) { case 'text': case 'html': - return onDropText(e, data, change) + return onDropText(event, data, change) case 'fragment': - return onDropFragment(e, data, change) + return onDropFragment(event, data, change) case 'node': - return onDropNode(e, data, change) + return onDropNode(event, data, change) } } /** - * On drop node, insert the node wherever it is dropped. + * On drop node. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onDropNode(e, data, change) { + function onDropNode(event, data, change) { debug('onDropNode', { data }) const { state } = change @@ -311,12 +306,12 @@ function Plugin(options = {}) { /** * On drop fragment. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onDropFragment(e, data, change) { + function onDropFragment(event, data, change) { debug('onDropFragment', { data }) const { state } = change @@ -346,14 +341,14 @@ function Plugin(options = {}) { } /** - * On drop text, split the blocks at new lines. + * On drop text. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onDropText(e, data, change) { + function onDropText(event, data, change) { debug('onDropText', { data }) const { state } = change @@ -386,43 +381,122 @@ function Plugin(options = {}) { }) } + /** + * On input. + * + * @param {Event} eventvent + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onInput(event, data, change, editor) { + const window = getWindow(event.target) + const { state } = change + + // Get the selection point. + const native = window.getSelection() + const { anchorNode, anchorOffset } = native + const point = findPoint(anchorNode, anchorOffset, state) + if (!point) return + + // Get the text node and leaf in question. + const { document, selection } = state + const node = document.getDescendant(point.key) + const leaves = node.getLeaves() + let start = 0 + let end = 0 + + const leaf = leaves.find((r) => { + end += r.text.length + if (end >= point.offset) return true + start = end + }) + + // Get the text information. + const { text } = leaf + let { textContent } = anchorNode + const block = document.getClosestBlock(node.key) + const lastText = block.getLastText() + const lastLeaf = leaves.last() + const lastChar = textContent.charAt(textContent.length - 1) + const isLastText = node == lastText + const isLastLeaf = leaf == lastLeaf + + // COMPAT: If this is the last leaf, and the DOM text ends in a new line, + // we will have added another new line in 's render method to account + // for browsers collapsing a single trailing new lines, so remove it. + if (isLastText && isLastLeaf && lastChar == '\n') { + textContent = textContent.slice(0, -1) + } + + // If the text is no different, abort. + if (textContent == text) return + + // Determine what the selection should be after changing the text. + const delta = textContent.length - text.length + const corrected = selection.collapseToEnd().move(delta) + const entire = selection.moveAnchorTo(point.key, start).moveFocusTo(point.key, end) + + // Change the current state to have the leaf's text replaced. + change + .select(entire) + .delete() + .insertText(textContent, leaf.marks) + .select(corrected) + } + /** * On key down. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onKeyDown(e, data, change) { + function onKeyDown(event, data, change) { debug('onKeyDown', { data }) - switch (e.key) { - case 'Enter': return onKeyDownEnter(e, data, change) - case 'Backspace': return onKeyDownBackspace(e, data, change) - case 'Delete': return onKeyDownDelete(e, data, change) - case 'ArrowLeft': return onKeyDownLeft(e, data, change) - case 'ArrowRight': return onKeyDownRight(e, data, change) - case 'ArrowUp': return onKeyDownUp(e, data, change) - case 'ArrowDown': return onKeyDownDown(e, data, change) - case 'd': return onKeyDownD(e, data, change) - case 'h': return onKeyDownH(e, data, change) - case 'k': return onKeyDownK(e, data, change) - case 'y': return onKeyDownY(e, data, change) - case 'z': - case 'Z': return onKeyDownZ(e, data, change) + switch (event.key) { + case 'Enter': return onKeyDownEnter(event, data, change) + case 'Backspace': return onKeyDownBackspace(event, data, change) + case 'Delete': return onKeyDownDelete(event, data, change) + case 'ArrowLeft': return onKeyDownLeft(event, data, change) + case 'ArrowRight': return onKeyDownRight(event, data, change) + case 'ArrowUp': return onKeyDownUp(event, data, change) + case 'ArrowDown': return onKeyDownDown(event, data, change) + } + + if (HOTKEYS.DELETE_CHAR_BACKWARD(event)) { + change.deleteCharBackward() + } + + if (HOTKEYS.DELETE_CHAR_FORWARD(event)) { + change.deleteCharForward() + } + + if (HOTKEYS.DELETE_LINE_FORWARD(event)) { + change.deleteLineForward() + } + + if (HOTKEYS.REDO(event)) { + change.redo() + } + + if (HOTKEYS.UNDO(event)) { + change.undo() } } /** * On `enter` key down, split the current block in half. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onKeyDownEnter(e, data, change) { + function onKeyDownEnter(event, data, change) { const { state } = change const { document, startKey } = state const hasVoidParent = document.hasVoidParent(startKey) @@ -442,14 +516,14 @@ function Plugin(options = {}) { /** * On `backspace` key down, delete backwards. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onKeyDownBackspace(e, data, change) { - const isWord = IS_MAC ? e.altKey : e.ctrlKey - const isLine = IS_MAC ? e.metaKey : false + function onKeyDownBackspace(event, data, change) { + const isWord = IS_MAC ? event.altKey : event.ctrlKey + const isLine = IS_MAC ? event.metaKey : false let boundary = 'Char' if (isWord) boundary = 'Word' @@ -461,14 +535,14 @@ function Plugin(options = {}) { /** * On `delete` key down, delete forwards. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onKeyDownDelete(e, data, change) { - const isWord = IS_MAC ? e.altKey : e.ctrlKey - const isLine = IS_MAC ? e.metaKey : false + function onKeyDownDelete(event, data, change) { + const isWord = IS_MAC ? event.altKey : event.ctrlKey + const isLine = IS_MAC ? event.metaKey : false let boundary = 'Char' if (isWord) boundary = 'Word' @@ -487,16 +561,16 @@ function Plugin(options = {}) { * surrounded by empty text nodes with zero-width spaces in them. Without this * the zero-width spaces will cause two arrow keys to jump to the next text. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onKeyDownLeft(e, data, change) { + function onKeyDownLeft(event, data, change) { const { state } = change - if (e.ctrlKey) return - if (e.altKey) return + if (event.ctrlKey) return + if (event.altKey) return if (state.isExpanded) return const { document, startKey, startText } = state @@ -505,7 +579,7 @@ function Plugin(options = {}) { // If the current text node is empty, or we're inside a void parent, we're // going to need to handle the selection behavior. if (startText.text == '' || hasVoidParent) { - e.preventDefault() + event.preventDefault() const previous = document.getPreviousText(startKey) // If there's no previous text node in the document, abort. @@ -518,7 +592,7 @@ function Plugin(options = {}) { const previousInline = document.getClosestInline(previous.key) if (previousBlock === startBlock && previousInline && !previousInline.isVoid) { - const extendOrMove = e.shiftKey ? 'extend' : 'move' + const extendOrMove = event.shiftKey ? 'extend' : 'move' change.collapseToEndOf(previous)[extendOrMove](-1) return } @@ -543,16 +617,16 @@ function Plugin(options = {}) { * of a previous inline node, which screws us up, so we never want to set the * selection to the very start of an inline node here. (2016/11/29) * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onKeyDownRight(e, data, change) { + function onKeyDownRight(event, data, change) { const { state } = change - if (e.ctrlKey) return - if (e.altKey) return + if (event.ctrlKey) return + if (event.altKey) return if (state.isExpanded) return const { document, startKey, startText } = state @@ -561,7 +635,7 @@ function Plugin(options = {}) { // If the current text node is empty, or we're inside a void parent, we're // going to need to handle the selection behavior. if (startText.text == '' || hasVoidParent) { - e.preventDefault() + event.preventDefault() const next = document.getNextText(startKey) // If there's no next text node in the document, abort. @@ -580,7 +654,7 @@ function Plugin(options = {}) { const nextInline = document.getClosestInline(next.key) if (nextBlock == startBlock && nextInline) { - const extendOrMove = e.shiftKey ? 'extend' : 'move' + const extendOrMove = event.shiftKey ? 'extend' : 'move' change.collapseToStartOf(next)[extendOrMove](1) return } @@ -597,17 +671,17 @@ function Plugin(options = {}) { * Chrome, option-shift-up doesn't properly extend the selection. And in * Firefox, option-up doesn't properly move the selection. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onKeyDownUp(e, data, change) { - if (!IS_MAC || e.ctrlKey || !e.altKey) return + function onKeyDownUp(event, data, change) { + if (!IS_MAC || event.ctrlKey || !event.altKey) return const { state } = change const { selection, document, focusKey, focusBlock } = state - const transform = e.shiftKey ? 'extendToStartOf' : 'collapseToStartOf' + const transform = event.shiftKey ? 'extendToStartOf' : 'collapseToStartOf' const block = selection.hasFocusAtStartOf(focusBlock) ? document.getPreviousBlock(focusKey) : focusBlock @@ -615,7 +689,7 @@ function Plugin(options = {}) { if (!block) return const text = block.getFirstText() - e.preventDefault() + event.preventDefault() change[transform](text) } @@ -626,17 +700,17 @@ function Plugin(options = {}) { * Chrome, option-shift-down doesn't properly extend the selection. And in * Firefox, option-down doesn't properly move the selection. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onKeyDownDown(e, data, change) { - if (!IS_MAC || e.ctrlKey || !e.altKey) return + function onKeyDownDown(event, data, change) { + if (!IS_MAC || event.ctrlKey || !event.altKey) return const { state } = change const { selection, document, focusKey, focusBlock } = state - const transform = e.shiftKey ? 'extendToEndOf' : 'collapseToEndOf' + const transform = event.shiftKey ? 'extendToEndOf' : 'collapseToEndOf' const block = selection.hasFocusAtEndOf(focusBlock) ? document.getNextBlock(focusKey) : focusBlock @@ -644,109 +718,39 @@ function Plugin(options = {}) { if (!block) return const text = block.getLastText() - e.preventDefault() + event.preventDefault() change[transform](text) } - /** - * On `d` key down, for Macs, delete one character forward. - * - * @param {Event} e - * @param {Object} data - * @param {Change} change - */ - - function onKeyDownD(e, data, change) { - if (!IS_MAC || !e.ctrlKey || e.altKey) return - e.preventDefault() - change.deleteCharForward() - } - - /** - * On `h` key down, for Macs, delete until the end of the line. - * - * @param {Event} e - * @param {Object} data - * @param {Change} change - */ - - function onKeyDownH(e, data, change) { - if (!IS_MAC || !e.ctrlKey || e.altKey) return - e.preventDefault() - change.deleteCharBackward() - } - - /** - * On `k` key down, for Macs, delete until the end of the line. - * - * @param {Event} e - * @param {Object} data - * @param {Change} change - */ - - function onKeyDownK(e, data, change) { - if (!IS_MAC || !e.ctrlKey || e.altKey) return - e.preventDefault() - change.deleteLineForward() - } - - /** - * On `y` key down, redo. - * - * @param {Event} e - * @param {Object} data - * @param {Change} change - */ - - function onKeyDownY(e, data, change) { - const modKey = IS_MAC ? e.metaKey : e.ctrlKey - if (!modKey) return - change.redo() - } - - /** - * On `z` key down, undo or redo. - * - * @param {Event} e - * @param {Object} data - * @param {Change} change - */ - - function onKeyDownZ(e, data, change) { - const modKey = IS_MAC ? e.metaKey : e.ctrlKey - if (!modKey) return - change[e.shiftKey ? 'redo' : 'undo']() - } - /** * On paste. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onPaste(e, data, change) { + function onPaste(event, data, change) { debug('onPaste', { data }) switch (data.type) { case 'fragment': - return onPasteFragment(e, data, change) + return onPasteFragment(event, data, change) case 'text': case 'html': - return onPasteText(e, data, change) + return onPasteText(event, data, change) } } /** * On paste fragment. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onPasteFragment(e, data, change) { + function onPasteFragment(event, data, change) { debug('onPasteFragment', { data }) change.insertFragment(data.fragment) } @@ -754,12 +758,12 @@ function Plugin(options = {}) { /** * On paste text, split blocks at new lines. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onPasteText(e, data, change) { + function onPasteText(event, data, change) { debug('onPasteText', { data }) const { state } = change @@ -776,12 +780,12 @@ function Plugin(options = {}) { /** * On select. * - * @param {Event} e + * @param {Event} event * @param {Object} data * @param {Change} change */ - function onSelect(e, data, change) { + function onSelect(event, data, change) { debug('onSelect', { data }) change.select(data.selection) } @@ -796,23 +800,19 @@ function Plugin(options = {}) { */ function render(props, state, editor) { + const handlers = EVENT_HANDLERS.reduce((obj, handler) => { + obj[handler] = editor[handler] + return obj + }, {}) + return ( { + if (compositionCount > n) return + isComposing = false + }) + + debug('onCompositionEnd', { event }) + } + + /** + * On composition start. + * + * @param {Event} event + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onCompositionStart(event, data, change, editor) { + isComposing = true + compositionCount++ + + debug('onCompositionStart', { event }) + } + + /** + * On copy. + * + * @param {Event} event + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onCopy(event, data, change, editor) { + const window = getWindow(event.target) + isCopying = true + window.requestAnimationFrame(() => isCopying = false) + + const { state } = change + data.type = 'fragment' + data.fragment = state.fragment + + debug('onCopy', { event }) + } + + /** + * On cut. + * + * @param {Event} event + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onCut(event, data, change, editor) { + if (editor.props.readOnly) return true + + const window = getWindow(event.target) + isCopying = true + window.requestAnimationFrame(() => isCopying = false) + + const { state } = change + data.type = 'fragment' + data.fragment = state.fragment + + debug('onCut', { event }) + } + + /** + * On drag end. + * + * @param {Event} event + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onDragEnd(event, data, change, editor) { + event.stopPropagation() + isDragging = false + isInternalDrag = null + + debug('onDragEnd', { event }) + } + + /** + * On drag over. + * + * @param {Event} event + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onDragOver(event, data, change, editor) { + if (isDragging) return true + event.stopPropagation() + isDragging = true + isInternalDrag = false + + debug('onDragOver', { event }) + } + + /** + * On drag start. + * + * @param {Event} event + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onDragStart(event, data, change, editor) { + isDragging = true + isInternalDrag = true + + const { dataTransfer } = event.nativeEvent + const d = getTransferData(dataTransfer) + Object.assign(data, d) + + if (data.type != 'node') { + const { state } = this.props + const { fragment } = state + const encoded = Base64.serializeNode(fragment) + setTransferData(dataTransfer, TRANSFER_TYPES.FRAGMENT, encoded) + } + + debug('onDragStart', { event }) + } + + /** + * On drop. + * + * @param {Event} event + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onDrop(event, data, change, editor) { + event.stopPropagation() + event.preventDefault() + + if (editor.props.readOnly) return + + const { state } = change + const { nativeEvent } = event + const { dataTransfer, x, y } = nativeEvent + const d = getTransferData(dataTransfer) + Object.assign(data, d) + + // Resolve a range from the caret position where the drop occured. + const window = getWindow(event.target) + let range + + // COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25) + if (window.document.caretRangeFromPoint) { + range = window.document.caretRangeFromPoint(x, y) + } else { + const position = window.document.caretPositionFromPoint(x, y) + range = window.document.createRange() + range.setStart(position.offsetNode, position.offset) + range.setEnd(position.offsetNode, position.offset) + } + + // Resolve a Slate range from the DOM range. + let selection = findRange(range, state) + if (!selection) return true + + const { document } = state + const node = document.getNode(selection.anchorKey) + const parent = document.getParent(node.key) + const el = findDOMNode(parent) + + // If the drop target is inside a void node, move it into either the next or + // previous node, depending on which side the `x` and `y` coordinates are + // closest to. + if (parent.isVoid) { + const rect = el.getBoundingClientRect() + const isPrevious = parent.kind == 'inline' + ? x - rect.left < rect.left + rect.width - x + : y - rect.top < rect.top + rect.height - y + + selection = isPrevious + ? selection.moveToEndOf(document.getPreviousText(node.key)) + : selection.moveToStartOf(document.getNextText(node.key)) + } + + // Add drop-specific information to the data. + data.target = selection + + // COMPAT: Edge throws "Permission denied" errors when + // accessing `dropEffect` or `effectAllowed` (2017/7/12) + try { + data.effect = dataTransfer.dropEffect + } catch (err) { + data.effect = null + } + + if (d.type == 'fragment' || d.type == 'node') { + data.isInternal = isInternalDrag + } + + debug('onDrop', { event }) + } + + /** + * On focus. + * + * @param {Event} event + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onFocus(event, data, change, editor) { + if (isCopying) return true + if (editor.props.readOnly) return true + + const el = findDOMNode(editor) + + // COMPAT: If the editor has nested editable elements, the focus can go to + // those elements. In Firefox, this must be prevented because it results in + // issues with keyboard navigation. (2017/03/30) + if (IS_FIREFOX && event.target != el) { + el.focus() + return true + } + + debug('onFocus', { event }) + } + + /** + * On input. + * + * @param {Event} event + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onInput(event, data, change, editor) { + if (isComposing) return true + if (change.state.isBlurred) return true + + debug('onInput', { event }) + } + + /** + * On key down. + * + * @param {Event} event + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onKeyDown(event, data, change, editor) { + if (editor.props.readOnly) return + + const { key } = event + + // When composing, these characters commit the composition but also move the + // selection before we're able to handle it, so prevent their default, + // selection-moving behavior. + if ( + isComposing && + (key == 'ArrowLeft' || key == 'ArrowRight' || key == 'ArrowUp' || key == 'ArrowDown') + ) { + event.preventDefault() + return true + } + + // Certain hotkeys have native behavior in contenteditable elements which + // will cause our state to be out of sync, so prevent them. + if (HOTKEYS.CONTENTEDITABLE(event)) { + event.preventDefault() + } + + // Keep track of an `isShifting` flag, because it's often used to trigger + // "Paste and Match Style" commands, but isn't available on the event in a + // normal paste event. + if (key == 'Shift') { + isShifting = true + } + + // COMPAT: add the deprecated keyboard event properties. + addDeprecatedKeyProperties(data, event) + + debug('onKeyDown', { event }) + } + + /** + * On key up. + * + * @param {Event} event + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onKeyUp(event, data, change, editor) { + // COMPAT: add the deprecated keyboard event properties. + addDeprecatedKeyProperties(data, event) + + if (event.key == 'Shift') { + isShifting = false + } + + debug('onKeyUp', { event }) + } + + /** + * On paste. + * + * @param {Event} event + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onPaste(event, data, change, editor) { + if (editor.props.readOnly) return + + event.preventDefault() + const d = getTransferData(event.clipboardData) + Object.assign(data, d) + + // COMPAT: Attach the `isShift` flag, so that people can use it to trigger + // "Paste and Match Style" logic. + Object.defineProperty(data, 'isShift', { + enumerable: true, + get() { + logger.deprecate('0.28.0', 'The `data.isShift` property of paste events has been deprecated. If you need this functionality, you\'ll need to keep track of that state with `onKeyDown` and `onKeyUp` events instead') + return isShifting + } + }) + + debug('onPaste', { event }) + } + + /** + * On select. + * + * @param {Event} event + * @param {Object} data + * @param {Change} change + * @param {Editor} editor + */ + + function onSelect(event, data, change, editor) { + if (isCopying) return + if (isComposing) return + if (editor.props.readOnly) return + + const window = getWindow(event.target) + const { state } = change + const { document, selection } = state + const native = window.getSelection() + + // If there are no ranges, the editor was blurred natively. + if (!native.rangeCount) { + data.selection = selection.blur() + } + + // Otherwise, determine the Slate selection from the native one. + else { + let range = findRange(native, state) + if (!range) return + + const { anchorKey, anchorOffset, focusKey, focusOffset } = range + const anchorText = document.getNode(anchorKey) + const focusText = document.getNode(focusKey) + const anchorInline = document.getClosestInline(anchorKey) + const focusInline = document.getClosestInline(focusKey) + const focusBlock = document.getClosestBlock(focusKey) + const anchorBlock = document.getClosestBlock(anchorKey) + + // COMPAT: If the anchor point is at the start of a non-void, and the + // focus point is inside a void node with an offset that isn't `0`, set + // the focus offset to `0`. This is due to void nodes 's being + // positioned off screen, resulting in the offset always being greater + // than `0`. Since we can't know what it really should be, and since an + // offset of `0` is less destructive because it creates a hanging + // selection, go with `0`. (2017/09/07) + if ( + anchorBlock && + !anchorBlock.isVoid && + anchorOffset == 0 && + focusBlock && + focusBlock.isVoid && + focusOffset != 0 + ) { + range = range.set('focusOffset', 0) + } + + // COMPAT: If the selection is at the end of a non-void inline node, and + // there is a node after it, put it in the node after instead. This + // standardizes the behavior, since it's indistinguishable to the user. + if ( + anchorInline && + !anchorInline.isVoid && + anchorOffset == anchorText.text.length + ) { + const block = document.getClosestBlock(anchorKey) + const next = block.getNextText(anchorKey) + if (next) range = range.moveAnchorTo(next.key, 0) + } + + if ( + focusInline && + !focusInline.isVoid && + focusOffset == focusText.text.length + ) { + const block = document.getClosestBlock(focusKey) + const next = block.getNextText(focusKey) + if (next) range = range.moveFocusTo(next.key, 0) + } + + range = range.normalize(document) + data.selection = range + } + + debug('onSelect', { event }) + } + + /** + * Return the plugin. + * + * @type {Object} + */ + + return { + onBeforeInput, + onBlur, + onCompositionEnd, + onCompositionStart, + onCopy, + onCut, + onDragEnd, + onDragOver, + onDragStart, + onDrop, + onFocus, + onInput, + onKeyDown, + onKeyUp, + onPaste, + onSelect, + } +} + +/** + * Add deprecated `data` fields from a key `event`. + * + * @param {Object} data + * @param {Object} event + */ + +function addDeprecatedKeyProperties(data, event) { + const { altKey, ctrlKey, metaKey, shiftKey, which } = event + const name = keycode(which) + + function define(key, value) { + Object.defineProperty(data, key, { + enumerable: true, + get() { + logger.deprecate('0.28.0', `The \`data.${key}\` property of keyboard events is deprecated, please use the native \`event\` properties instead.`) + return value + } + }) + } + + define('code', which) + define('key', name) + define('isAlt', altKey) + define('isCmd', IS_MAC ? metaKey && !altKey : false) + define('isCtrl', ctrlKey && !altKey) + define('isLine', IS_MAC ? metaKey : false) + define('isMeta', metaKey) + define('isMod', IS_MAC ? metaKey && !altKey : ctrlKey && !altKey) + define('isModAlt', IS_MAC ? metaKey && altKey : ctrlKey && altKey) + define('isShift', shiftKey) + define('isWord', IS_MAC ? altKey : ctrlKey) +} + +/** + * Export. + * + * @type {Object} + */ + +export default BeforePlugin diff --git a/packages/slate-react/test/plugins/index.js b/packages/slate-react/test/plugins/index.js index beeba7900..1aee8db23 100644 --- a/packages/slate-react/test/plugins/index.js +++ b/packages/slate-react/test/plugins/index.js @@ -1,5 +1,6 @@ -import CorePlugin from '../../src/plugins/core' +import AfterPlugin from '../../src/plugins/after' +import BeforePlugin from '../../src/plugins/before' import Simulator from 'slate-simulator' import assert from 'assert' import fs from 'fs' @@ -11,7 +12,7 @@ import { basename, extname, resolve } from 'path' */ describe('plugins', () => { - describe('core', () => { + describe.skip('core', () => { const dir = resolve(__dirname, 'core') const events = fs.readdirSync(dir).filter(e => e[0] != '.' && e != 'index.js') @@ -25,7 +26,7 @@ describe('plugins', () => { const module = require(resolve(testDir, test)) const { input, output, props = {}} = module const fn = module.default - const plugins = [CorePlugin(props)] + const plugins = [BeforePlugin(props), AfterPlugin(props)] const simulator = new Simulator({ plugins, state: input }) fn(simulator) diff --git a/packages/slate-simulator/src/index.js b/packages/slate-simulator/src/index.js index aab357408..c16eb179b 100644 --- a/packages/slate-simulator/src/index.js +++ b/packages/slate-simulator/src/index.js @@ -33,8 +33,10 @@ class Simulator { * @param {Object} attrs */ - constructor({ plugins, state }) { + constructor(props) { + const { plugins, state } = props const stack = new Stack({ plugins }) + this.props = props this.stack = stack this.state = state } @@ -53,13 +55,13 @@ EVENT_HANDLERS.forEach((handler) => { if (data == null) data = {} const { stack, state } = this - const editor = createEditor(stack, state) + const editor = createEditor(this) const event = createEvent(e) const change = state.change() - stack[handler](change, editor, event, data) - stack.onBeforeChange(change, editor) - stack.onChange(change, editor) + stack.handle(handler, change, editor, event, data) + stack.handle('onBeforeChange', change, editor) + stack.handle('onChange', change, editor) this.state = change.state return this @@ -84,11 +86,22 @@ function getMethodName(handler) { * @param {State} state */ -function createEditor(stack, state) { - return { +function createEditor({ stack, state, props }) { + const editor = { getSchema: () => stack.schema, getState: () => state, + props: { + autoCorrect: true, + autoFocus: false, + onChange: () => {}, + readOnly: false, + spellCheck: true, + ...props, + + }, } + + return editor } /** diff --git a/packages/slate/src/models/stack.js b/packages/slate/src/models/stack.js index 7ff130c29..c3d225bc7 100644 --- a/packages/slate/src/models/stack.js +++ b/packages/slate/src/models/stack.js @@ -13,27 +13,6 @@ import Schema from './schema' const debug = Debug('slate:stack') -/** - * Methods that are triggered on events and can change the state. - * - * @type {Array} - */ - -const METHODS = [ - 'onBeforeInput', - 'onBeforeChange', - 'onBlur', - 'onCopy', - 'onCut', - 'onDrop', - 'onFocus', - 'onKeyDown', - 'onKeyUp', - 'onPaste', - 'onSelect', - 'onChange', -] - /** * Default properties. * @@ -90,6 +69,27 @@ class Stack extends Record(DEFAULTS) { return 'stack' } + /** + * Invoke an event `handler` on all of the plugins, until one of them decides + * to stop propagation. + * + * @param {String} handler + * @param {Change} change + * @param {Editor} editor + * @param {Mixed} ...args + */ + + handle(handler, change, editor, ...args) { + debug(handler) + + for (let k = 0; k < this.plugins.length; k++) { + const plugin = this.plugins[k] + if (!plugin[handler]) continue + const next = plugin[handler](...args, change, editor) + if (next != null) break + } + } + /** * Invoke `render` on all of the plugins in reverse, building up a tree of * higher-order components. @@ -146,28 +146,6 @@ class Stack extends Record(DEFAULTS) { Stack.prototype[MODEL_TYPES.STACK] = true -/** - * Mix in the stack methods. - * - * @param {Change} change - * @param {Editor} editor - * @param {Mixed} ...args - */ - -for (let i = 0; i < METHODS.length; i++) { - const method = METHODS[i] - Stack.prototype[method] = function (change, editor, ...args) { - debug(method) - - for (let k = 0; k < this.plugins.length; k++) { - const plugin = this.plugins[k] - if (!plugin[method]) continue - const next = plugin[method](...args, change, editor) - if (next != null) break - } - } -} - /** * Resolve a schema from a set of `plugins`. * diff --git a/yarn.lock b/yarn.lock index a7fc9e5e9..8b8459351 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3364,6 +3364,10 @@ is-hotkey@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.0.1.tgz#d8d817209b34292551a85357e65cdbfcfa763443" +is-hotkey@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.0.3.tgz#3713fea135f86528c87cf39810b3934e45151390" + is-image@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-image/-/is-image-1.0.1.tgz#6fd51a752a1a111506d060d952118b0b989b426e"