From 6378c12a9875dbbd599e1f99292f7098a5f29a5e Mon Sep 17 00:00:00 2001 From: Ryan Grove Date: Mon, 16 Oct 2017 13:38:23 -0700 Subject: [PATCH] Use native `beforeinput` events to handle text insertion when possible (#1232) * Add support for finding a Slate range from a native StaticRange * Add a `SUPPORTED_EVENTS` environment constant This is an object mapping of DOM event names to booleans indicating whether the browser supports that event. * Use native `beforeinput` events to handle text insertion when possible In browsers that support it (currently only Safari has full support), the native `beforeinput` DOM event provides much more useful information about text insertion than React's synthetic `onBeforeInput` event. By handling text insertion with the native event instead of the synthetic event when possible, we can fully support autocorrect, spellcheck replacements, and related functionality on iOS without resorting to hacks. See the discussion in #1177 for more background on this change. Fixes #1176 Fixes #1177 * Fix lint error. --- .../slate-react/src/components/content.js | 55 ++++++++++++++++++- .../slate-react/src/constants/environment.js | 21 +++++++ packages/slate-react/src/plugins/core.js | 32 ++++------- packages/slate-react/src/utils/find-range.js | 6 +- 4 files changed, 88 insertions(+), 26 deletions(-) diff --git a/packages/slate-react/src/components/content.js b/packages/slate-react/src/components/content.js index e7b21e593..6957a4378 100644 --- a/packages/slate-react/src/components/content.js +++ b/packages/slate-react/src/components/content.js @@ -19,7 +19,7 @@ import getHtmlFromNativePaste from '../utils/get-html-from-native-paste' import getTransferData from '../utils/get-transfer-data' import scrollToSelection from '../utils/scroll-to-selection' import setTransferData from '../utils/set-transfer-data' -import { IS_FIREFOX, IS_MAC, IS_IE } from '../constants/environment' +import { IS_FIREFOX, IS_MAC, IS_IE, SUPPORTED_EVENTS } from '../constants/environment' /** * Debug. @@ -96,11 +96,16 @@ class Content extends React.Component { /** * When the editor first mounts in the DOM we need to: * + * - Add native DOM event listeners. * - Update the selection, in case it starts focused. * - Focus the editor if `autoFocus` is set. */ componentDidMount = () => { + if (SUPPORTED_EVENTS.beforeinput) { + this.element.addEventListener('beforeinput', this.onNativeBeforeInput) + } + this.updateSelection() if (this.props.autoFocus) { @@ -108,6 +113,16 @@ class Content extends React.Component { } } + /** + * When unmounting, remove DOM event listeners. + */ + + componentWillUnmount() { + if (SUPPORTED_EVENTS.beforeinput) { + this.element.removeEventListener('beforeinput', this.onNativeBeforeInput) + } + } + /** * On update, update the selection. */ @@ -226,6 +241,44 @@ class Content extends React.Component { this.props.onBeforeInput(event, data) } + /** + * On a native `beforeinput` event, use the additional range information + * provided by the event to insert text exactly as the browser would. + * + * @param {InputEvent} event + */ + + onNativeBeforeInput = (event) => { + if (this.props.readOnly) return + if (!this.isInEditor(event.target)) return + + const { inputType } = event + if (inputType !== 'insertText' && inputType !== 'insertReplacementText') return + + const [ targetRange ] = event.getTargetRanges() + if (!targetRange) return + + // `data` should have the text for the `insertText` input type and + // `dataTransfer` should have the text for the `insertReplacementText` input + // type, but Safari uses `insertText` for spell check replacements and sets + // `data` to `null`. + const text = event.data == null + ? event.dataTransfer.getData('text/plain') + : event.data + + if (text == null) return + + debug('onNativeBeforeInput', { event, text }) + event.preventDefault() + + const { editor, state } = this.props + const range = findRange(targetRange, state) + + editor.change((change) => { + change.insertTextAtRange(range, text) + }) + } + /** * On blur, update the selection to be not focused. * diff --git a/packages/slate-react/src/constants/environment.js b/packages/slate-react/src/constants/environment.js index 3c4c23e90..0c9b07664 100644 --- a/packages/slate-react/src/constants/environment.js +++ b/packages/slate-react/src/constants/environment.js @@ -20,6 +20,16 @@ const BROWSER_RULES = [ ['safari', /Version\/([0-9\._]+).*Safari/], ] +/** + * DOM event matching rules. + * + * @type {Array} + */ + +const EVENT_RULES = [ + ['beforeinput', el => 'onbeforeinput' in el] +] + /** * Operating system matching rules. * @@ -39,6 +49,7 @@ const OS_RULES = [ */ let BROWSER +const EVENTS = {} let OS /** @@ -63,6 +74,14 @@ if (browser) { break } } + + const testEl = document.createElement('div') + testEl.contentEditable = true + + for (let i = 0; i < EVENT_RULES.length; i++) { + const [ name, testFn ] = EVENT_RULES[i] + EVENTS[name] = testFn(testEl) + } } /** @@ -79,3 +98,5 @@ export const IS_IE = BROWSER === 'ie' export const IS_IOS = OS === 'ios' export const IS_MAC = OS === 'macos' export const IS_WINDOWS = OS === 'windows' + +export const SUPPORTED_EVENTS = EVENTS diff --git a/packages/slate-react/src/plugins/core.js b/packages/slate-react/src/plugins/core.js index 53462fa5c..1f8d5fb18 100644 --- a/packages/slate-react/src/plugins/core.js +++ b/packages/slate-react/src/plugins/core.js @@ -9,8 +9,7 @@ import { Block, Inline, coreSchema } from 'slate' import Content from '../components/content' import Placeholder from '../components/placeholder' import findDOMNode from '../utils/find-dom-node' -import findRange from '../utils/find-range' -import { IS_CHROME, IS_IOS, IS_MAC, IS_SAFARI } from '../constants/environment' +import { IS_CHROME, IS_MAC, IS_SAFARI, SUPPORTED_EVENTS } from '../constants/environment' /** * Debug. @@ -68,27 +67,16 @@ function Plugin(options = {}) { function onBeforeInput(e, data, change) { debug('onBeforeInput', { data }) + + // React's `onBeforeInput` synthetic event is based on the native `keypress` + // and `textInput` events. In browsers that support the native `beforeinput` + // event, we instead use that event to trigger text insertion, since it + // provides more useful information about the range being affected and also + // preserves compatibility with iOS autocorrect, which would be broken if we + // called `preventDefault()` on React's synthetic event here. + if (SUPPORTED_EVENTS.beforeinput) return + e.preventDefault() - - const { state } = change - const { selection } = state - - // COMPAT: In iOS, when using predictive text suggestions, the native - // selection will be changed to span the existing word, so that the word is - // replaced. But the `select` fires after the `beforeInput` event, even - // though the native selection is updated. So we need to manually check if - // the selection has gotten out of sync, and adjust it if so. (10/16/2017) - if (IS_IOS) { - const window = getWindow(e.target) - const native = window.getSelection() - const range = findRange(native, state) - const hasMismatch = range && !range.equals(selection) - - if (hasMismatch) { - change.select(range) - } - } - change.insertText(e.data) } diff --git a/packages/slate-react/src/utils/find-range.js b/packages/slate-react/src/utils/find-range.js index 0838d81c6..5647f3df5 100644 --- a/packages/slate-react/src/utils/find-range.js +++ b/packages/slate-react/src/utils/find-range.js @@ -17,9 +17,9 @@ function findRange(native, state) { const el = native.anchorNode || native.startContainer const window = getWindow(el) - // If the `native` object is a DOM `Range` object, change it into something - // that looks like a DOM `Selection` instead. - if (native instanceof window.Range) { + // If the `native` object is a DOM `Range` or `StaticRange` object, change it + // into something that looks like a DOM `Selection` instead. + if (native instanceof window.Range || (window.StaticRange && native instanceof window.StaticRange)) { native = { anchorNode: native.startContainer, anchorOffset: native.startOffset,