Skip to content

Commit

Permalink
Use native beforeinput events to handle text insertion when possible (
Browse files Browse the repository at this point in the history
#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.
  • Loading branch information
rgrove authored and ianstormtaylor committed Oct 16, 2017
1 parent c64673f commit 6378c12
Show file tree
Hide file tree
Showing 4 changed files with 87 additions and 25 deletions.
55 changes: 54 additions & 1 deletion packages/slate-react/src/components/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -96,18 +96,33 @@ 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) {
this.element.focus()
}
}

/**
* When unmounting, remove DOM event listeners.
*/

componentWillUnmount() {
if (SUPPORTED_EVENTS.beforeinput) {
this.element.removeEventListener('beforeinput', this.onNativeBeforeInput)
}
}

/**
* On update, update the selection.
*/
Expand Down Expand Up @@ -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.
*
Expand Down
21 changes: 21 additions & 0 deletions packages/slate-react/src/constants/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -39,6 +49,7 @@ const OS_RULES = [
*/

let BROWSER
const EVENTS = {}
let OS

/**
Expand All @@ -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)
}
}

/**
Expand All @@ -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
30 changes: 9 additions & 21 deletions packages/slate-react/src/plugins/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -68,27 +67,16 @@ function Plugin(options = {}) {

function onBeforeInput(e, data, change) {
debug('onBeforeInput', { data })
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)
}
}
// 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()
change.insertText(e.data)
}

Expand Down
6 changes: 3 additions & 3 deletions packages/slate-react/src/utils/find-range.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 6378c12

Please sign in to comment.