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:
@@ -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')
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -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 })
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user