1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-18 05:01:17 +02:00

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.
This commit is contained in:
Ryan Grove
2017-10-16 13:38:23 -07:00
committed by Ian Storm Taylor
parent c64673f7af
commit 6378c12a98
4 changed files with 88 additions and 26 deletions

View File

@@ -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.
*

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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,