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

refactor native beforeinput handling (#2063)

* refactor native beforeinput handling

* fix lint
This commit is contained in:
Ian Storm Taylor
2018-08-09 01:06:31 -07:00
committed by GitHub
parent a396d013ef
commit f812816b7d
4 changed files with 164 additions and 148 deletions

View File

@@ -1,4 +1,4 @@
import browser from 'is-in-browser' import isBrowser from 'is-in-browser'
/** /**
* Browser matching rules. * Browser matching rules.
@@ -19,13 +19,16 @@ const BROWSER_RULES = [
['safari', /Version\/([0-9\._]+).*Safari/], ['safari', /Version\/([0-9\._]+).*Safari/],
] ]
/** let browser
* DOM event matching rules.
*
* @type {Array}
*/
const EVENT_RULES = [['beforeinput', el => 'onbeforeinput' in el]] if (isBrowser) {
for (const [name, regexp] of BROWSER_RULES) {
if (regexp.test(window.navigator.userAgent)) {
browser = name
break
}
}
}
/** /**
* Operating system matching rules. * Operating system matching rules.
@@ -41,59 +44,70 @@ const OS_RULES = [
['windows', /windows\s*(?:nt)?\s*([\.\_\d]+)/i], ['windows', /windows\s*(?:nt)?\s*([\.\_\d]+)/i],
] ]
/** let os
* Define variables to store the result.
*/
let BROWSER
const EVENTS = {}
let OS
/**
* Run the matchers when in browser.
*/
if (browser) {
const { userAgent } = window.navigator
for (const [name, regexp] of BROWSER_RULES) {
if (regexp.test(userAgent)) {
BROWSER = name
break
}
}
if (isBrowser) {
for (const [name, regexp] of OS_RULES) { for (const [name, regexp] of OS_RULES) {
if (regexp.test(userAgent)) { if (regexp.test(window.navigator.userAgent)) {
OS = name os = name
break break
} }
} }
}
const testEl = window.document.createElement('div') /**
testEl.contentEditable = true * Feature matching rules.
*
* @type {Array}
*/
for (const [name, testFn] of EVENT_RULES) { const FEATURE_RULES = [
EVENTS[name] = testFn(testEl) [
'inputeventslevel1',
window => {
const event = window.InputEvent ? new InputEvent('input') : {}
const support = 'inputType' in event
return support
},
],
[
'inputeventslevel2',
window => {
const element = window.document.createElement('div')
element.contentEditable = true
const support = 'onbeforeinput' in element
return support
},
],
]
const features = []
if (isBrowser) {
for (const [name, test] of FEATURE_RULES) {
if (test(window)) {
features.push(name)
}
} }
} }
/** /**
* Export. * Export.
* *
* @type {Object} * @type {Boolean}
*/ */
export const IS_CHROME = BROWSER === 'chrome' export const IS_CHROME = browser === 'chrome'
export const IS_OPERA = BROWSER === 'opera' export const IS_OPERA = browser === 'opera'
export const IS_FIREFOX = BROWSER === 'firefox' export const IS_FIREFOX = browser === 'firefox'
export const IS_SAFARI = BROWSER === 'safari' export const IS_SAFARI = browser === 'safari'
export const IS_IE = BROWSER === 'ie' export const IS_IE = browser === 'ie'
export const IS_EDGE = BROWSER === 'edge' export const IS_EDGE = browser === 'edge'
export const IS_ANDROID = OS === 'android' export const IS_ANDROID = os === 'android'
export const IS_IOS = OS === 'ios' export const IS_IOS = os === 'ios'
export const IS_MAC = OS === 'macos' export const IS_MAC = os === 'macos'
export const IS_WINDOWS = OS === 'windows' export const IS_WINDOWS = os === 'windows'
export const SUPPORTED_EVENTS = EVENTS export const HAS_INPUT_EVENTS_LEVEL_1 = features.includes('inputeventslevel1')
export const HAS_INPUT_EVENTS_LEVEL_2 = features.includes('inputeventslevel2')

View File

@@ -2,12 +2,7 @@ import Debug from 'debug'
import React from 'react' import React from 'react'
import Types from 'prop-types' import Types from 'prop-types'
import getWindow from 'get-window' import getWindow from 'get-window'
import { import { IS_FIREFOX, HAS_INPUT_EVENTS_LEVEL_2 } from 'slate-dev-environment'
IS_FIREFOX,
IS_IOS,
IS_ANDROID,
SUPPORTED_EVENTS,
} from 'slate-dev-environment'
import logger from 'slate-dev-logger' import logger from 'slate-dev-logger'
import throttle from 'lodash/throttle' import throttle from 'lodash/throttle'
@@ -97,9 +92,10 @@ class Content extends React.Component {
this.onNativeSelectionChange this.onNativeSelectionChange
) )
// COMPAT: Restrict scope of `beforeinput` to mobile. // COMPAT: Restrict scope of `beforeinput` to clients that support the
if ((IS_IOS || IS_ANDROID) && SUPPORTED_EVENTS.beforeinput) { // Input Events Level 2 spec, since they are preventable events.
this.element.addEventListener('beforeinput', this.onNativeBeforeInput) if (HAS_INPUT_EVENTS_LEVEL_2) {
this.element.addEventListener('beforeinput', this.onBeforeInput)
} }
this.updateSelection() this.updateSelection()
@@ -119,9 +115,8 @@ class Content extends React.Component {
) )
} }
// COMPAT: Restrict scope of `beforeinput` to mobile. if (HAS_INPUT_EVENTS_LEVEL_2) {
if ((IS_IOS || IS_ANDROID) && SUPPORTED_EVENTS.beforeinput) { this.element.removeEventListener('beforeinput', this.onBeforeInput)
this.element.removeEventListener('beforeinput', this.onNativeBeforeInput)
} }
} }
@@ -342,83 +337,6 @@ class Content extends React.Component {
this.props[handler](event) this.props[handler](event)
} }
/**
* On a native `beforeinput` event, use the additional range information
* provided by the event to manipulate text exactly as the browser would.
*
* This is currently only used on iOS and Android.
*
* @param {InputEvent} event
*/
onNativeBeforeInput = event => {
if (this.props.readOnly) return
if (!this.isInEditor(event.target)) return
const [targetRange] = event.getTargetRanges()
if (!targetRange) return
const { editor } = this.props
switch (event.inputType) {
case 'deleteContentBackward': {
event.preventDefault()
const range = findRange(targetRange, editor.value)
editor.change(change => change.deleteAtRange(range))
break
}
case 'insertLineBreak': // intentional fallthru
case 'insertParagraph': {
event.preventDefault()
const range = findRange(targetRange, editor.value)
editor.change(change => {
if (change.value.isInVoid) {
change.moveToStartOfNextText()
} else {
change.splitBlockAtRange(range)
}
})
break
}
case 'insertReplacementText': // intentional fallthru
case 'insertText': {
// `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
event.preventDefault()
const { value } = editor
const { selection } = value
const range = findRange(targetRange, value)
editor.change(change => {
change.insertTextAtRange(range, text, selection.marks)
// If the text was successfully inserted, and the selection had marks
// on it, unset the selection's marks.
if (selection.marks && value.document != change.value.document) {
change.select({ marks: null })
}
})
break
}
}
}
/** /**
* On native `selectionchange` event, trigger the `onSelect` handler. This is * On native `selectionchange` event, trigger the `onSelect` handler. This is
* needed to account for React's `onSelect` being non-standard and not firing * needed to account for React's `onSelect` being non-standard and not firing

View File

@@ -36,7 +36,7 @@ function AfterPlugin() {
let isDraggingInternally = null let isDraggingInternally = null
/** /**
* On before input, correct any browser inconsistencies. * On before input.
* *
* @param {Event} event * @param {Event} event
* @param {Change} change * @param {Change} change
@@ -46,8 +46,96 @@ function AfterPlugin() {
function onBeforeInput(event, change, editor) { function onBeforeInput(event, change, editor) {
debug('onBeforeInput', { event }) debug('onBeforeInput', { event })
const isSynthetic = !!event.nativeEvent
// If the event is synthetic, it's React's polyfill of `beforeinput` that
// isn't a true `beforeinput` event with meaningful information. It only
// gets triggered for character insertions, so we can just insert directly.
if (isSynthetic) {
event.preventDefault() event.preventDefault()
change.insertText(event.data) change.insertText(event.data)
return
}
// Otherwise, we can use the information in the `beforeinput` event to
// figure out the exact change that will occur, and prevent it.
const [targetRange] = event.getTargetRanges()
if (!targetRange) return
event.preventDefault()
const { value } = change
const { selection } = value
const range = findRange(targetRange, value)
switch (event.inputType) {
case 'deleteByDrag':
case 'deleteByCut':
case 'deleteContent':
case 'deleteContentBackward':
case 'deleteContentForward': {
change.deleteAtRange(range)
return
}
case 'deleteWordBackward': {
change.deleteWordBackwardAtRange(range)
return
}
case 'deleteWordForward': {
change.deleteWordForwardAtRange(range)
return
}
case 'deleteSoftLineBackward':
case 'deleteHardLineBackward': {
change.deleteLineBackwardAtRange(range)
return
}
case 'deleteSoftLineForward':
case 'deleteHardLineForward': {
change.deleteLineForwardAtRange(range)
return
}
case 'insertLineBreak':
case 'insertParagraph': {
if (change.value.isInVoid) {
change.moveToStartOfNextText()
} else {
change.splitBlockAtRange(range)
}
return
}
case 'insertFromYank':
case 'insertReplacementText':
case 'insertText': {
// COMPAT: `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`. (2018/08/09)
const text =
event.data == null
? event.dataTransfer.getData('text/plain')
: event.data
if (text == null) return
change.insertTextAtRange(range, text, selection.marks)
// If the text was successfully inserted, and the selection had marks
// on it, unset the selection's marks.
if (selection.marks && value.document != change.value.document) {
change.select({ marks: null })
}
return
}
}
} }
/** /**

View File

@@ -6,8 +6,7 @@ import {
IS_FIREFOX, IS_FIREFOX,
IS_IE, IS_IE,
IS_IOS, IS_IOS,
IS_ANDROID, HAS_INPUT_EVENTS_LEVEL_2,
SUPPORTED_EVENTS,
} from 'slate-dev-environment' } from 'slate-dev-environment'
import findNode from '../utils/find-node' import findNode from '../utils/find-node'
@@ -44,15 +43,12 @@ function BeforePlugin() {
function onBeforeInput(event, change, editor) { function onBeforeInput(event, change, editor) {
if (editor.props.readOnly) return true if (editor.props.readOnly) return true
// COMPAT: React's `onBeforeInput` synthetic event is based on the native const isSynthetic = !!event.nativeEvent
// `keypress` and `textInput` events. In browsers that support the native
// `beforeinput` event, we instead use that event to trigger text insertion, // COMPAT: If the browser supports Input Events Level 2, we will have
// since it provides more useful information about the range being affected // attached a custom handler for the real `beforeinput` events, instead of
// and also preserves compatibility with iOS autocorrect, which would be // allowing React's synthetic polyfill, so we need to ignore synthetics.
// broken if we called `preventDefault()` on React's synthetic event here. if (isSynthetic && HAS_INPUT_EVENTS_LEVEL_2) return true
// Since native `onbeforeinput` mainly benefits autocorrect and spellcheck
// for mobile, on desktop it brings IME issue, limit its scope for now.
if ((IS_IOS || IS_ANDROID) && SUPPORTED_EVENTS.beforeinput) return true
debug('onBeforeInput', { event }) debug('onBeforeInput', { event })
} }