Skip to content

Commit

Permalink
refactor native beforeinput handling (#2063)
Browse files Browse the repository at this point in the history
* refactor native beforeinput handling

* fix lint
  • Loading branch information
ianstormtaylor authored Aug 9, 2018
1 parent a396d01 commit f812816
Show file tree
Hide file tree
Showing 4 changed files with 163 additions and 147 deletions.
104 changes: 59 additions & 45 deletions packages/slate-dev-environment/src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import browser from 'is-in-browser'
import isBrowser from 'is-in-browser'

/**
* Browser matching rules.
Expand All @@ -19,13 +19,16 @@ const BROWSER_RULES = [
['safari', /Version\/([0-9\._]+).*Safari/],
]

/**
* DOM event matching rules.
*
* @type {Array}
*/
let browser

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.
Expand All @@ -41,59 +44,70 @@ const OS_RULES = [
['windows', /windows\s*(?:nt)?\s*([\.\_\d]+)/i],
]

/**
* Define variables to store the result.
*/
let os

let BROWSER
const EVENTS = {}
let OS
if (isBrowser) {
for (const [name, regexp] of OS_RULES) {
if (regexp.test(window.navigator.userAgent)) {
os = name
break
}
}
}

/**
* Run the matchers when in browser.
* Feature matching rules.
*
* @type {Array}
*/

if (browser) {
const { userAgent } = window.navigator
const FEATURE_RULES = [
[
'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
},
],
]

for (const [name, regexp] of BROWSER_RULES) {
if (regexp.test(userAgent)) {
BROWSER = name
break
}
}
const features = []

for (const [name, regexp] of OS_RULES) {
if (regexp.test(userAgent)) {
OS = name
break
if (isBrowser) {
for (const [name, test] of FEATURE_RULES) {
if (test(window)) {
features.push(name)
}
}

const testEl = window.document.createElement('div')
testEl.contentEditable = true

for (const [name, testFn] of EVENT_RULES) {
EVENTS[name] = testFn(testEl)
}
}

/**
* Export.
*
* @type {Object}
* @type {Boolean}
*/

export const IS_CHROME = BROWSER === 'chrome'
export const IS_OPERA = BROWSER === 'opera'
export const IS_FIREFOX = BROWSER === 'firefox'
export const IS_SAFARI = BROWSER === 'safari'
export const IS_IE = BROWSER === 'ie'
export const IS_EDGE = BROWSER === 'edge'
export const IS_CHROME = browser === 'chrome'
export const IS_OPERA = browser === 'opera'
export const IS_FIREFOX = browser === 'firefox'
export const IS_SAFARI = browser === 'safari'
export const IS_IE = browser === 'ie'
export const IS_EDGE = browser === 'edge'

export const IS_ANDROID = OS === 'android'
export const IS_IOS = OS === 'ios'
export const IS_MAC = OS === 'macos'
export const IS_WINDOWS = OS === 'windows'
export const IS_ANDROID = os === 'android'
export const IS_IOS = os === 'ios'
export const IS_MAC = os === 'macos'
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')
96 changes: 7 additions & 89 deletions packages/slate-react/src/components/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@ import Debug from 'debug'
import React from 'react'
import Types from 'prop-types'
import getWindow from 'get-window'
import {
IS_FIREFOX,
IS_IOS,
IS_ANDROID,
SUPPORTED_EVENTS,
} from 'slate-dev-environment'
import { IS_FIREFOX, HAS_INPUT_EVENTS_LEVEL_2 } from 'slate-dev-environment'
import logger from 'slate-dev-logger'
import throttle from 'lodash/throttle'

Expand Down Expand Up @@ -97,9 +92,10 @@ class Content extends React.Component {
this.onNativeSelectionChange
)

// COMPAT: Restrict scope of `beforeinput` to mobile.
if ((IS_IOS || IS_ANDROID) && SUPPORTED_EVENTS.beforeinput) {
this.element.addEventListener('beforeinput', this.onNativeBeforeInput)
// COMPAT: Restrict scope of `beforeinput` to clients that support the
// Input Events Level 2 spec, since they are preventable events.
if (HAS_INPUT_EVENTS_LEVEL_2) {
this.element.addEventListener('beforeinput', this.onBeforeInput)
}

this.updateSelection()
Expand All @@ -119,9 +115,8 @@ class Content extends React.Component {
)
}

// COMPAT: Restrict scope of `beforeinput` to mobile.
if ((IS_IOS || IS_ANDROID) && SUPPORTED_EVENTS.beforeinput) {
this.element.removeEventListener('beforeinput', this.onNativeBeforeInput)
if (HAS_INPUT_EVENTS_LEVEL_2) {
this.element.removeEventListener('beforeinput', this.onBeforeInput)
}
}

Expand Down Expand Up @@ -342,83 +337,6 @@ class Content extends React.Component {
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
* needed to account for React's `onSelect` being non-standard and not firing
Expand Down
92 changes: 90 additions & 2 deletions packages/slate-react/src/plugins/after.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function AfterPlugin() {
let isDraggingInternally = null

/**
* On before input, correct any browser inconsistencies.
* On before input.
*
* @param {Event} event
* @param {Change} change
Expand All @@ -46,8 +46,96 @@ function AfterPlugin() {
function onBeforeInput(event, change, editor) {
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()
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()
change.insertText(event.data)

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

/**
Expand Down
Loading

0 comments on commit f812816

Please sign in to comment.