Skip to content

Commit

Permalink
Revert the removal of isNative
Browse files Browse the repository at this point in the history
Preventing native text input events completely breaks autocorrect,
autocomplete, auto-capitalization, predictive text, and double-space
period insertion on iOS. In order to allow iOS to perform these kinds of
operations, we have to avoid preventing the default event behavior
and allow native input handling whenever possible.

Fixes ianstormtaylor#1176
Fixes ianstormtaylor#1177

See also: ianstormtaylor#1088
  • Loading branch information
rgrove committed Oct 4, 2017
1 parent e0d7122 commit 8b14335
Show file tree
Hide file tree
Showing 6 changed files with 149 additions and 6 deletions.
27 changes: 27 additions & 0 deletions packages/slate-react/src/components/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,32 @@ class Content extends React.Component {
this.tmp.forces = 0
}

/**
* Should the component update?
*
* @param {Object} props
* @return {Boolean}
*/

shouldComponentUpdate = (props) => {
// If the readOnly state has changed, we need to re-render so that
// the cursor will be added or removed again.
if (props.readOnly != this.props.readOnly) return true

// If the state has been changed natively, never re-render, or else we'll
// end up duplicating content.
if (props.state.isNative) return false

return (
props.className != this.props.className ||
props.schema != this.props.schema ||
props.autoCorrect != this.props.autoCorrect ||
props.spellCheck != this.props.spellCheck ||
props.state != this.props.state ||
props.style != this.props.style
)
}

/**
* When the editor first mounts in the DOM we need to:
*
Expand Down Expand Up @@ -698,6 +724,7 @@ class Content extends React.Component {
// If there are no ranges, the editor was blurred natively.
if (!native.rangeCount) {
data.selection = selection.set('isFocused', false)
data.isNative = true
}

// Otherwise, determine the Slate selection from the native one.
Expand Down
29 changes: 28 additions & 1 deletion packages/slate-react/src/components/leaf.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@

import Debug from 'debug'
import React from 'react'
import ReactDOM from 'react-dom'
import Types from 'prop-types'
import SlateTypes from 'slate-prop-types'

import OffsetKey from '../utils/offset-key'
import findDeepestNode from '../utils/find-deepest-node'
import { IS_FIREFOX } from '../constants/environment'

/**
Expand All @@ -23,6 +25,7 @@ const debug = Debug('slate:leaf')

class Leaf extends React.Component {


/**
* Property types.
*
Expand All @@ -43,6 +46,18 @@ class Leaf extends React.Component {
text: Types.string.isRequired,
}

/**
* Constructor.
*
* @param {Object} props
*/

constructor(props) {
super(props)
this.tmp = {}
this.tmp.renders = 0
}

/**
* Debug.
*
Expand Down Expand Up @@ -73,6 +88,12 @@ class Leaf extends React.Component {
return true
}

// If the DOM text does not equal the `text` property, re-render. This can
// happen because React gets out of sync when previously natively rendered.
const el = findDeepestNode(ReactDOM.findDOMNode(this))
const text = this.renderText(props)
if (el.textContent != text) return true

// Otherwise, don't update.
return false
}
Expand All @@ -91,10 +112,16 @@ class Leaf extends React.Component {
index
})

// Increment the renders key, which forces a re-render whenever this
// component is told it should update. This is required because "native"
// renders where we don't update the leaves cause React's internal state to
// get out of sync, causing it to not realize the DOM needs updating.
this.tmp.renders++

this.debug('render', { props })

return (
<span data-offset-key={offsetKey}>
<span key={this.tmp.renders} data-offset-key={offsetKey}>
{this.renderMarks(props)}
</span>
)
Expand Down
84 changes: 79 additions & 5 deletions packages/slate-react/src/plugins/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Debug from 'debug'
import Plain from 'slate-plain-serializer'
import React from 'react'
import getWindow from 'get-window'
import { Block, Inline, coreSchema } from 'slate'
import { Block, Character, Inline, coreSchema } from 'slate'

import Content from '../components/content'
import Placeholder from '../components/placeholder'
Expand Down Expand Up @@ -49,6 +49,10 @@ function Plugin(options = {}) {
const schema = editor.getSchema()
const prevState = editor.getState()

// PERF: Skip normalizing if the change is native, since we know that it
// can't have changed anything that requires a core schema fix.
if (state.isNative) return

// PERF: Skip normalizing if the document hasn't changed, since schemas only
// normalize changes to the document, not selection.
if (prevState && state.document == prevState.document) return
Expand All @@ -68,12 +72,32 @@ function Plugin(options = {}) {
*/

