From 17cdeae858b4c8a88b408fe743e6d8f2bdcbb72b Mon Sep 17 00:00:00 2001 From: Sunny Hirai Date: Mon, 28 Jan 2019 12:30:48 -0800 Subject: [PATCH] Android 8.0, 8.1 and 9.0 Support (#2553) * Allow the dev server to work for non localhost host * Refactored set-selection-from-dom into utils as prep for Android support * Show debug onInput at start if triggered * Added and refactored to use set-text-from-dom-node with improved set selection after input * Remove unnecessary console.log in set-text-from-dom-node * Fixes to pass linter * Adds basic composition to Android API27 including fixing one bug where compositionStart does not fire * Fix some of the enter handling in API 27 and 28 * Add fixes for API 25 * Add debug for slate:update instead of separate render and updateSelection * Add API 26 fix for ignoring all but Enter in onKeyDown * Fix enter on Android 26 and 27 * Revert onSelect bug. Editor API 26 and 27 stable-ish * Fix enter at beginning and end of word in API 26 and 27 * Fix enter handling at end of line API 26 and 27 * Fix reversion of enter bug when not at end of line * Rename enter to linefeed which is more accurate * Fix backspace on Android 27 and 28 * Fix enter at end of line then backspace then enter bug in API 26 and 27 * Refactor to simplify reading code * Refactor to use executor and fix the suggestion problem * Fix multi point edit in API 27/28 * Update Android documentation on enter handling * Fix enter in API 26/27 and document 4 different enter cases * Refactor partial into SlateSnapshot * Complete SlateSnapshot refactor * Remove unnecessary plugin comments * Add smoke tests * Rename smoke tests to composition in exmaples * Fix API28 split join and insertion * Fix space then backspace in middle of word bug in API 28 * Add text for middle word space and backspace bug * Add note that the space backspace bug does not exist on API 27 * Fix 'It me. No.' bug in API 26/27 * Fix comments * Update comments to fit Slate style guide * Move a debug statement * Fix zero-width selection placement bug. * Fix 'it is' then enter in middle of 'it' bug * Partial fix of enter, backspace, enter in word * Add and fix comments. Fix selection in zero-width for API26-27 * Fix linting * Fix documentation * Remove snapback from packages * Remove snapback from yarn.lock --- examples/app.js | 16 +- examples/composition/index.js | 401 +++++++++++ examples/composition/insert.js | 15 + examples/composition/special.js | 23 + examples/composition/split-join.js | 18 + examples/composition/util.js | 15 + examples/composition/value.json | 119 ++++ package.json | 2 + .../slate-react/src/components/content.js | 43 +- packages/slate-react/src/plugins/ANDROID.md | 219 ++++++ packages/slate-react/src/plugins/android.js | 624 ++++++++++++++++++ packages/slate-react/src/plugins/debug.js | 64 ++ packages/slate-react/src/plugins/dom.js | 11 +- .../src/utils/android-api-version.js | 49 ++ packages/slate-react/src/utils/closest.js | 16 + .../slate-react/src/utils/element-snapshot.js | 164 +++++ packages/slate-react/src/utils/executor.js | 96 +++ .../fix-selection-in-zero-width-block.js | 32 + .../src/utils/get-selection-from-dom.js | 77 +++ .../src/utils/is-input-data-enter.js | 19 + .../src/utils/is-input-data-last-char.js | 17 + .../src/utils/set-selection-from-dom.js | 83 +-- .../src/utils/set-text-from-dom-node.js | 14 + .../slate-react/src/utils/slate-snapshot.js | 52 ++ 24 files changed, 2105 insertions(+), 84 deletions(-) create mode 100644 examples/composition/index.js create mode 100644 examples/composition/insert.js create mode 100644 examples/composition/special.js create mode 100644 examples/composition/split-join.js create mode 100644 examples/composition/util.js create mode 100644 examples/composition/value.json create mode 100644 packages/slate-react/src/plugins/ANDROID.md create mode 100644 packages/slate-react/src/plugins/android.js create mode 100644 packages/slate-react/src/plugins/debug.js create mode 100644 packages/slate-react/src/utils/android-api-version.js create mode 100644 packages/slate-react/src/utils/closest.js create mode 100644 packages/slate-react/src/utils/element-snapshot.js create mode 100644 packages/slate-react/src/utils/executor.js create mode 100644 packages/slate-react/src/utils/fix-selection-in-zero-width-block.js create mode 100644 packages/slate-react/src/utils/get-selection-from-dom.js create mode 100644 packages/slate-react/src/utils/is-input-data-enter.js create mode 100644 packages/slate-react/src/utils/is-input-data-last-char.js create mode 100644 packages/slate-react/src/utils/slate-snapshot.js diff --git a/examples/app.js b/examples/app.js index c0be11d24..2da89d5cc 100644 --- a/examples/app.js +++ b/examples/app.js @@ -29,6 +29,7 @@ import RTL from './rtl' import ReadOnly from './read-only' import RichText from './rich-text' import SearchHighlighting from './search-highlighting' +import Composition from './composition' import InputTester from './input-tester' import SyncingOperations from './syncing-operations' import Tables from './tables' @@ -43,6 +44,7 @@ import Mentions from './mentions' const EXAMPLES = [ ['Check Lists', CheckLists, '/check-lists'], ['Code Highlighting', CodeHighlighting, '/code-highlighting'], + ['Composition', Composition, '/composition/:subpage?'], ['Embeds', Embeds, '/embeds'], ['Emojis', Emojis, '/emojis'], ['Forced Layout', ForcedLayout, '/forced-layout'], @@ -262,11 +264,13 @@ export default class App extends React.Component { {EXAMPLES.map(([name, Component, path]) => ( -
- - - -
+ {({ match }) => ( +
+ + + +
+ )}
))} @@ -290,7 +294,7 @@ export default class App extends React.Component { {EXAMPLES.map(([name, Component, path]) => ( - + {name} ))} diff --git a/examples/composition/index.js b/examples/composition/index.js new file mode 100644 index 000000000..101b6bd58 --- /dev/null +++ b/examples/composition/index.js @@ -0,0 +1,401 @@ +import { Editor } from 'slate-react' +import { Value } from 'slate' + +import React from 'react' +import styled from 'react-emotion' +import { Link, Redirect } from 'react-router-dom' +import splitJoin from './split-join.js' +import insert from './insert.js' +import special from './special.js' +import { isKeyHotkey } from 'is-hotkey' +import { Button, Icon, Toolbar } from '../components' + +/** + * Define the default node type. + * + * @type {String} + */ + +const DEFAULT_NODE = 'paragraph' + +/** + * Some styled components. + * + * @type {Component} + */ + +const Instruction = styled('div')` + white-space: pre-wrap; + margin: -1em -1em 1em; + padding: 0.5em; + background: #eee; +` + +const Tabs = styled('div')` + margin-bottom: 0.5em; +` + +const TabLink = ({ active, ...props }) => + +const Tab = styled(TabLink)` + display: inline-block; + text-decoration: none; + color: black; + background: ${p => (p.active ? '#AAA' : '#DDD')}; + padding: 0.25em 0.5em; + border-radius: 0.25em; + margin-right: 0.25em; +` + +/** + * Subpages which are each a smoke test. + * + * @type {Array} + */ + +const SUBPAGES = [ + ['Split/Join', splitJoin, 'split-join'], + ['Insertion', insert, 'insert'], + ['Special', special, 'special'], +] + +/** + * Define hotkey matchers. + * + * @type {Function} + */ + +const isBoldHotkey = isKeyHotkey('mod+b') +const isItalicHotkey = isKeyHotkey('mod+i') +const isUnderlinedHotkey = isKeyHotkey('mod+u') +const isCodeHotkey = isKeyHotkey('mod+`') + +/** + * The rich text example. + * + * @type {Component} + */ + +class RichTextExample extends React.Component { + state = {} + + /** + * Select and deserialize the initial editor value. + * + * @param {Object} nextProps + * @param {Object} prevState + * @return {Object} + */ + + static getDerivedStateFromProps(nextProps, prevState) { + const { subpage } = nextProps.params + if (subpage === prevState.subpage) return null + const found = SUBPAGES.find( + ([name, value, iSubpage]) => iSubpage === subpage + ) + if (found == null) return {} + const { text, document } = found[1] + return { + subpage, + text, + value: Value.fromJSON({ document }), + } + } + + /** + * Check if the current selection has a mark with `type` in it. + * + * @param {String} type + * @return {Boolean} + */ + + hasMark = type => { + const { value } = this.state + return value.activeMarks.some(mark => mark.type == type) + } + + /** + * Check if the any of the currently selected blocks are of `type`. + * + * @param {String} type + * @return {Boolean} + */ + + hasBlock = type => { + const { value } = this.state + return value.blocks.some(node => node.type == type) + } + + /** + * Store a reference to the `editor`. + * + * @param {Editor} editor + */ + + ref = editor => { + this.editor = editor + } + + /** + * Render. + * + * @return {Element} + */ + + render() { + const { text } = this.state + if (text == null) return + return ( +
+ + + {SUBPAGES.map(([name, Component, subpage]) => { + const active = subpage === this.props.params.subpage + return ( + + {name} + + ) + })} + +
{this.state.text}
+
+ + {this.renderMarkButton('bold', 'format_bold')} + {this.renderMarkButton('italic', 'format_italic')} + {this.renderMarkButton('underlined', 'format_underlined')} + {this.renderMarkButton('code', 'code')} + {this.renderBlockButton('heading-one', 'looks_one')} + {this.renderBlockButton('heading-two', 'looks_two')} + {this.renderBlockButton('block-quote', 'format_quote')} + {this.renderBlockButton('numbered-list', 'format_list_numbered')} + {this.renderBlockButton('bulleted-list', 'format_list_bulleted')} + + +
+ ) + } + + /** + * Render a mark-toggling toolbar button. + * + * @param {String} type + * @param {String} icon + * @return {Element} + */ + + renderMarkButton = (type, icon) => { + const isActive = this.hasMark(type) + + return ( + + ) + } + + /** + * Render a block-toggling toolbar button. + * + * @param {String} type + * @param {String} icon + * @return {Element} + */ + + renderBlockButton = (type, icon) => { + let isActive = this.hasBlock(type) + + if (['numbered-list', 'bulleted-list'].includes(type)) { + const { value: { document, blocks } } = this.state + + if (blocks.size > 0) { + const parent = document.getParent(blocks.first().key) + isActive = this.hasBlock('list-item') && parent && parent.type === type + } + } + + return ( + + ) + } + + /** + * Render a Slate node. + * + * @param {Object} props + * @return {Element} + */ + + renderNode = (props, editor, next) => { + const { attributes, children, node } = props + + switch (node.type) { + case 'block-quote': + return
{children}
+ case 'bulleted-list': + return
    {children}
+ case 'heading-one': + return

{children}

+ case 'heading-two': + return

{children}

+ case 'list-item': + return
  • {children}
  • + case 'numbered-list': + return
      {children}
    + default: + return next() + } + } + + /** + * Render a Slate mark. + * + * @param {Object} props + * @return {Element} + */ + + renderMark = (props, editor, next) => { + const { children, mark, attributes } = props + + switch (mark.type) { + case 'bold': + return {children} + case 'code': + return {children} + case 'italic': + return {children} + case 'underlined': + return {children} + default: + return next() + } + } + + /** + * On change, save the new `value`. + * + * @param {Editor} editor + */ + + onChange = ({ value }) => { + this.setState({ value }) + } + + /** + * On key down, if it's a formatting command toggle a mark. + * + * @param {Event} event + * @param {Editor} editor + * @return {Change} + */ + + onKeyDown = (event, editor, next) => { + let mark + + if (isBoldHotkey(event)) { + mark = 'bold' + } else if (isItalicHotkey(event)) { + mark = 'italic' + } else if (isUnderlinedHotkey(event)) { + mark = 'underlined' + } else if (isCodeHotkey(event)) { + mark = 'code' + } else { + return next() + } + + event.preventDefault() + editor.toggleMark(mark) + } + + /** + * When a mark button is clicked, toggle the current mark. + * + * @param {Event} event + * @param {String} type + */ + + onClickMark = (event, type) => { + event.preventDefault() + this.editor.toggleMark(type) + } + + /** + * When a block button is clicked, toggle the block type. + * + * @param {Event} event + * @param {String} type + */ + + onClickBlock = (event, type) => { + event.preventDefault() + + const { editor } = this + const { value } = editor + const { document } = value + + // Handle everything but list buttons. + if (type != 'bulleted-list' && type != 'numbered-list') { + const isActive = this.hasBlock(type) + const isList = this.hasBlock('list-item') + + if (isList) { + editor + .setBlocks(isActive ? DEFAULT_NODE : type) + .unwrapBlock('bulleted-list') + .unwrapBlock('numbered-list') + } else { + editor.setBlocks(isActive ? DEFAULT_NODE : type) + } + } else { + // Handle the extra wrapping required for list buttons. + const isList = this.hasBlock('list-item') + const isType = value.blocks.some(block => { + return !!document.getClosest(block.key, parent => parent.type == type) + }) + + if (isList && isType) { + editor + .setBlocks(DEFAULT_NODE) + .unwrapBlock('bulleted-list') + .unwrapBlock('numbered-list') + } else if (isList) { + editor + .unwrapBlock( + type == 'bulleted-list' ? 'numbered-list' : 'bulleted-list' + ) + .wrapBlock(type) + } else { + editor.setBlocks('list-item').wrapBlock(type) + } + } + } +} + +/** + * Export. + */ + +export default RichTextExample diff --git a/examples/composition/insert.js b/examples/composition/insert.js new file mode 100644 index 000000000..c38569931 --- /dev/null +++ b/examples/composition/insert.js @@ -0,0 +1,15 @@ +import { p, text, bold } from './util' + +export default { + text: `Enter text below each line of instruction exactly including mis-spelling wasnt`, + document: { + nodes: [ + p(bold('Tap on virtual keyboard: '), text('It wasnt me. No.')), + p(), + p(bold('Gesture write: '), text('Yes Sam, I am.')), + p(), + p(bold('If you have IME, write any two words with it')), + p(), + ], + }, +} diff --git a/examples/composition/special.js b/examples/composition/special.js new file mode 100644 index 000000000..fab03c65d --- /dev/null +++ b/examples/composition/special.js @@ -0,0 +1,23 @@ +import { p, bold } from './util' + +export default { + text: `Follow the instructions on each line exactly`, + document: { + nodes: [ + p(bold('Type "it is". cursor to "i|t" then hit enter.')), + p(''), + p( + bold( + 'Cursor to "mid|dle" then press space, backspace, space, backspace. Should say "middle".' + ) + ), + p('The middle word.'), + p( + bold( + 'Cursor in line below. Wait for caps on keyboard to show up. If not try again. Type "It me. No." and it should not mangle on the last period.' + ) + ), + p(''), + ], + }, +} diff --git a/examples/composition/split-join.js b/examples/composition/split-join.js new file mode 100644 index 000000000..a72fabc99 --- /dev/null +++ b/examples/composition/split-join.js @@ -0,0 +1,18 @@ +import { p, text, bold } from './util' + +export default { + text: `Hit enter x2 then backspace x2 before word "before", after "after", and in "middle" between two "d"s`, + document: { + nodes: [ + p( + text('Before it before it '), + bold('before'), + text(' it middle it '), + bold('middle'), + text(' it after it '), + bold('after'), + text(' it after') + ), + ], + }, +} diff --git a/examples/composition/util.js b/examples/composition/util.js new file mode 100644 index 000000000..6facdb4f6 --- /dev/null +++ b/examples/composition/util.js @@ -0,0 +1,15 @@ +export function p(...leaves) { + return { + object: 'block', + type: 'paragraph', + nodes: [{ object: 'text', leaves }], + } +} + +export function text(textContent) { + return { text: textContent } +} + +export function bold(textContent) { + return { text: textContent, marks: [{ type: 'bold' }] } +} diff --git a/examples/composition/value.json b/examples/composition/value.json new file mode 100644 index 000000000..8817f5880 --- /dev/null +++ b/examples/composition/value.json @@ -0,0 +1,119 @@ +{ + "document": { + "nodes": [ + { + "object": "block", + "type": "paragraph", + "nodes": [ + { + "object": "text", + "leaves": [ + { "text": "Insert Text: ", "marks": [{ "type": "bold" }] }, + { + "text": + "Type 'cat' before every word 'before' and after every word 'after' and in the middle of the word 'pion' so that it says 'pi cat on' using the virtual keyboard", + "marks": [{ "type": "italic" }] + } + ] + } + ] + }, + { + "object": "block", + "type": "paragraph", + "nodes": [ + { + "object": "text", + "leaves": [ + { + "text": "Before there before is pion at after for after" + } + ] + } + ] + }, + { + "object": "block", + "type": "paragraph", + "nodes": [ + { + "object": "text", + "leaves": [ + { "text": "Handle Enter: ", "marks": [{ "type": "bold" }] }, + { + "text": + "Hit Enter twice before every word 'before' and after every word 'after' and in the middle of the word 'split'", + "marks": [{ "type": "italic" }] + } + ] + } + ] + }, + { + "object": "block", + "type": "paragraph", + "nodes": [ + { + "object": "text", + "leaves": [ + { + "text": "Before there before is split at after for after" + } + ] + } + ] + }, + { + "object": "block", + "type": "paragraph", + "nodes": [ + { + "object": "text", + "leaves": [ + { + "text": + "Since it's rich text, you can do things like turn a selection of text " + }, + { + "text": "bold", + "marks": [{ "type": "bold" }] + }, + { + "text": + ", or add a semantically rendered block quote in the middle of the page, like this:" + } + ] + } + ] + }, + { + "object": "block", + "type": "block-quote", + "nodes": [ + { + "object": "text", + "leaves": [ + { + "text": "A wise quote." + } + ] + } + ] + }, + { + "object": "block", + "type": "paragraph", + "nodes": [ + { + "object": "text", + "leaves": [ + { + "text": "Try it out for yourself!" + } + ] + } + ] + } + ] + } +} diff --git a/package.json b/package.json index 70aa82c92..61508f9f5 100644 --- a/package.json +++ b/package.json @@ -93,8 +93,10 @@ "bootstrap": "lerna bootstrap && yarn build", "build": "rollup --config ./support/rollup/config.js", "build:production": "cross-env NODE_ENV=production rollup --config ./support/rollup/config.js && cross-env NODE_ENV=production webpack --config support/webpack/config.js", + "build:clean-fork": "rm ./build/CNAME", "clean": "lerna run clean && rm -rf ./node_modules ./dist ./build", "gh-pages": "gh-pages --dist ./build", + "gh-pages:fork": "npm-run-all build:production build:clean-fork gh-pages", "lint": "yarn lint:eslint && yarn lint:prettier", "lint:eslint": "eslint benchmark packages/*/src packages/*/test examples/*/*.js examples/dev/*/*.js", "lint:prettier": "prettier --list-different '**/*.{md,json,css}'", diff --git a/packages/slate-react/src/components/content.js b/packages/slate-react/src/components/content.js index fa1451616..2fe5fc448 100644 --- a/packages/slate-react/src/components/content.js +++ b/packages/slate-react/src/components/content.js @@ -4,7 +4,12 @@ import Types from 'prop-types' import getWindow from 'get-window' import warning from 'tiny-warning' import throttle from 'lodash/throttle' -import { IS_FIREFOX, HAS_INPUT_EVENTS_LEVEL_2 } from 'slate-dev-environment' +import { + IS_ANDROID, + IS_FIREFOX, + HAS_INPUT_EVENTS_LEVEL_2, +} from 'slate-dev-environment' +import ANDROID_API_VERSION from '../utils/android-api-version' import EVENT_HANDLERS from '../constants/event-handlers' import Node from './node' @@ -24,6 +29,15 @@ const FIREFOX_NODE_TYPE_ACCESS_ERROR = /Permission denied to access property "no const debug = Debug('slate:content') +/** + * Separate debug to easily see when the DOM has updated either by render or + * changing selection. + * + * @type {Function} + */ + +debug.update = Debug('slate:update') + /** * Content. * @@ -99,7 +113,7 @@ class Content extends React.Component { // COMPAT: Restrict scope of `beforeinput` to clients that support the // Input Events Level 2 spec, since they are preventable events. - if (HAS_INPUT_EVENTS_LEVEL_2) { + if (HAS_INPUT_EVENTS_LEVEL_2 || ANDROID_API_VERSION === 28) { this.element.addEventListener('beforeinput', this.handlers.onBeforeInput) } @@ -120,7 +134,7 @@ class Content extends React.Component { ) } - if (HAS_INPUT_EVENTS_LEVEL_2) { + if (HAS_INPUT_EVENTS_LEVEL_2 || ANDROID_API_VERSION === 28) { this.element.removeEventListener( 'beforeinput', this.handlers.onBeforeInput @@ -133,6 +147,14 @@ class Content extends React.Component { */ componentDidUpdate() { + debug.update('componentDidUpdate') + // NOTE: + // Don't disable `updateSelection` on Android. Clicking a word and a + // suggestion breaks on API27. It does fix the crazy jumping cursor loop + // when doing an auto-suggest on a fully enclosed text with bold though. + // Most likely it still needs other fix issues though. + // + // if (IS_ANDROID) return this.updateSelection() } @@ -148,6 +170,7 @@ class Content extends React.Component { const window = getWindow(this.element) const native = window.getSelection() const { activeElement } = window.document + debug.update('updateSelection', { selection: selection.toJSON() }) // COMPAT: In Firefox, there's a but where `getSelection` can return `null`. // https://bugzilla.mozilla.org/show_bug.cgi?id=827585 (2018/11/07) @@ -260,6 +283,7 @@ class Content extends React.Component { if (updated) { debug('updateSelection', { selection, native, activeElement }) + debug.update('updateSelection-applied', { selection }) } } @@ -339,7 +363,12 @@ class Content extends React.Component { // 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') { + // + // ANDROID: The updateSelection causes issues in Android when you are + // at the end of a black. The selection ends up to the left of the inserted + // character instead of to the right. This behavior continues even if + // you enter more than one character. (2019/01/03) + if (!IS_ANDROID && handler == 'onSelect') { const { editor } = this.props const { value } = editor const { selection } = value @@ -461,6 +490,12 @@ class Content extends React.Component { debug('render', { props }) + debug.update('render', { + text: value.document.text, + selection: value.selection.toJSON(), + value: value.toJSON(), + }) + 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.js b/packages/slate-react/src/plugins/android.js new file mode 100644 index 000000000..2c57bec1d --- /dev/null +++ b/packages/slate-react/src/plugins/android.js @@ -0,0 +1,624 @@ +import Debug from 'debug' +import getWindow from 'get-window' +import pick from 'lodash/pick' + +import API_VERSION from '../utils/android-api-version' +import fixSelectionInZeroWidthBlock from '../utils/fix-selection-in-zero-width-block' +import getSelectionFromDom from '../utils/get-selection-from-dom' +import setSelectionFromDom from '../utils/set-selection-from-dom' +import setTextFromDomNode from '../utils/set-text-from-dom-node' +import isInputDataEnter from '../utils/is-input-data-enter' +import isInputDataLastChar from '../utils/is-input-data-last-char' +import SlateSnapshot from '../utils/slate-snapshot' +import Executor from '../utils/executor' + +const debug = Debug('slate:android') +debug.reconcile = Debug('slate:reconcile') + +debug('API_VERSION', { API_VERSION }) + +/** + * Define variables related to composition state. + */ + +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 {SlateSnapshot} [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 {SlateSnapshot} + */ + + 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() + + nodes.forEach(node => { + setTextFromDomNode(window, editor, node) + }) + + setSelectionFromDom(window, editor, domSelection) + 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 (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.key, 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 (API_VERSION) { + case 26: + case 27: + compositionEndSnapshot = new SlateSnapshot(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 (API_VERSION) { + case 24: + case 25: + break + case 26: + case 27: + case 28: + const { nativeEvent } = event + + if (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 (API_VERSION === 26 || 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 (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 (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.key, 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 SlateSnapshot(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 SlateSnapshot(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 (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, + } +} + +/** + * Export. + * + * @type {Function} + */ + +export default AndroidPlugin diff --git a/packages/slate-react/src/plugins/debug.js b/packages/slate-react/src/plugins/debug.js new file mode 100644 index 000000000..1b6aee4d8 --- /dev/null +++ b/packages/slate-react/src/plugins/debug.js @@ -0,0 +1,64 @@ +import Debug from 'debug' + +/** + * A plugin that adds the "before" browser-specific logic to the editor. + * + * @return {Object} + */ + +function DebugPlugin(namespace) { + /** + * Debug. + * + * @type {Function} + */ + + const debug = Debug(namespace) + + const events = [ + 'onBeforeInput', + 'onBlur', + 'onClick', + 'onCompositionEnd', + 'onCompositionStart', + 'onCopy', + 'onCut', + 'onDragEnd', + 'onDragEnter', + 'onDragExit', + 'onDragLeave', + 'onDragOver', + 'onDragStart', + 'onDrop', + 'onFocus', + 'onInput', + 'onKeyDown', + 'onPaste', + 'onSelect', + ] + + const plugin = {} + + for (const eventName of events) { + plugin[eventName] = function(event, editor, next) { + debug(eventName, { event }) + next() + } + } + + /** + * Return the plugin. + * + * @type {Object} + */ + + return plugin +} + +/** + * Export. + * + * @type {Function} + */ + +export default DebugPlugin diff --git a/packages/slate-react/src/plugins/dom.js b/packages/slate-react/src/plugins/dom.js index f32e10eab..3ea5ff0f1 100644 --- a/packages/slate-react/src/plugins/dom.js +++ b/packages/slate-react/src/plugins/dom.js @@ -1,3 +1,6 @@ +import { IS_ANDROID } from 'slate-dev-environment' +import AndroidPlugin from './android' +import DebugPlugin from './debug' import AfterPlugin from './after' import BeforePlugin from './before' @@ -10,9 +13,15 @@ import BeforePlugin from './before' function DOMPlugin(options = {}) { const { plugins = [] } = options + // 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(), DebugPlugin('slate:debug')] + : [] const beforePlugin = BeforePlugin() const afterPlugin = AfterPlugin() - return [beforePlugin, ...plugins, afterPlugin] + return [...beforeBeforePlugins, beforePlugin, ...plugins, afterPlugin] } /** diff --git a/packages/slate-react/src/utils/android-api-version.js b/packages/slate-react/src/utils/android-api-version.js new file mode 100644 index 000000000..6c405bfcd --- /dev/null +++ b/packages/slate-react/src/utils/android-api-version.js @@ -0,0 +1,49 @@ +import { IS_ANDROID } from 'slate-dev-environment' + +/** + * Array of regular expression matchers and their API version + * + * @type {Array} + */ + +const ANDROID_API_VERSIONS = [ + [/^9([.]0|)/, 28], + [/^8[.]1/, 27], + [/^8([.]0|)/, 26], + [/^7[.]1/, 25], + [/^7([.]0|)/, 24], + [/^6([.]0|)/, 23], + [/^5[.]1/, 22], + [/^5([.]0|)/, 21], + [/^4[.]4/, 20], +] + +/** + * get the Android API version from the userAgent + * + * @return {number} version + */ + +function getApiVersion() { + if (!IS_ANDROID) return null + const { userAgent } = window.navigator + const matchData = userAgent.match(/Android\s([0-9\.]+)/) + if (matchData == null) return null + const versionString = matchData[1] + + for (const tuple of ANDROID_API_VERSIONS) { + const [regex, version] = tuple + if (versionString.match(regex)) return version + } + return null +} + +const API_VERSION = getApiVersion() + +/** + * Export. + * + * type {number} + */ + +export default API_VERSION diff --git a/packages/slate-react/src/utils/closest.js b/packages/slate-react/src/utils/closest.js new file mode 100644 index 000000000..89b455c36 --- /dev/null +++ b/packages/slate-react/src/utils/closest.js @@ -0,0 +1,16 @@ +/** + * Returns the closest element that matches the selector. + * Unlike the native `Element.closest` method, this doesn't require the + * starting node to be an Element. + * + * @param {Node} node to start at + * @param {String} css selector to match + * @return {Element} the closest matching element + */ + +export default function closest(node, selector, win = window) { + if (node.nodeType === win.Node.TEXT_NODE) { + node = node.parentNode + } + return node.closest(selector) +} diff --git a/packages/slate-react/src/utils/element-snapshot.js b/packages/slate-react/src/utils/element-snapshot.js new file mode 100644 index 000000000..0a53e13e3 --- /dev/null +++ b/packages/slate-react/src/utils/element-snapshot.js @@ -0,0 +1,164 @@ +import getWindow from 'get-window' + +/** + * Is the given node a text node? + * + * @param {node} node + * @param {Window} window + * @return {Boolean} + */ + +function isTextNode(node, window) { + return node.nodeType === window.Node.TEXT_NODE +} + +/** + * Takes a node and returns a snapshot of the node. + * + * @param {node} node + * @param {Window} window + * @return {object} element snapshot + */ + +function getElementSnapshot(node, window) { + const snapshot = {} + snapshot.node = node + + if (isTextNode(node, window)) { + snapshot.text = node.textContent + } + + snapshot.children = Array.from(node.childNodes).map(childNode => + getElementSnapshot(childNode, window) + ) + return snapshot +} + +/** + * Takes an array of elements and returns a snapshot + * + * @param {elements[]} elements + * @param {Window} window + * @return {object} snapshot + */ + +function getSnapshot(elements, window) { + if (!elements.length) throw new Error(`elements must be an Array`) + + const lastElement = elements[elements.length - 1] + const snapshot = { + elements: elements.map(element => getElementSnapshot(element, window)), + parent: lastElement.parentElement, + next: lastElement.nextElementSibling, + } + return snapshot +} + +/** + * Takes an element snapshot and applies it to the element in the DOM. + * Basically, it fixes the DOM to the point in time that the snapshot was + * taken. This will put the DOM back in sync with React. + * + * @param {Object} snapshot + * @param {Window} window + */ + +function applyElementSnapshot(snapshot, window) { + const el = snapshot.node + + if (isTextNode(el, window)) { + // Update text if it is different + if (el.textContent !== snapshot.text) { + el.textContent = snapshot.text + } + } + + snapshot.children.forEach(childSnapshot => { + applyElementSnapshot(childSnapshot, window) + el.appendChild(childSnapshot.node) + }) + + // remove children that shouldn't be there + const snapLength = snapshot.children.length + + while (el.childNodes.length > snapLength) { + el.removeChild(el.childNodes[0]) + } + + // remove any clones from the DOM. This can happen when a block is split. + const { dataset } = el + if (!dataset) return // if there's no dataset, don't remove it + const key = dataset.key + if (!key) return // if there's no `data-key`, don't remove it + const dups = new window.Set( + Array.from(window.document.querySelectorAll(`[data-key='${key}']`)) + ) + dups.delete(el) + dups.forEach(dup => dup.parentElement.removeChild(dup)) +} + +/** + * Takes a snapshot and applies it to the DOM. Rearranges both the contents + * of the elements in the snapshot as well as putting the elements back into + * position relative to each other and also makes sure the last element is + * before the same element as it was when the snapshot was taken. + * + * @param {snapshot} snapshot + * @param {Window} window + */ + +function applySnapshot(snapshot, window) { + const { elements, next, parent } = snapshot + elements.forEach(element => applyElementSnapshot(element, window)) + const lastElement = elements[elements.length - 1].node + + if (snapshot.next) { + parent.insertBefore(lastElement, next) + } else { + parent.appendChild(lastElement) + } + + let prevElement = lastElement + + for (let i = elements.length - 2; i >= 0; i--) { + const element = elements[i].node + parent.insertBefore(element, prevElement) + prevElement = element + } +} + +/** + * A snapshot of one or more elements. + */ + +export default class ElementSnapshot { + /** + * constructor + * @param {elements[]} elements - array of element to snapshot. Must be in order. + * @param {object} data - any arbitrary data you want to store with the snapshot + */ + + constructor(elements, data) { + this.window = getWindow(elements[0]) + this.snapshot = getSnapshot(elements, this.window) + this.data = data + } + + /** + * apply the current snapshot to the DOM. + */ + + apply() { + applySnapshot(this.snapshot, this.window) + } + + /** + * get the data you passed into the constructor. + * + * @return {object} data + */ + + getData() { + return this.data + } +} diff --git a/packages/slate-react/src/utils/executor.js b/packages/slate-react/src/utils/executor.js new file mode 100644 index 000000000..483298e9e --- /dev/null +++ b/packages/slate-react/src/utils/executor.js @@ -0,0 +1,96 @@ +/** + * A function that does nothing + * @return {Function} + */ + +function noop() {} + +/** + * Creates an executor like a `resolver` or a `deleter` that handles + * delayed execution of a method using a `requestAnimationFrame` or `setTimeout`. + * + * Unlike a `requestAnimationFrame`, after a method is cancelled, it can be + * resumed. You can also optionally add a `timeout` after which time the + * executor is automatically cancelled. + */ + +export default class Executor { + /** + * Executor + * @param {window} window + * @param {Function} fn - the function to execute when done + * @param {Object} options + */ + + constructor(window, fn, options = {}) { + this.fn = fn + this.window = window + this.resume() + this.onCancel = options.onCancel + this.__setTimeout__(options.timeout) + } + + __call__ = () => { + // I don't clear the timeout since it will be noop'ed anyways. Less code. + this.fn() + this.preventFurtherCalls() // Ensure you can only call the function once + } + + /** + * Make sure that the function cannot be executed any more, even if other + * methods attempt to call `__call__`. + */ + + preventFurtherCalls = () => { + this.fn = noop + } + + /** + * Resume the executor's timer, usually after it has been cancelled. + * + * @param {Number} [ms] - how long to wait by default it is until next frame + */ + + resume = ms => { + // in case resume is called more than once, we don't want old timers + // from executing because the `timeoutId` or `callbackId` is overwritten. + this.cancel() + + if (ms) { + this.mode = 'timeout' + this.timeoutId = this.window.setTimeout(this.__call__, ms) + } else { + this.mode = 'animationFrame' + this.callbackId = this.window.requestAnimationFrame(this.__call__) + } + } + + /** + * Cancel the executor from executing after the wait. This can be resumed + * with the `resume` method. + */ + + cancel = () => { + if (this.mode === 'timeout') { + this.window.clearTimeout(this.timeoutId) + } else { + this.window.cancelAnimationFrame(this.callbackId) + } + + if (this.onCancel) this.onCancel() + } + + /** + * Sets a timeout after which this executor is automatically cancelled. + * @param {Number} ms + */ + + __setTimeout__ = timeout => { + if (timeout == null) return + + this.window.setTimeout(() => { + this.cancel() + this.preventFurtherCalls() + }, timeout) + } +} diff --git a/packages/slate-react/src/utils/fix-selection-in-zero-width-block.js b/packages/slate-react/src/utils/fix-selection-in-zero-width-block.js new file mode 100644 index 000000000..81a33e82f --- /dev/null +++ b/packages/slate-react/src/utils/fix-selection-in-zero-width-block.js @@ -0,0 +1,32 @@ +/** + * 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/utils/get-selection-from-dom.js b/packages/slate-react/src/utils/get-selection-from-dom.js new file mode 100644 index 000000000..6f7a182a3 --- /dev/null +++ b/packages/slate-react/src/utils/get-selection-from-dom.js @@ -0,0 +1,77 @@ +import findRange from './find-range' + +export default function getSelectionFromDOM(window, editor, domSelection) { + const { value } = editor + const { document } = value + + // If there are no ranges, the editor was blurred natively. + if (!domSelection.rangeCount) { + editor.blur() + return + } + + // Otherwise, determine the Slate selection from the native one. + let range = findRange(domSelection, editor) + + if (!range) { + return + } + + const { anchor, focus } = range + const anchorText = document.getNode(anchor.key) + const focusText = document.getNode(focus.key) + const anchorInline = document.getClosestInline(anchor.key) + const focusInline = document.getClosestInline(focus.key) + const focusBlock = document.getClosestBlock(focus.key) + const anchorBlock = document.getClosestBlock(anchor.key) + + // 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 && + !editor.isVoid(anchorBlock) && + anchor.offset == 0 && + focusBlock && + editor.isVoid(focusBlock) && + focus.offset != 0 + ) { + range = range.setFocus(focus.setOffset(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 && + !editor.isVoid(anchorInline) && + anchor.offset == anchorText.text.length + ) { + const block = document.getClosestBlock(anchor.key) + const nextText = block.getNextText(anchor.key) + if (nextText) range = range.moveAnchorTo(nextText.key, 0) + } + + if ( + focusInline && + !editor.isVoid(focusInline) && + focus.offset == focusText.text.length + ) { + const block = document.getClosestBlock(focus.key) + const nextText = block.getNextText(focus.key) + if (nextText) range = range.moveFocusTo(nextText.key, 0) + } + + let selection = document.createSelection(range) + selection = selection.setIsFocused(true) + + // Preserve active marks from the current selection. + // They will be cleared by `editor.select` if the selection actually moved. + selection = selection.set('marks', value.selection.marks) + + return selection +} diff --git a/packages/slate-react/src/utils/is-input-data-enter.js b/packages/slate-react/src/utils/is-input-data-enter.js new file mode 100644 index 000000000..7384429e4 --- /dev/null +++ b/packages/slate-react/src/utils/is-input-data-enter.js @@ -0,0 +1,19 @@ +/** + * 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/utils/is-input-data-last-char.js b/packages/slate-react/src/utils/is-input-data-last-char.js new file mode 100644 index 000000000..f9635c1e7 --- /dev/null +++ b/packages/slate-react/src/utils/is-input-data-last-char.js @@ -0,0 +1,17 @@ +/** + * 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/utils/set-selection-from-dom.js b/packages/slate-react/src/utils/set-selection-from-dom.js index 301d0fdde..90b6b412d 100644 --- a/packages/slate-react/src/utils/set-selection-from-dom.js +++ b/packages/slate-react/src/utils/set-selection-from-dom.js @@ -1,77 +1,14 @@ -import findRange from './find-range' +import getSelectionFromDOM from './get-selection-from-dom' + +/** + * Looks at the DOM and generates the equivalent Slate Selection. + * + * @param {Window} window + * @param {Editor} editor + * @param {Selection} domSelection - The DOM's selection Object + */ export default function setSelectionFromDOM(window, editor, domSelection) { - const { value } = editor - const { document } = value - - // If there are no ranges, the editor was blurred natively. - if (!domSelection.rangeCount) { - editor.blur() - return - } - - // Otherwise, determine the Slate selection from the native one. - let range = findRange(domSelection, editor) - - if (!range) { - return - } - - const { anchor, focus } = range - const anchorText = document.getNode(anchor.key) - const focusText = document.getNode(focus.key) - const anchorInline = document.getClosestInline(anchor.key) - const focusInline = document.getClosestInline(focus.key) - const focusBlock = document.getClosestBlock(focus.key) - const anchorBlock = document.getClosestBlock(anchor.key) - - // 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 && - !editor.isVoid(anchorBlock) && - anchor.offset == 0 && - focusBlock && - editor.isVoid(focusBlock) && - focus.offset != 0 - ) { - range = range.setFocus(focus.setOffset(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 && - !editor.isVoid(anchorInline) && - anchor.offset == anchorText.text.length - ) { - const block = document.getClosestBlock(anchor.key) - const nextText = block.getNextText(anchor.key) - if (nextText) range = range.moveAnchorTo(nextText.key, 0) - } - - if ( - focusInline && - !editor.isVoid(focusInline) && - focus.offset == focusText.text.length - ) { - const block = document.getClosestBlock(focus.key) - const nextText = block.getNextText(focus.key) - if (nextText) range = range.moveFocusTo(nextText.key, 0) - } - - let selection = document.createSelection(range) - selection = selection.setIsFocused(true) - - // Preserve active marks from the current selection. - // They will be cleared by `editor.select` if the selection actually moved. - selection = selection.set('marks', value.selection.marks) - + const selection = getSelectionFromDOM(window, editor, domSelection) editor.select(selection) } diff --git a/packages/slate-react/src/utils/set-text-from-dom-node.js b/packages/slate-react/src/utils/set-text-from-dom-node.js index e9b7eada0..5a9d3f4d5 100644 --- a/packages/slate-react/src/utils/set-text-from-dom-node.js +++ b/packages/slate-react/src/utils/set-text-from-dom-node.js @@ -1,5 +1,19 @@ import findPoint from './find-point' +/** + * setTextFromDomNode lets us take a domNode and reconcile the text in the + * editor's Document such that it reflects the text in the DOM. This is the + * opposite of what the Editor usually does which takes the Editor's Document + * and React modifies the DOM to match. The purpose of this method is for + * composition changes where we don't know what changes the user made by + * looking at events. Instead we wait until the DOM is in a safe state, we + * read from it, and update the Editor's Document. + * + * @param {Window} window + * @param {Editor} editor + * @param {Node} domNode + */ + export default function setTextFromDomNode(window, editor, domNode) { const point = findPoint(domNode, 0, editor) if (!point) return diff --git a/packages/slate-react/src/utils/slate-snapshot.js b/packages/slate-react/src/utils/slate-snapshot.js new file mode 100644 index 000000000..e485e5b0e --- /dev/null +++ b/packages/slate-react/src/utils/slate-snapshot.js @@ -0,0 +1,52 @@ +import closest from './closest' +import getSelectionFromDom from './get-selection-from-dom' +import ElementSnapshot from './element-snapshot' + +/** + * A SlateSnapshot remembers the state of elements at a given point in time + * and also remembers the state of the Editor at that time as well. + * The state can be applied to the DOM at a time in the future. + */ + +export default class SlateSnapshot { + /** + * Constructor. + * + * @param {Window} window + * @param {Editor} editor + * @param {Boolean} options.before - should we remember the element before the one passed in + */ + + constructor(window, editor, { before = false } = {}) { + const domSelection = window.getSelection() + const { anchorNode } = domSelection + const subrootEl = closest(anchorNode, '[data-slate-editor] > *') + const elements = [subrootEl] + + // The before option is for when we need to take a snapshot of the current + // subroot and the element before when the user hits the backspace key. + if (before) { + const { previousElementSibling } = subrootEl + + if (previousElementSibling) { + elements.unshift(previousElementSibling) + } + } + + this.snapshot = new ElementSnapshot(elements) + this.selection = getSelectionFromDom(window, editor, domSelection) + } + + /** + * Apply the snapshot to the DOM and set the selection in the Editor. + * + * @param {Editor} editor + */ + + apply(editor) { + if (editor == null) throw new Error('editor is required') + const { snapshot, selection } = this + snapshot.apply() + editor.moveTo(selection.anchor.key, selection.anchor.offset) + } +}