diff --git a/packages/slate-react/package.json b/packages/slate-react/package.json index f60550f04..f79cdf446 100644 --- a/packages/slate-react/package.json +++ b/packages/slate-react/package.json @@ -33,6 +33,7 @@ "peerDependencies": { "immutable": ">=3.8.1 || >4.0.0-rc", "react": ">=16.6.0", + "react-dom": ">=16.6.0", "slate": ">=0.47.0" }, "devDependencies": { diff --git a/packages/slate-react/src/components/content.js b/packages/slate-react/src/components/content.js index 1ac2e38e6..1cbcf76c0 100644 --- a/packages/slate-react/src/components/content.js +++ b/packages/slate-react/src/components/content.js @@ -101,6 +101,7 @@ class Content extends React.Component { isUpdatingSelection: false, nodeRef: React.createRef(), nodeRefs: {}, + contentKey: 0, } /** @@ -211,7 +212,7 @@ class Content extends React.Component { const native = window.getSelection() const { activeElement } = window.document - if (debug.enabled) { + if (debug.update.enabled) { debug.update('updateSelection', { selection: selection.toJSON() }) } @@ -322,12 +323,23 @@ class Content extends React.Component { } this.tmp.isUpdatingSelection = false + + debug.update('updateSelection:setTimeout', { + anchorOffset: window.getSelection().anchorOffset, + }) }) } - if (updated && debug.enabled) { + if (updated && (debug.enabled || debug.update.enabled)) { debug('updateSelection', { selection, native, activeElement }) - debug.update('updateSelection-applied', { selection }) + + debug.update('updateSelection:applied', { + selection: selection.toJSON(), + native: { + anchorOffset: native.anchorOffset, + focusOffset: native.focusOffset, + }, + }) } } @@ -470,6 +482,11 @@ class Content extends React.Component { const window = getWindow(event.target) const { activeElement } = window.document + + debug.update('onNativeSelectionChange', { + anchorOffset: window.getSelection().anchorOffset, + }) + if (activeElement !== this.ref.current) return this.props.onEvent('onSelect', event) @@ -512,7 +529,10 @@ class Content extends React.Component { ...props.style, } + // console.log('rerender content', this.tmp.contentKey, document.text) + debug('render', { props }) + debug.update('render', this.tmp.contentKey, document.text) this.props.onEvent('onRender') @@ -523,7 +543,7 @@ class Content extends React.Component { return ( SELF - * keydown:Unidentified - * beforeInput:CHR(10) at end \* - * TOO LATE TO CANCEL -* End of word - * compositionEnd - * keydown:Enter \* - * beforeInput:insertParagraph - * CANCELLABLE -* End of line - * keydown:Enter \* - * beforeInput:insertParagraph - * CANCELLABLE - -Based on the previous cases: - -* Use a snapshot if `input:deleteContentBackward` is detected before an Enter which is detected either by a `keydown:Enter` or a `beforeInput:insertParagraph` and we don't know which. -* Cancel the event if we detect a `keydown:Enter` without an immediately preceding `input:deleteContentBackward`. - -### Enter at Start of Line - -**TODO:** - -* Go through all the steps in the Backspace handler. An enter at the beginning of a block looks exactly like a `delete` action at the beginning. The `reconciler` will be cancelled in the course of these events. -* A `keydown` event will fire with `event.key` === `Enter`. We need to set a variable `ENTER_START_OF_LINE` to `true`. Cancel the delete event and remove the reference. -* NOTE!!! Looks like splitting at other positions (not end of line) also provides an `Enter` and might be preferable to using the native `beforeInput` which we had to hack in!!! Try this!!! -* A `beforeinput` event will be called like in the `delete` code which usually cancels the `deleter` and resumes the `reconciler`. But since we removed the reference to the `deleter` neither of these methods are called. - -# API 28 - -## DOM breaks when suggestion selected on text entirely within a `strong` tag - -Appears similar to the bug in API 27. - -## Can't hit Enter at begining of word API27 (probably 26 too) - -WORKING ON THIS - -## Can't split word with Enter (PARTIAL FIXED) - -Move the cursor to `edit|able` where | is the cursor. - -Hit `enter` on the virtual keyboard. - -The `keydown` event does not indicate what key is being pressed so we don't know that we should be handling an enter. There are two opportunities: - -1. The onBeforeInput event has a `data` property that contains the text immediately before the cursor and it includes `edit|` where the pipe indicates an enter. -2. We can look through the text at the end of a composition and simulate hitting enter maybe. - -### Fixed for API 28 - -Allow enter to go through to the before plugin even during a compositiong and it works in API 28. - -### Broken in API 27 - -# API 27 - -## Typing at end of line yields wrong cursor position (FIXED) - -When you enter any text at the end of a block, the text gets entered in the wrong position. - -### Fix - -Fixed by ignoring the `updateSelection` code in `content.js` on the `onEvent` method if we are in Android. This doesn't ignore `updateSelection` altogether, only in that one place. - -## Missing `onCompositionStart` (FIXED) - -### Desciption - -Insert a word using the virtual keyboard. Click outside the editor. Touch after the last letter in the word. This will display some suggestions. Click one. Selecting a suggestion will fire the `onCompositionEnd` but will not fire the corresponding `onCompositionStart` before it. - -### Fix - -Fixed by setting `isComposing` from the `onCompositionEnd` event until the `requestAnimationFrame` callback is executed. - -## DOM breaks when suggestion selected on text entirely within a `strong` tag - -Touch anywhere in the bold word "rich" in the example. Select an alternative recommendation and we get a failure. - -Android is destroying the `strong` tag and replacing it with a `b` tag. - -The problem does not present itself if the word is surrounding by spaces before the `strong` tag. - -A possible fix may be to surround the word with a `ZERO WIDTH NO-BREAK SPACE` represented as `` in HTML. It appears in React for empty paragraphs.# - -## Other stuff - -In API 28 and possibly other versions of Android, when you select inside an empty block, the block is not actually empty. It contains a `ZERO WIDTH NO-BREAK SPACE` which is `𐃁` or `\uFEFF`. - -When the editor first starts, if you click immediately into an empty block, you will end up to the right of the zero-width space. Because of this, we don't get the all caps because I presume the editor only capitalizes the first characters and since the no break space is the first character it doesn't do this. - -But also, as a side effect, you end up in a different editing mode which fires events differently. This breaks a bunch of things. - -The fix (which I will be attempting) is to move the offset to `0` if we find ourselves in a block with the property `data-slate-zero-width="n"`. diff --git a/packages/slate-react/src/plugins/android/composition-manager.js b/packages/slate-react/src/plugins/android/composition-manager.js new file mode 100644 index 000000000..b2f321a87 --- /dev/null +++ b/packages/slate-react/src/plugins/android/composition-manager.js @@ -0,0 +1,610 @@ +import Debug from 'debug' +import getWindow from 'get-window' +import ReactDOM from 'react-dom' +import diffText from './diff-text' + +/** + * @type {Debug} + */ + +const debug = Debug('slate:composition-manager') + +/** + * Unicode String for a ZERO_WIDTH_SPACE + * + * @type {String} + */ + +const ZERO_WIDTH_SPACE = String.fromCharCode(65279) + +/** + * https://github.com/facebook/draft-js/commit/cda13cb8ff9c896cdb9ff832d1edeaa470d3b871 + */ + +const flushControlled = ReactDOM.unstable_flushControlled + +function renderSync(editor, fn) { + flushControlled(() => { + fn() + editor.controller.flush() + }) +} + +/** + * Takes text from a dom node and an offset within that text and returns an + * object with fixed text and fixed offset which removes zero width spaces + * and adjusts the offset. + * + * Optionally, if an `isLastNode` argument is passed in, it will also remove + * a trailing newline. + * + * @param {String} text + * @param {Number} offset + * @param {Boolean} isLastNode + */ + +function fixTextAndOffset(prevText, prevOffset = 0, isLastNode = false) { + let nextOffset = prevOffset + let nextText = prevText + let index = 0 + + while (index !== -1) { + index = nextText.indexOf(ZERO_WIDTH_SPACE, index) + if (index === -1) break + if (nextOffset > index) nextOffset-- + nextText = `${nextText.slice(0, index)}${nextText.slice(index + 1)}` + } + + // remove the last newline if we are in the last node of a block + const lastChar = nextText.charAt(nextText.length - 1) + + if (isLastNode && lastChar === '\n') { + nextText = nextText.slice(0, -1) + } + + const maxOffset = nextText.length + + if (nextOffset > maxOffset) nextOffset = maxOffset + return { text: nextText, offset: nextOffset } +} + +/** + * Based loosely on: + * + * https://github.com/facebook/draft-js/blob/master/src/component/handlers/composition/DOMObserver.js + * https://github.com/ProseMirror/prosemirror-view/blob/master/src/domobserver.js + * + * But is an analysis mainly for `backspace` and `enter` as we handle + * compositions as a single operation. + * + * @param {} element + */ + +function CompositionManager(editor) { + /** + * A MutationObserver that flushes to the method `flush` + * + * @type {MutationObserver} + */ + + const observer = new window.MutationObserver(flush) + + let win = null + + /** + * Object that keeps track of the most recent state + * + * @type {Range} + */ + + const last = { + rootEl: null, // root element that MutationObserver is attached to + diff: null, // last text node diff between Slate and DOM + range: null, // last range selected + domNode: null, // last DOM node the cursor was in + } + + /** + * Connect the MutationObserver to a specific editor root element + */ + + function connect() { + debug('connect', { rootEl }) + + const rootEl = editor.findDOMNode([]) + + if (last.rootEl === rootEl) return + + debug('connect:run') + + win = getWindow(rootEl) + + observer.observe(rootEl, { + childList: true, + characterData: true, + attributes: true, + subtree: true, + characterDataOldValue: true, + }) + } + + function disconnect() { + debug('disconnect') + observer.disconnect() + last.rootEl = null + } + + function clearDiff() { + debug('clearDIff') + last.diff = null + } + + /** + * Clear the `last` properties related to an action only + */ + + function clearAction() { + debug('clearAction') + last.diff = null + last.domNode = null + } + + /** + * Apply the last `diff` + * + * We don't want to apply the `diff` at the time it is created because we + * may be in a composition. There are a few things that trigger the applying + * of the saved diff. Sometimeson its own and sometimes immediately before + * doing something else with the Editor. + * + * - `onCompositionEnd` event + * - `onSelect` event only when the user has moved into a different node + * - The user hits `enter` + * - The user hits `backspace` and removes an inline node + * - The user hits `backspace` and merges two blocks + */ + + function applyDiff() { + debug('applyDiff') + const { diff } = last + if (diff == null) return + debug('applyDiff:run') + const { document } = editor.value + + let entire = editor.value.selection + .moveAnchorTo(diff.path, diff.start) + .moveFocusTo(diff.path, diff.end) + + entire = document.resolveRange(entire) + + editor.insertTextAtRange(entire, diff.insertText) + } + + /** + * Handle `enter` that splits block + */ + + function splitBlock() { + debug('splitBlock') + + renderSync(editor, () => { + applyDiff() + + if (last.range) { + editor.select(last.range) + } else { + debug('splitBlock:NO-SELECTION') + } + + editor + .splitBlock() + .focus() + .restoreDOM() + + clearAction() + }) + } + + /** + * Handle `backspace` that merges blocks + */ + + function mergeBlock() { + debug('mergeBlock') + + /** + * The delay is required because hitting `enter`, `enter` then `backspace` + * in a word results in the cursor being one position to the right in + * Android 9. + * + * Slate sets the position to `0` and we even check it immediately after + * setting it and it is correct, but somewhere Android moves it to the right. + * + * This happens only when using the virtual keyboard. Hitting enter on a + * hardware keyboard does not trigger this bug. + * + * The call to `focus` is required because when we switch examples then + * merge a block, we lose focus in Android 9 (possibly others). + */ + + win.requestAnimationFrame(() => { + renderSync(editor, () => { + applyDiff() + + editor + .select(last.range) + .deleteBackward() + .focus() + .restoreDOM() + + clearAction() + }) + }) + } + + /** + * The requestId used to the save selection + * + * @type {Any} + */ + + let onSelectTimeoutId = null + + let bufferedMutations = [] + let startActionFrameId = null + let isFlushing = false + + /** + * Mark the beginning of an action. The action happens when the + * `requestAnimationFrame` expires. + * + * If `startAction` is called again, it pushes the `action` to a new + * `requestAnimationFrame` and cancels the old one. + */ + + function startAction() { + if (onSelectTimeoutId) { + window.cancelAnimationFrame(onSelectTimeoutId) + onSelectTimeoutId = null + } + + isFlushing = true + + if (startActionFrameId) window.cancelAnimationFrame(startActionFrameId) + + startActionFrameId = window.requestAnimationFrame(() => { + if (bufferedMutations.length > 0) { + flushAction(bufferedMutations) + } + + startActionFrameId = null + bufferedMutations = [] + isFlushing = false + }) + } + + /** + * Handle MutationObserver flush + * + * @param {MutationList} mutations + */ + + function flush(mutations) { + debug('flush') + bufferedMutations.push(...mutations) + startAction() + } + + /** + * Handle a `requestAnimationFrame` long batch of mutations. + * + * @param {Array} mutations + */ + + function flushAction(mutations) { + debug('flushAction', mutations.length, mutations) + + // If there is an expanded collection, delete it + if (last.range && !last.range.isCollapsed) { + renderSync(editor, () => { + editor + .select(last.range) + .deleteBackward() + .focus() + .restoreDOM() + }) + return + } + + if (mutations.length > 1) { + // check if one of the mutations matches the signature of an `enter` + // which we use to signify a `splitBlock` + const splitBlockMutation = mutations.find(m => { + if (m.type !== 'childList') return false + if (m.addedNodes.length === 0) return false + const addedNode = m.addedNodes[0] + + // If a text node is created anywhere with a newline in it, it's an + // enter + if ( + addedNode.nodeType === window.Node.TEXT_NODE && + addedNode.textContent === '\n' + ) + return true + + // If an element is created with a key that matches a block in our + // document, that means the mutation is splitting an existing block + // by creating a new element with the same key. + if (addedNode.nodeType !== window.Node.ELEMENT_NODE) return false + const dataset = addedNode.dataset + const key = dataset.key + if (key == null) return false + const block = editor.value.document.getClosestBlock(key) + return !!block + }) + + if (splitBlockMutation) { + splitBlock() + return + } + } + + // If we haven't matched a more specific mutation already, these general + // mutation catchers will try and determine what the user was trying to + // do. + + const firstMutation = mutations[0] + + if (firstMutation.type === 'characterData') { + resolveDOMNode(firstMutation.target.parentNode) + } else if (firstMutation.type === 'childList') { + if (firstMutation.removedNodes.length > 0) { + if (mutations.length === 1) { + removeNode(firstMutation.removedNodes[0]) + } else { + mergeBlock() + } + } else if (firstMutation.addedNodes.length > 0) { + splitBlock() + } + } + } + + /** + * Takes a DOM Node and resolves it against Slate's Document. + * + * Saves the changes to `last.diff` which can be applied later using + * `applyDiff()` + * + * @param {DOMNode} domNode + */ + + function resolveDOMNode(domNode) { + debug('resolveDOMNode') + + const { value } = editor + const { document } = value + + const dataElement = domNode.closest(`[data-key]`) + const key = dataElement.dataset.key + const path = document.getPath(key) + const block = document.getClosestBlock(key) + const node = document.getDescendant(key) + const prevText = node.text + + // 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. + const isLastNode = block.nodes.last() === node + + const fix = fixTextAndOffset(domNode.textContent, 0, isLastNode) + + const nextText = fix.text + + // If the text is no different, there is no diff. + if (nextText === prevText) { + last.diff = null + return + } + + const diff = diffText(prevText, nextText) + + last.diff = { + path, + start: diff.start, + end: diff.end, + insertText: diff.insertText, + } + + debug('resolveDOMNode:diff', last.diff) + } + + /** + * Remove an Inline DOM Node. + * + * Happens when you delete the last character in an Inline DOM Node + */ + + function removeNode(domNode) { + debug('removeNode') + if (domNode.nodeType !== window.Node.ELEMENT_NODE) return + const { value } = editor + const { document, selection } = value + const node = editor.findNode(domNode) + const nodeSelection = document.resolveRange( + selection.moveToRangeOfNode(node) + ) + + renderSync(editor, () => { + editor + .select(nodeSelection) + .delete() + .restoreDOM() + }) + } + + /** + * handle `onCompositionStart` + */ + + function onCompositionStart() { + debug('onCompositionStart') + } + + /** + * handle `onCompositionEnd` + */ + + function onCompositionEnd() { + debug('onCompositionEnd') + + /** + * The timing on the `setTimeout` with `20` ms is sensitive. + * + * It cannot use `requestAnimationFrame` because it is too short. + * + * Android 9, for example, when you type `it ` the space will first trigger + * a `compositionEnd` for the `it` part before the mutation for the ` `. + * This means that we end up with `it` if we trigger too soon because it + * is on the wrong value. + */ + + window.setTimeout(() => { + if (last.diff) { + debug('onCompositionEnd:applyDiff') + + renderSync(editor, () => { + applyDiff() + + const domRange = win.getSelection().getRangeAt(0) + const domText = domRange.startContainer.textContent + const offset = domRange.startOffset + + const fix = fixTextAndOffset(domText, offset) + + const range = editor + .findRange({ + anchorNode: domRange.startContainer, + anchorOffset: 0, + focusNode: domRange.startContainer, + focusOffset: 0, + isCollapsed: true, + }) + .moveTo(fix.offset) + + /** + * We must call `restoreDOM` even though this is applying a `diff` which + * should not require it. But if you type `it me. no.` on a blank line + * with a block following it, the next line will merge with the this + * line. A mysterious `keydown` with `input` of backspace appears in the + * event stream which the user not React caused. + * + * `focus` is required as well because otherwise we lose focus on hitting + * `enter` in such a scenario. + */ + + editor + .select(range) + .focus() + .restoreDOM() + }) + } + + clearAction() + }, 20) + } + + /** + * Handle `onSelect` event + * + * Save the selection after a `requestAnimationFrame` + * + * - If we're not in the middle of flushing mutations + * - and cancel save if a mutation runs before the `requestAnimationFrame` + */ + + function onSelect(event) { + debug('onSelect:try') + + // Event can be Synthetic React or native. Grab only the native one so + // that we don't have to call `event.perist` for performance. + event = event.nativeEvent ? event.nativeEvent : event + + window.cancelAnimationFrame(onSelectTimeoutId) + onSelectTimeoutId = null + + // Don't capture the last selection if the selection was made during the + // flushing of DOM mutations. This means it is all part of one user action. + if (isFlushing) return + + onSelectTimeoutId = window.requestAnimationFrame(() => { + debug('onSelect:save-selection') + + const domSelection = getWindow(event.target).getSelection() + let range = editor.findRange(domSelection) + + const anchorFix = fixTextAndOffset( + domSelection.anchorNode.textContent, + domSelection.anchorOffset + ) + + const focusFix = fixTextAndOffset( + domSelection.focusNode.textContent, + domSelection.focusOffset + ) + + if (range.anchor.offset !== anchorFix.offset) { + range = range.set( + 'anchor', + range.anchor.set('offset', anchorFix.offset) + ) + } + + if (range.focus.offset !== focusFix.offset) { + range = range.set('focus', range.focus.set('offset', focusFix.offset)) + } + + debug('onSelect:save-data', { + domSelection: normalizeDOMSelection(domSelection), + range: range.toJS(), + }) + + // If the `domSelection` has moved into a new node, then reconcile with + // `applyDiff` + if ( + domSelection.isCollapsed && + last.node !== domSelection.anchorNode && + last.diff != null + ) { + debug('onSelect:applyDiff', last.diff) + applyDiff() + editor.select(range) + clearAction() + } + + last.range = range + last.node = domSelection.anchorNode + }) + } + + return { + clearDiff, + connect, + disconnect, + onKeyDown: startAction, + onCompositionStart, + onCompositionEnd, + onSelect, + } +} + +function normalizeDOMSelection(selection) { + return { + anchorNode: selection.anchorNode, + anchorOffset: selection.anchorOffset, + focusNode: selection.focusNode, + focusOffset: selection.focusOffset, + } +} + +export default CompositionManager diff --git a/packages/slate-react/src/plugins/android/composition-manager.md b/packages/slate-react/src/plugins/android/composition-manager.md new file mode 100644 index 000000000..8527f4e07 --- /dev/null +++ b/packages/slate-react/src/plugins/android/composition-manager.md @@ -0,0 +1,32 @@ +# CompositionManager + +Android version 8 and 9 use `CompositionManager` for compatibility. + +## MutationObserver + +The `CompositionManager` looks at mutations using `MutationObserver` and +bypasses all other event processing from the `dom` plugin. + +It uses mutations to determine how to modify the Editor `value` where `dom` +uses events to determine how to modify the Editor `value`. + +## How It Works + +We try to avoid an Editor `render` in the middle of a composition. At the same +time we want to make sure all mutations are eventually reflected in the Editor +`value`. + +### Actions + +`MutationObserver` emits batches of `mutations`. + +We also batch these batches into actions. Each action contains all the +mutations that are emitted within a single `requestAnimationFrame`. + +We do this because some actions like hitting `enter` might trigger multiple +batches of `mutations` but these multiple batches of `mutations` are all part +of one `enter` action. + +We get so many mutations because there are many DOM manipulations required to +split a node. The browser has to split the current text node in two and it +also has to clone all of the surrounding mark, inline and block nodes. diff --git a/packages/slate-react/src/plugins/android/diff-text.js b/packages/slate-react/src/plugins/android/diff-text.js new file mode 100644 index 000000000..633fd3afd --- /dev/null +++ b/packages/slate-react/src/plugins/android/diff-text.js @@ -0,0 +1,97 @@ +/** + * Returns the number of characters that are the same at the beginning of the + * String. + * + * @param {String} prev + * @param {String} next + */ + +function getDiffStart(prev, next) { + const length = Math.min(prev.length, next.length) + + for (let i = 0; i < length; i++) { + if (prev.charAt(i) !== next.charAt(i)) return i + } + + if (prev.length !== next.length) return length + return null +} + +/** + * Returns the number of characters that are the same at the end of the String + * up to `max`. Max prevents double-counting characters when there are + * multiple duplicate characters around the diff area. + * + * @param {String} prev + * @param {String} next + * @param {Number} max + */ + +function getDiffEnd(prev, next, max) { + const prevLength = prev.length + const nextLength = next.length + const length = Math.min(prevLength, nextLength, max) + + for (let i = 0; i < length; i++) { + const prevChar = prev.charAt(prevLength - i - 1) + const nextChar = next.charAt(nextLength - i - 1) + if (prevChar !== nextChar) return i + } + + if (prev.length !== next.length) return length + return null +} + +/** + * Takes two strings and returns an object representing two offsets. The + * first, `start` represents the number of characters that are the same at + * the front of the String. The `end` represents the number of characters + * that are the same at the end of the String. + * + * Returns null if they are identical. + * + * @param {String} prev + * @param {String} next + */ + +function getDiffOffsets(prev, next) { + if (prev === next) return null + const start = getDiffStart(prev, next) + const maxEnd = Math.min(prev.length - start, next.length - start) + const end = getDiffEnd(prev, next, maxEnd) + return { start, end, total: start + end } +} + +/** + * Takes a text string and returns a slice from the string at the given offses + * + * @param {String} text + * @param {Object} offsets + */ + +function sliceText(text, offsets) { + return text.slice(offsets.start, text.length - offsets.end) +} + +/** + * Takes two strings and returns a smart diff that can be used to describe the + * change in a way that can be used as operations like inserting, removing or + * replacing text. + * + * @param {String} prev + * @param {String} next + */ + +export default function diff(prev, next) { + const offsets = getDiffOffsets(prev, next) + if (offsets == null) return null + const insertText = sliceText(next, offsets) + const removeText = sliceText(prev, offsets) + return { + start: offsets.start, + end: prev.length - offsets.end, + cursor: offsets.start + insertText.length, + insertText, + removeText, + } +} diff --git a/packages/slate-react/src/plugins/android/fix-selection-in-zero-width-block.js b/packages/slate-react/src/plugins/android/fix-selection-in-zero-width-block.js deleted file mode 100644 index 81a33e82f..000000000 --- a/packages/slate-react/src/plugins/android/fix-selection-in-zero-width-block.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Fixes a selection within the DOM when the cursor is in Slate's special - * zero-width block. Slate handles empty blocks in a special manner and the - * cursor can end up either before or after the non-breaking space. This - * causes different behavior in Android and so we make sure the seleciton is - * always before the zero-width space. - * - * @param {Window} window - */ - -export default function fixSelectionInZeroWidthBlock(window) { - const domSelection = window.getSelection() - const { anchorNode } = domSelection - const { dataset } = anchorNode.parentElement - const isZeroWidth = dataset ? dataset.slateZeroWidth === 'n' : false - - // We are doing three checks to see if we need to move the cursor. - // Is this a zero-width slate span? - // Is the current cursor position not at the start of it? - // Is there more than one character (i.e. the zero-width space char) in here? - if ( - isZeroWidth && - anchorNode.textContent.length === 1 && - domSelection.anchorOffset !== 0 - ) { - const range = window.document.createRange() - range.setStart(anchorNode, 0) - range.setEnd(anchorNode, 0) - domSelection.removeAllRanges() - domSelection.addRange(range) - } -} diff --git a/packages/slate-react/src/plugins/android/index.js b/packages/slate-react/src/plugins/android/index.js index 8372e6ed7..68f26aed0 100644 --- a/packages/slate-react/src/plugins/android/index.js +++ b/packages/slate-react/src/plugins/android/index.js @@ -1,623 +1,122 @@ -import Debug from 'debug' import getWindow from 'get-window' -import pick from 'lodash/pick' - -import { ANDROID_API_VERSION } from 'slate-dev-environment' -import fixSelectionInZeroWidthBlock from './fix-selection-in-zero-width-block' -import getSelectionFromDom from '../../utils/get-selection-from-dom' -import isInputDataEnter from './is-input-data-enter' -import isInputDataLastChar from './is-input-data-last-char' -import DomSnapshot from './dom-snapshot' -import Executor from './executor' - -const debug = Debug('slate:android') -debug.reconcile = Debug('slate:reconcile') - -debug('ANDROID_API_VERSION', { ANDROID_API_VERSION }) +import CompositionManager from './composition-manager' /** - * Define variables related to composition state. + * Fixes a selection within the DOM when the cursor is in Slate's special + * zero-width block. Slate handles empty blocks in a special manner and the + * cursor can end up either before or after the non-breaking space. This + * causes different behavior in Android and so we make sure the seleciton is + * always before the zero-width space. + * + * @param {Window} window */ -const NONE = 0 -const COMPOSING = 1 - -function AndroidPlugin() { - /** - * The current state of composition. - * - * @type {NONE|COMPOSING|WAITING} - */ - - let status = NONE - - /** - * The set of nodes that we need to process when we next reconcile. - * Usually this is soon after the `onCompositionEnd` event. - * - * @type {Set} set containing Node objects - */ - - const nodes = new window.Set() - - /** - * Keep a snapshot after a composition end for API 26/27. If a `beforeInput` - * gets called with data that ends in an ENTER then we need to use this - * snapshot to revert the DOM so that React doesn't get out of sync with the - * DOM. We also need to cancel the `reconcile` operation as it interferes in - * certain scenarios like hitting 'enter' at the end of a word. - * - * @type {DomSnapshot} [compositionEndSnapshot] - - */ - - let compositionEndSnapshot = null - - /** - * When there is a `compositionEnd` we ened to reconcile Slate's Document - * with the DOM. The `reconciler` is an instance of `Executor` that does - * this for us. It is created on every `compositionEnd` and executes on the - * next `requestAnimationFrame`. The `Executor` can be cancelled and resumed - * which some methods do. - * - * @type {Executor} - */ - - let reconciler = null - - /** - * A snapshot that gets taken when there is a `keydown` event in API26/27. - * If an `input` gets called with `inputType` of `deleteContentBackward` - * we need to undo the delete that Android does to keep React in sync with - * the DOM. - * - * @type {DomSnapshot} - */ - - let keyDownSnapshot = null - - /** - * The deleter is an instace of `Executor` that will execute a delete - * operation on the next `requestAnimationFrame`. It has to wait because - * we need Android to finish all of its DOM operations to do with deletion - * before we revert them to a Snapshot. After reverting, we then execute - * Slate's version of delete. - * - * @type {Executor} - */ - - let deleter = null - - /** - * Because Slate implements its own event handler for `beforeInput` in - * addition to React's version, we actually get two. If we cancel the - * first native version, the React one will still fire. We set this to - * `true` if we don't want that to happen. Remember that when we prevent it, - * we need to tell React to `preventDefault` so the event doesn't continue - * through React's event system. - * - * type {Boolean} - */ - - let preventNextBeforeInput = false - - /** - * When a composition ends, in some API versions we may need to know what we - * have learned so far about the composition and what we want to do because of - * some actions that may come later. - * - * For example in API 26/27, if we get a `beforeInput` that tells us that the - * input was a `.`, then we want the reconcile to happen even if there are - * `onInput:delete` events that follow. In this case, we would set - * `compositionEndAction` to `period`. During the `onInput` we would check if - * the `compositionEndAction` says `period` and if so we would not start the - * `delete` action. - * - * @type {(String|null)} - */ - - let compositionEndAction = null - - /** - * Looks at the `nodes` we have collected, usually the things we have edited - * during the course of a composition, and then updates Slate's internal - * Document based on the text values in these DOM nodes and also updates - * Slate's Selection based on the current cursor position in the Editor. - * - * @param {Window} window - * @param {Editor} editor - * @param {String} options.from - where reconcile was called from for debug - */ - - function reconcile(window, editor, { from }) { - debug.reconcile({ from }) - const domSelection = window.getSelection() - const selection = editor.findSelection(domSelection) - - nodes.forEach(node => { - editor.reconcileDOMNode(node) - }) - - editor.select(selection) - nodes.clear() - } - - /** - * On before input. - * - * Check `components/content` because some versions of Android attach a - * native `beforeinput` event on the Editor. In this case, you might need - * to distinguish whether the event coming through is the native or React - * version of the event. Also, if you cancel the native version that does - * not necessarily mean that the React version is cancelled. - * - * @param {Event} event - * @param {Editor} editor - * @param {Function} next - */ - - function onBeforeInput(event, editor, next) { - const isNative = !event.nativeEvent - - debug('onBeforeInput', { - isNative, - event, - status, - e: pick(event, ['data', 'inputType', 'isComposing', 'nativeEvent']), - }) - - const window = getWindow(event.target) - - if (preventNextBeforeInput) { - event.preventDefault() - preventNextBeforeInput = false - return - } - - switch (ANDROID_API_VERSION) { - case 25: - // prevent onBeforeInput to allow selecting an alternate suggest to - // work. - break - case 26: - case 27: - if (deleter) { - deleter.cancel() - reconciler.resume() - } - - // This analyses Android's native `beforeInput` which Slate adds - // on in the `Content` component. It only fires if the cursor is at - // the end of a block. Otherwise, the code below checks. - if (isNative) { - if ( - event.inputType === 'insertParagraph' || - event.inputType === 'insertLineBreak' - ) { - debug('onBeforeInput:enter:native', {}) - const domSelection = window.getSelection() - const selection = getSelectionFromDom(window, editor, domSelection) - preventNextBeforeInput = true - event.preventDefault() - editor.moveTo(selection.anchor.path, selection.anchor.offset) - editor.splitBlock() - } - } else { - if (isInputDataLastChar(event.data, ['.'])) { - debug('onBeforeInput:period') - reconciler.cancel() - compositionEndAction = 'period' - return - } - - // This looks at the beforeInput event's data property and sees if it - // ends in a linefeed which is character code 10. This appears to be - // the only way to detect that enter has been pressed except at end - // of line where it doesn't work. - const isEnter = isInputDataEnter(event.data) - - if (isEnter) { - if (reconciler) reconciler.cancel() - - window.requestAnimationFrame(() => { - debug('onBeforeInput:enter:react', {}) - compositionEndSnapshot.apply(editor) - editor.splitBlock() - }) - } - } - - break - case 28: - // If a `beforeInput` event fires after an `input:deleteContentBackward` - // event, it appears to be a good indicator that it is some sort of - // special combined Android event. If this is the case, then we don't - // want to have a deletion to happen, we just want to wait until Android - // has done its thing and then at the end we just want to reconcile. - if (deleter) { - deleter.cancel() - reconciler.resume() - } - - break - default: - if (status !== COMPOSING) next() - } - } - - /** - * On Composition end. By default, when a `compositionEnd` event happens, - * we start a reconciler. The reconciler will update Slate's Document using - * the DOM as the source of truth. In some cases, the reconciler needs to - * be cancelled and can also be resumed. For example, when a delete - * immediately followed a `compositionEnd`, we don't want to reconcile. - * Instead, we want the `delete` to take precedence. - * - * @param {Event} event - * @param {Editor} editor - * @param {Function} next - */ - - function onCompositionEnd(event, editor, next) { - debug('onCompositionEnd', { event }) - const window = getWindow(event.target) - const domSelection = window.getSelection() - const { anchorNode } = domSelection - - switch (ANDROID_API_VERSION) { - case 26: - case 27: - compositionEndSnapshot = new DomSnapshot(window, editor) - // fixes a bug in Android API 26 & 27 where a `compositionEnd` is triggered - // without the corresponding `compositionStart` event when clicking a - // suggestion. - // - // If we don't add this, the `onBeforeInput` is triggered and passes - // through to the `before` plugin. - status = COMPOSING - break - } - - compositionEndAction = 'reconcile' - nodes.add(anchorNode) - - reconciler = new Executor(window, () => { - status = NONE - reconcile(window, editor, { from: 'onCompositionEnd:reconciler' }) - compositionEndAction = null - }) - } - - /** - * On composition start. - * - * @param {Event} event - * @param {Editor} editor - * @param {Function} next - */ - - function onCompositionStart(event, editor, next) { - debug('onCompositionStart', { event }) - status = COMPOSING - nodes.clear() - } - - /** - * On composition update. - * - * @param {Event} event - * @param {Editor} editor - * @param {Function} next - */ - - function onCompositionUpdate(event, editor, next) { - debug('onCompositionUpdate', { event }) - } - - /** - * On input. - * - * @param {Event} event - * @param {Editor} editor - * @param {Function} next - */ - - function onInput(event, editor, next) { - debug('onInput', { - event, - status, - e: pick(event, ['data', 'nativeEvent', 'inputType', 'isComposing']), - }) - - switch (ANDROID_API_VERSION) { - case 24: - case 25: - break - case 26: - case 27: - case 28: - const { nativeEvent } = event - - if (ANDROID_API_VERSION === 28) { - // NOTE API 28: - // When a user hits space and then backspace in `middle` we end up - // with `midle`. - // - // This is because when the user hits space, the composition is not - // ended because `compositionEnd` is not called yet. When backspace is - // hit, the `compositionEnd` is called. We need to revert the DOM but - // the reconciler has not had a chance to run from the - // `compositionEnd` because it is set to run on the next - // `requestAnimationFrame`. When the backspace is carried out on the - // Slate Value, Slate doesn't know about the space yet so the - // backspace is carried out without the space cuasing us to lose a - // character. - // - // This fix forces Android to reconcile immediately after hitting - // the space. - // - // NOTE API 27: - // It is confirmed that this bug does not present itself on API27. - // A space fires a `compositionEnd` (as well as other events including - // an input that is a delete) so the reconciliation happens. - // - if ( - nativeEvent.inputType === 'insertText' && - nativeEvent.data === ' ' - ) { - if (reconciler) reconciler.cancel() - if (deleter) deleter.cancel() - reconcile(window, editor, { from: 'onInput:space' }) - return - } - } - - if (ANDROID_API_VERSION === 26 || ANDROID_API_VERSION === 27) { - if (compositionEndAction === 'period') { - debug('onInput:period:abort') - // This means that there was a `beforeInput` that indicated the - // period was pressed. When a period is pressed, you get a bunch - // of delete actions mixed in. We want to ignore those. At this - // point, we add the current node to the list of things we need to - // resolve at the next compositionEnd. We know that a new - // composition will start right after this event so it is safe to - // do this. - const { anchorNode } = window.getSelection() - nodes.add(anchorNode) - return - } - } - - if (nativeEvent.inputType === 'deleteContentBackward') { - debug('onInput:delete', { keyDownSnapshot }) - const window = getWindow(event.target) - if (reconciler) reconciler.cancel() - if (deleter) deleter.cancel() - - deleter = new Executor( - window, - () => { - debug('onInput:delete:callback', { keyDownSnapshot }) - keyDownSnapshot.apply(editor) - editor.deleteBackward() - deleter = null - }, - { - onCancel() { - deleter = null - }, - } - ) - return - } - - if (status === COMPOSING) { - const { anchorNode } = window.getSelection() - nodes.add(anchorNode) - return - } - - // Some keys like '.' are input without compositions. This happens - // in API28. It might be happening in API 27 as well. Check by typing - // `It me. No.` On a blank line. - if (ANDROID_API_VERSION === 28) { - debug('onInput:fallback') - const { anchorNode } = window.getSelection() - nodes.add(anchorNode) - - window.requestAnimationFrame(() => { - debug('onInput:fallback:callback') - reconcile(window, editor, { from: 'onInput:fallback' }) - }) - return - } - - break - default: - if (status === COMPOSING) return - next() - } - } - - /** - * On key down. - * - * @param {Event} event - * @param {Editor} editor - * @param {Function} next - */ - - function onKeyDown(event, editor, next) { - debug('onKeyDown', { - event, - status, - e: pick(event, [ - 'char', - 'charCode', - 'code', - 'key', - 'keyCode', - 'keyIdentifier', - 'keyLocation', - 'location', - 'nativeEvent', - 'which', - ]), - }) - - const window = getWindow(event.target) - - switch (ANDROID_API_VERSION) { - // 1. We want to allow enter keydown to allows go through - // 2. We want to deny keydown, I think, when it fires before the composition - // or something. Need to remember what it was. - - case 25: - // in API25 prevent other keys to fix clicking a word and then - // selecting an alternate suggestion. - // - // NOTE: - // The `setSelectionFromDom` is to allow hitting `Enter` to work - // because the selection needs to be in the right place; however, - // for now we've removed the cancelling of `onSelect` and everything - // appears to be working. Not sure why we removed `onSelect` though - // in API25. - if (event.key === 'Enter') { - // const window = getWindow(event.target) - // const selection = window.getSelection() - // setSelectionFromDom(window, editor, selection) - next() - } - - break - case 26: - case 27: - if (event.key === 'Enter') { - debug('onKeyDown:enter', {}) - - if (deleter) { - // If a `deleter` exists which means there was an onInput with - // `deleteContentBackward` that hasn't fired yet, then we know - // this is one of the cases where we have to revert to before - // the split. - deleter.cancel() - event.preventDefault() - - window.requestAnimationFrame(() => { - debug('onKeyDown:enter:callback') - compositionEndSnapshot.apply(editor) - editor.splitBlock() - }) - } else { - event.preventDefault() - // If there is no deleter, all we have to do is prevent the - // action before applying or splitBlock. In this scenario, we - // have to grab the selection from the DOM. - const domSelection = window.getSelection() - const selection = getSelectionFromDom(window, editor, domSelection) - editor.moveTo(selection.anchor.path, selection.anchor.offset) - editor.splitBlock() - } - return - } - - // We need to take a snapshot of the current selection and the - // element before when the user hits the backspace key. This is because - // we only know if the user hit backspace if the `onInput` event that - // follows has an `inputType` of `deleteContentBackward`. At that time - // it's too late to stop the event. - keyDownSnapshot = new DomSnapshot(window, editor, { - before: true, - }) - - // If we let 'Enter' through it breaks handling of hitting - // enter at the beginning of a word so we need to stop it. - break - case 28: - { - if (event.key === 'Enter') { - debug('onKeyDown:enter') - event.preventDefault() - if (reconciler) reconciler.cancel() - if (deleter) deleter.cancel() - - window.requestAnimationFrame(() => { - reconcile(window, editor, { from: 'onKeyDown:enter' }) - editor.splitBlock() - }) - return - } - - // We need to take a snapshot of the current selection and the - // element before when the user hits the backspace key. This is because - // we only know if the user hit backspace if the `onInput` event that - // follows has an `inputType` of `deleteContentBackward`. At that time - // it's too late to stop the event. - keyDownSnapshot = new DomSnapshot(window, editor, { - before: true, - }) - - debug('onKeyDown:snapshot', { keyDownSnapshot }) - } - - // If we let 'Enter' through it breaks handling of hitting - // enter at the beginning of a word so we need to stop it. - break - - default: - if (status !== COMPOSING) { - next() - } - } - } - - /** - * On select. - * - * @param {Event} event - * @param {Editor} editor - * @param {Function} next - */ - - function onSelect(event, editor, next) { - debug('onSelect', { event, status }) - - switch (ANDROID_API_VERSION) { - // We don't want to have the selection move around in an onSelect because - // it happens after we press `enter` in the same transaction. This - // causes the cursor position to not be properly placed. - case 26: - case 27: - case 28: - const window = getWindow(event.target) - fixSelectionInZeroWidthBlock(window) - break - default: - break - } - } - - /** - * Return the plugin. - * - * @type {Object} - */ - - return { - onBeforeInput, - onCompositionEnd, - onCompositionStart, - onCompositionUpdate, - onInput, - onKeyDown, - onSelect, +function fixSelectionInZeroWidthBlock(window) { + const domSelection = window.getSelection() + const { anchorNode } = domSelection + if (anchorNode == null) return + const { dataset } = anchorNode.parentElement + const isZeroWidth = dataset ? dataset.slateZeroWidth === 'n' : false + + if ( + isZeroWidth && + anchorNode.textContent.length === 1 && + domSelection.anchorOffset !== 0 + ) { + const range = window.document.createRange() + range.setStart(anchorNode, 0) + range.setEnd(anchorNode, 0) + domSelection.removeAllRanges() + domSelection.addRange(range) } } /** - * Export. + * Android Plugin * - * @type {Function} + * @param {Editor} options.editor */ +function AndroidPlugin({ editor }) { + const observer = new CompositionManager(editor) + + /** + * handle `onCompositionStart` + */ + + function onCompositionStart() { + observer.onCompositionStart() + } + + /** + * handle `onCompositionEnd` + */ + + function onCompositionEnd() { + observer.onCompositionEnd() + } + + /** + * handle `onSelect` + * + * @param {Event} event + */ + + function onSelect(event) { + const window = getWindow(event.target) + fixSelectionInZeroWidthBlock(window) + observer.onSelect(event) + } + + /** + * handle `onComponentDidMount` + */ + + function onComponentDidMount() { + observer.connect() + } + + /** + * handle `onComponentDidUpdate` + */ + + function onComponentDidUpdate() { + observer.connect() + } + + /** + * handle `onComponentWillUnmount` + * + * @param {Event} event + */ + + function onComponentWillUnmount() { + observer.disconnect() + } + + /** + * handle `onRender` + * + * @param {Event} event + */ + + function onRender() { + observer.disconnect() + + // We don't want the `diff` from a previous render to apply to a + // potentially different value (e.g. when we switch examples) + observer.clearDiff() + } + + return { + onComponentDidMount, + onComponentDidUpdate, + onComponentWillUnmount, + onCompositionEnd, + onCompositionStart, + onRender, + onSelect, + } +} + export default AndroidPlugin diff --git a/packages/slate-react/src/plugins/android/is-input-data-enter.js b/packages/slate-react/src/plugins/android/is-input-data-enter.js deleted file mode 100644 index 7384429e4..000000000 --- a/packages/slate-react/src/plugins/android/is-input-data-enter.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * In Android API 26 and 27 we can tell if the input key was pressed by - * waiting for the `beforeInput` event and seeing that the last character - * of its `data` property is char code `10`. - * - * Note that at this point it is too late to prevent the event from affecting - * the DOM so we use other methods to clean the DOM up after we have detected - * the input. - * - * @param {String} data - * @return {Boolean} - */ - -export default function isInputDataEnter(data) { - if (data == null) return false - const lastChar = data[data.length - 1] - const charCode = lastChar.charCodeAt(0) - return charCode === 10 -} diff --git a/packages/slate-react/src/plugins/android/is-input-data-last-char.js b/packages/slate-react/src/plugins/android/is-input-data-last-char.js deleted file mode 100644 index f9635c1e7..000000000 --- a/packages/slate-react/src/plugins/android/is-input-data-last-char.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * In Android sometimes the only way to tell what the user is trying to do - * is to look at an event's `data` property and see if the last characters - * matches a character. This method helps us make that determination. - * - * @param {String} data - * @param {[String]} chars - * @return {Boolean} - */ - -export default function isInputDataLastChar(data, chars) { - if (!Array.isArray(chars)) - throw new Error(`chars must be an array of one character strings`) - if (data == null) return false - const lastChar = data[data.length - 1] - return chars.includes(lastChar) -} diff --git a/packages/slate-react/src/plugins/dom/index.js b/packages/slate-react/src/plugins/dom/index.js index eb9e187b3..a5aafbf95 100644 --- a/packages/slate-react/src/plugins/dom/index.js +++ b/packages/slate-react/src/plugins/dom/index.js @@ -1,5 +1,8 @@ import { IS_ANDROID } from 'slate-dev-environment' + import AndroidPlugin from '../android' +import NoopPlugin from '../debug/noop' + import AfterPlugin from './after' import BeforePlugin from './before' @@ -18,9 +21,11 @@ function DOMPlugin(options = {}) { // COMPAT: Add Android specific handling separately before it gets to the // other plugins because it is specific (other browser don't need it) and // finicky (it has to come before other plugins to work). - const beforeBeforePlugins = IS_ANDROID ? [AndroidPlugin()] : [] + const androidPlugins = IS_ANDROID + ? [AndroidPlugin(options), NoopPlugin(options)] + : [] - return [...beforeBeforePlugins, beforePlugin, ...plugins, afterPlugin] + return [...androidPlugins, beforePlugin, ...plugins, afterPlugin] } /** diff --git a/packages/slate-react/src/plugins/react/index.js b/packages/slate-react/src/plugins/react/index.js index 4fd912db2..26cd9aa62 100644 --- a/packages/slate-react/src/plugins/react/index.js +++ b/packages/slate-react/src/plugins/react/index.js @@ -1,6 +1,7 @@ import Debug from 'debug' -import PlaceholderPlugin from 'slate-react-placeholder' +import { IS_ANDROID } from 'slate-dev-environment' +import PlaceholderPlugin from 'slate-react-placeholder' import EditorPropsPlugin from './editor-props' import RenderingPlugin from './rendering' import CommandsPlugin from './commands' @@ -19,7 +20,7 @@ import DebugMutationsPlugin from '../debug/debug-mutations' */ function ReactPlugin(options = {}) { - const { placeholder = '', plugins = [] } = options + const { placeholder = '' } = options const debugEventsPlugin = Debug.enabled('slate:events') ? DebugEventsPlugin(options) : null @@ -33,23 +34,28 @@ function ReactPlugin(options = {}) { const commandsPlugin = CommandsPlugin(options) const queriesPlugin = QueriesPlugin(options) const editorPropsPlugin = EditorPropsPlugin(options) - const domPlugin = DOMPlugin({ - plugins: [editorPropsPlugin, ...plugins], - }) + const domPlugin = DOMPlugin(options) const restoreDomPlugin = RestoreDOMPlugin() - const placeholderPlugin = PlaceholderPlugin({ - placeholder, - when: (editor, node) => - node.object === 'document' && - node.text === '' && - node.nodes.size === 1 && - Array.from(node.texts()).length === 1, - }) + + // Disable placeholder for Android because it messes with reconciliation + // and doesn't disappear until composition is complete. + // e.g. In empty, type "h" and autocomplete on Android 9 and deletes all text. + const placeholderPlugin = IS_ANDROID + ? null + : PlaceholderPlugin({ + placeholder, + when: (editor, node) => + node.object === 'document' && + node.text === '' && + node.nodes.size === 1 && + Array.from(node.texts()).length === 1, + }) return [ debugEventsPlugin, debugBatchEventsPlugin, debugMutationsPlugin, + editorPropsPlugin, domPlugin, restoreDomPlugin, placeholderPlugin, diff --git a/packages/slate-react/src/plugins/react/restore-dom.js b/packages/slate-react/src/plugins/react/restore-dom.js index c40a9c521..896218ebe 100644 --- a/packages/slate-react/src/plugins/react/restore-dom.js +++ b/packages/slate-react/src/plugins/react/restore-dom.js @@ -8,7 +8,8 @@ function RestoreDOMPlugin() { */ function restoreDOM(editor) { - editor.setState({ contentKey: editor.state.contentKey + 1 }) + const tmp = editor.tmp.contentRef.current.tmp + tmp.contentKey = tmp.contentKey + 1 } return {