function onBeforeInput(e, data, change, editor) {
debug('onBeforeInput', { data })
e.preventDefault()

const { state } = change
const { selection } = state
const { document, selection, startKey, startBlock, startOffset, startInline, startText } = state
const { anchorKey, anchorOffset, focusKey, focusOffset } = selection
const pText = startBlock.getPreviousText(startKey)
const pInline = pText && startBlock.getClosestInline(pText.key)
const nText = startBlock.getNextText(startKey)
const nInline = nText && startBlock.getClosestInline(nText.key)

// Determine what the characters would be if natively inserted.
const schema = editor.getSchema()
const decorators = document.getDescendantDecorators(startKey, schema)
const initialChars = startText.getDecorations(decorators)
const prevChar = startOffset === 0 ? null : initialChars.get(startOffset - 1)
const nextChar = startOffset === initialChars.size ? null : initialChars.get(startOffset)
const char = Character.create({
text: e.data,
// When cursor is at start of a range of marks, without preceding text,
// the native behavior is to insert inside the range of marks.
marks: (
(prevChar && prevChar.marks) ||
(nextChar && nextChar.marks) ||
[]
)
})

const chars = initialChars.insert(startOffset, char)

// COMPAT: In iOS, when using predictive text suggestions, the native
// selection will be changed to span the existing word, so that the word is
Expand All @@ -100,7 +124,57 @@ function Plugin(options = {}) {
})
}

// Determine what the characters should be, if not natively inserted.
change.insertText(e.data)
const next = change.state
const nextText = next.startText
const nextChars = nextText.getDecorations(decorators)

// We do not have to re-render if the current selection is collapsed, the
// current node is not empty, there are no marks on the cursor, the cursor
// is not at the edge of an inline node, the cursor isn't at the starting
// edge of a text node after an inline node, and the natively inserted
// characters would be the same as the non-native.
const isNative = (
// If the selection is expanded, we don't know what the edit will look
// like so we can't let it happen natively.
(state.isCollapsed) &&
// If the selection has marks, then we need to render it non-natively
// because we need to create the new marks as well.
(state.selection.marks == null) &&
// If the text node in question has no content, browsers might do weird
// things so we need to insert it normally instead.
(state.startText.text != '') &&
// COMPAT: Browsers do weird things when typing at the edges of inline
// nodes, so we can't let them render natively. (?)
(!startInline || !state.selection.isAtStartOf(startInline)) &&
(!startInline || !state.selection.isAtEndOf(startInline)) &&
// COMPAT: In Chrome & Safari, it isn't possible to have a selection at
// the starting edge of a text node after another inline node. It will
// have been automatically changed. So we can't render natively because
// the cursor isn't technically in the right spot. (2016/12/01)
(!(pInline && !pInline.isVoid && startOffset == 0)) &&
(!(nInline && !nInline.isVoid && startOffset == startText.text.length)) &&
// COMPAT: When inserting a Space character, Chrome will sometimes
// split the text node into two adjacent text nodes. See:
// https://github.com/ianstormtaylor/slate/issues/938
(!(e.data === ' ' && IS_CHROME)) &&
(chars.equals(nextChars))
)

// If `isNative`, set the flag on the change.
if (isNative) {
change
.setIsNative(true)
.deleteCharBackward()
}

// Otherwise, prevent default so that the DOM remains untouched.
else {
e.preventDefault()
}

debug('onBeforeInput', { data, isNative })
}

/**
Expand Down
13 changes: 13 additions & 0 deletions packages/slate/src/changes/on-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@

const Changes = {}

/**
* Set the `isNative` flag on the underlying state to prevent re-renders.
*
* @param {Change} change
* @param {Boolean} value
*/

Changes.setIsNative = (change, value) => {
let { state } = change
state = state.set('isNative', value)
change.state = state
}

/**
* Set `properties` on the top-level state's data.
*
Expand Down
1 change: 1 addition & 0 deletions packages/slate/src/models/change.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class Change {
this.state = state
this.operations = []
this.flags = pick(attrs, ['merge', 'save'])
this.setIsNative(attrs.isNative === undefined ? false : attrs.isNative)
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/slate/src/models/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const DEFAULTS = {
selection: Selection.create(),
history: History.create(),
data: new Map(),
isNative: false,
}

/**
Expand Down

0 comments on commit 8b14335

Please sign in to comment.