diff --git a/packages/slate-dev-environment/src/index.js b/packages/slate-dev-environment/src/index.js index 14eb69f74..b6c822d45 100644 --- a/packages/slate-dev-environment/src/index.js +++ b/packages/slate-dev-environment/src/index.js @@ -1,4 +1,4 @@ -import browser from 'is-in-browser' +import isBrowser from 'is-in-browser' /** * Browser matching rules. @@ -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. @@ -41,59 +44,70 @@ const OS_RULES = [ ['windows', /windows\s*(?:nt)?\s*([\.\_\d]+)/i], ] -/** - * 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 - } - } +let os +if (isBrowser) { for (const [name, regexp] of OS_RULES) { - if (regexp.test(userAgent)) { - OS = name + if (regexp.test(window.navigator.userAgent)) { + os = name break } } +} - const testEl = window.document.createElement('div') - testEl.contentEditable = true +/** + * Feature matching rules. + * + * @type {Array} + */ - for (const [name, testFn] of EVENT_RULES) { - EVENTS[name] = testFn(testEl) +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 + }, + ], +] + +const features = [] + +if (isBrowser) { + for (const [name, test] of FEATURE_RULES) { + if (test(window)) { + features.push(name) + } } } /** * 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') diff --git a/packages/slate-react/src/components/content.js b/packages/slate-react/src/components/content.js index f250d27f8..cc09c2583 100644 --- a/packages/slate-react/src/components/content.js +++ b/packages/slate-react/src/components/content.js @@ -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' @@ -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() @@ -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) } } @@ -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 diff --git a/packages/slate-react/src/plugins/after.js b/packages/slate-react/src/plugins/after.js index 71d56b93d..55a26d880 100644 --- a/packages/slate-react/src/plugins/after.js +++ b/packages/slate-react/src/plugins/after.js @@ -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 @@ -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 + } + } } /** diff --git a/packages/slate-react/src/plugins/before.js b/packages/slate-react/src/plugins/before.js index 5c79fc773..4cfdbf210 100644 --- a/packages/slate-react/src/plugins/before.js +++ b/packages/slate-react/src/plugins/before.js @@ -6,8 +6,7 @@ import { IS_FIREFOX, IS_IE, IS_IOS, - IS_ANDROID, - SUPPORTED_EVENTS, + HAS_INPUT_EVENTS_LEVEL_2, } from 'slate-dev-environment' import findNode from '../utils/find-node' @@ -44,15 +43,12 @@ function BeforePlugin() { function onBeforeInput(event, change, editor) { if (editor.props.readOnly) return true - // COMPAT: 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. - // 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 + const isSynthetic = !!event.nativeEvent + + // COMPAT: If the browser supports Input Events Level 2, we will have + // attached a custom handler for the real `beforeinput` events, instead of + // allowing React's synthetic polyfill, so we need to ignore synthetics. + if (isSynthetic && HAS_INPUT_EVENTS_LEVEL_2) return true debug('onBeforeInput', { event }) }