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,