1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-17 12:41:44 +02:00

Add Android 8 and 9 compatibility using Mutations (#2853)

* Add debug-mutations

* Fix linting

* Add debug-mutations to o core plugins

* Fix debug output

* Add comment about debug statement

* Add comment explaining the building of debug output object

* Add framework for mutations and mutation observer

* Working splitBlock and mergeBlock

* many things working but not autocorrect

* All later tests pass

* Before adding isComposing to composition-manager

* Fixit

* fix enter enter backspace backspace

* Pass all tests except space-back-space-back

* Passes all tests I think but doesn't continuous backspace or select delete

* Passes all tests and delete selection work

* Fix for merge

* Passes all tests including typing hello world on new line and enter

* Before switching to a function

* Fix enter after last char

* Fix it wasn't me. no. bug

* Remove timeout delay on compositionEnd and everthing works except it wasnt me. no.

* Passes all tests but need to add tests for delete all and select delete

* Pass all tests

* Fix  remove selection

* Added flush onCompositionEnd just in case

* Fix bugs for Android 8 split join and fix side effects on Android 9 to that fix

* Add comments to composition manager

* Clean up code

* Fix bug with delete range

* Add comments

* Fix focus lost bug on change examples

* Improve comments

* Rename clear to clearAction and a comment

* Rename lastEl to last.rootEl

* Remove isListening

* Rename vars

* Fix bug where changing to new example during a composition messes up update

* Add comment to switching examples in composition fix

* Improve comments

* Refactor

* Refactor removeNode

* Remove unused event callbacks

* Refactor connect

* Cleanup mutation plugin

* Remove unnecessary comments

* Remove readme

* Refactor ReactPlugin

* Refactor plugins and injection locations

* Remove dom-observer

* Remove is-input-data helpers

* Move fixSelectionInZeroWidthBlock

* Fix some linting and also a composition manager bug

* Fix linting and remove placeholder on Android

* Refactor

* Update composition-manager description

* Fix comment on composition manager

Co-Authored-By: Nick Anderson <tetramputechture@gmail.com>
This commit is contained in:
Sunny Hirai
2019-06-12 16:14:00 -07:00
committed by GitHub
parent 5471343ae8
commit 7d4062cde9
13 changed files with 901 additions and 917 deletions

View File

@@ -33,6 +33,7 @@
"peerDependencies": {
"immutable": ">=3.8.1 || >4.0.0-rc",
"react": ">=16.6.0",
"react-dom": ">=16.6.0",
"slate": ">=0.47.0"
},
"devDependencies": {

View File

@@ -101,6 +101,7 @@ class Content extends React.Component {
isUpdatingSelection: false,
nodeRef: React.createRef(),
nodeRefs: {},
contentKey: 0,
}
/**
@@ -211,7 +212,7 @@ class Content extends React.Component {
const native = window.getSelection()
const { activeElement } = window.document
if (debug.enabled) {
if (debug.update.enabled) {
debug.update('updateSelection', { selection: selection.toJSON() })
}
@@ -322,12 +323,23 @@ class Content extends React.Component {
}
this.tmp.isUpdatingSelection = false
debug.update('updateSelection:setTimeout', {
anchorOffset: window.getSelection().anchorOffset,
})
})
}
if (updated && debug.enabled) {
if (updated && (debug.enabled || debug.update.enabled)) {
debug('updateSelection', { selection, native, activeElement })
debug.update('updateSelection-applied', { selection })
debug.update('updateSelection:applied', {
selection: selection.toJSON(),
native: {
anchorOffset: native.anchorOffset,
focusOffset: native.focusOffset,
},
})
}
}
@@ -470,6 +482,11 @@ class Content extends React.Component {
const window = getWindow(event.target)
const { activeElement } = window.document
debug.update('onNativeSelectionChange', {
anchorOffset: window.getSelection().anchorOffset,
})
if (activeElement !== this.ref.current) return
this.props.onEvent('onSelect', event)
@@ -512,7 +529,10 @@ class Content extends React.Component {
...props.style,
}
// console.log('rerender content', this.tmp.contentKey, document.text)
debug('render', { props })
debug.update('render', this.tmp.contentKey, document.text)
this.props.onEvent('onRender')
@@ -523,7 +543,7 @@ class Content extends React.Component {
return (
<Container
key={this.props.contentKey}
key={this.tmp.contentKey}
{...handlers}
{...data}
ref={this.setRef}

View File

@@ -1,219 +0,0 @@
# Android
The following is a list of unexpected behaviors in Android
# Debugging
```
slate:android,slate:before,slate:after,slate:update,slate:reconcile
```
# API 25
### Backspace Handling
There appears to be no way to discern that backspace was pressed by looking at the events. There are two options (1) check the DOM and (2) look for a signature in the mutation.
For (1) we may be able to look at the current block and see if it disappears in the `input` event. If it no longer appears there, we know a `backspace` is likely what happened.
* Join previous paragraph
* keydown:Unidentified
* DOM change
* input:native
* mutation
* input:react
* keyup
# API 28
### Backspace Handling
* join previous paragraph
* compositionEnd
* keydown:Unidentified
* beforeinput:deleteContentBackward
* DOM change
* input:deleteContentBackward
## In the middle of a word, space then backspace
When you select in `edit|able` then press space and backspace we end up with `editble`.
When you hit `space` the composition hasn't ended.
## Two Words. One.
Type two words followed by a period. Then one word followed by a period.
The space after the second period is deleted. It does not happen if there is only one word followed by a period.
This text exhibits that issue when typed in a blank paragraph:
```
It me. No.
```
When we hit the period, here are the events:
* onCompositionEnd
* onKeyDown:Unidentified
* onBeforeInput:native:insertText "."
* onBeforeInput:synthetic:TextEvent data:"."
* onInput:insertText data:"."
* onSelect
* onKeyDown:Unidentified
* onBeforeInput:deleteContentBackward
* onInput:deleteContentBackward
# API 26/27
Although there are minor differences, API 26/27 behave similarly.
### It me. No. Failure with uppercase I.
Touch away from original selection position. Touch into a blank line. Wait for the keyboard to display uppercase letters. If it doesn't, this bug won't present itself.
Type `It me. No.` and upon hitting the final `.` you will end up with unexpected value which is usually removing the first `.` and putting the cursor behind it.
### Data in Input
In API 27, in certain situations, the `input` event returns data which lets us identify, for example, that a '.' was the last character typed. Other times, it does not provide this data.
If you start typing a line and the first character capitalizes itself, then you will not receive the data. If you start typing a line and the first character stays lower case, you will receive the data.
Because of this, `data` is not a reliable source of information even when it is available because in other scenarios it may not be.
### Backspace Handling
* Save the state using a snapshot during a `keydown` event as it may end up being a delete. The DOM is in a good before state at this time.
* Look at the `input` event to see if `event.nativeEvent.inputType` is equal to `deleteContentBackward`. If it is, then we are going to have to handle a delete but the DOM is already damaged.
* If we are handling a delete then:
* stop the `reconciler` which was started at `compositionEnd` because we don't need to reconcile anything as we will be reverting dom then deleting.
* start the `deleter` which will revert to the snapshot then execute the delete command within Slate after a `requestAnimationFrame` time.
* HOWEVER! if an `onBeforeInput` is called before the `deleter` handler is executed, we now know that it wasn't a delete after all and instead it was responding to a text change from a suggestion. In this case:
* cancel the `deleter`
* resume the `reconciler`
### Enter Handling
* Save the state using a snapshot during a `compositionEnd` event as it may end up being an `enter`. The DOM is in a good before state at this time.
* Look at the native version of the `beforeInput` event (two will fire a native and a React). Note: We manually forced Android to handle the native `beforeInput` by adding it to the `content` code.
* If the `event.nativeEvent.inputType` is equal to `insertParagraph` or `insertLineBreak` then we have determined that the user pressed enter at the end of a block (and only at the end of a block).
* If `enter is detected then:
* `preventDefault`
* set Slate's selection using the DOM
* call `splitBlock`
* Put some code in to make sure React's version of `beforeInput` doesn't fire by setting a variable. React's version will fire as it can't be cancelled from the native version even though we told it to stop.
* During React's version of `beforeInput`, if the `data` property which is a string ends in a linefeed (character code 10) then we know that it was an enter anywhere other than the end of block. At this point the DOM is already damaged.
* If we are handling an enter then:
* cancel the reconciler which was started from the `compositionEnd` event because we don't want reconciliation from the DOM to happen.
* wait until next animation frame
* revert to the last good state
* splitBlock using Slate
Events for different cases
* Start of word & Start of line
* compositionEnd
* keydown:Unidentified
* input:deleteContentBackward START DELETER
* keydown:Enter \*
* beforeInput:insertParagraph \*
* TOO LATE TO CANCEL
* Middle of word
* compositionEnd
* keydown:Unidentified
* input:deleteContentBackward START DELETER => SELF
* keydown:Unidentified
* beforeInput:CHR(10) at end \*
* TOO LATE TO CANCEL
* End of word
* compositionEnd
* keydown:Enter \*
* beforeInput:insertParagraph
* CANCELLABLE
* End of line
* keydown:Enter \*
* beforeInput:insertParagraph
* CANCELLABLE
Based on the previous cases:
* Use a snapshot if `input:deleteContentBackward` is detected before an Enter which is detected either by a `keydown:Enter` or a `beforeInput:insertParagraph` and we don't know which.
* Cancel the event if we detect a `keydown:Enter` without an immediately preceding `input:deleteContentBackward`.
### Enter at Start of Line
**TODO:**
* Go through all the steps in the Backspace handler. An enter at the beginning of a block looks exactly like a `delete` action at the beginning. The `reconciler` will be cancelled in the course of these events.
* A `keydown` event will fire with `event.key` === `Enter`. We need to set a variable `ENTER_START_OF_LINE` to `true`. Cancel the delete event and remove the reference.
* NOTE!!! Looks like splitting at other positions (not end of line) also provides an `Enter` and might be preferable to using the native `beforeInput` which we had to hack in!!! Try this!!!
* A `beforeinput` event will be called like in the `delete` code which usually cancels the `deleter` and resumes the `reconciler`. But since we removed the reference to the `deleter` neither of these methods are called.
# API 28
## DOM breaks when suggestion selected on text entirely within a `strong` tag
Appears similar to the bug in API 27.
## Can't hit Enter at begining of word API27 (probably 26 too)
WORKING ON THIS
## Can't split word with Enter (PARTIAL FIXED)
Move the cursor to `edit|able` where | is the cursor.
Hit `enter` on the virtual keyboard.
The `keydown` event does not indicate what key is being pressed so we don't know that we should be handling an enter. There are two opportunities:
1. The onBeforeInput event has a `data` property that contains the text immediately before the cursor and it includes `edit|` where the pipe indicates an enter.
2. We can look through the text at the end of a composition and simulate hitting enter maybe.
### Fixed for API 28
Allow enter to go through to the before plugin even during a compositiong and it works in API 28.
### Broken in API 27
# API 27
## Typing at end of line yields wrong cursor position (FIXED)
When you enter any text at the end of a block, the text gets entered in the wrong position.
### Fix
Fixed by ignoring the `updateSelection` code in `content.js` on the `onEvent` method if we are in Android. This doesn't ignore `updateSelection` altogether, only in that one place.
## Missing `onCompositionStart` (FIXED)
### Desciption
Insert a word using the virtual keyboard. Click outside the editor. Touch after the last letter in the word. This will display some suggestions. Click one. Selecting a suggestion will fire the `onCompositionEnd` but will not fire the corresponding `onCompositionStart` before it.
### Fix
Fixed by setting `isComposing` from the `onCompositionEnd` event until the `requestAnimationFrame` callback is executed.
## DOM breaks when suggestion selected on text entirely within a `strong` tag
Touch anywhere in the bold word "rich" in the example. Select an alternative recommendation and we get a failure.
Android is destroying the `strong` tag and replacing it with a `b` tag.
The problem does not present itself if the word is surrounding by spaces before the `strong` tag.
A possible fix may be to surround the word with a `ZERO WIDTH NO-BREAK SPACE` represented as `&#65279;` in HTML. It appears in React for empty paragraphs.#
## Other stuff
In API 28 and possibly other versions of Android, when you select inside an empty block, the block is not actually empty. It contains a `ZERO WIDTH NO-BREAK SPACE` which is `&#65729` or `\uFEFF`.
When the editor first starts, if you click immediately into an empty block, you will end up to the right of the zero-width space. Because of this, we don't get the all caps because I presume the editor only capitalizes the first characters and since the no break space is the first character it doesn't do this.
But also, as a side effect, you end up in a different editing mode which fires events differently. This breaks a bunch of things.
The fix (which I will be attempting) is to move the offset to `0` if we find ourselves in a block with the property `data-slate-zero-width="n"`.

View File

@@ -0,0 +1,610 @@
import Debug from 'debug'
import getWindow from 'get-window'
import ReactDOM from 'react-dom'
import diffText from './diff-text'
/**
* @type {Debug}
*/
const debug = Debug('slate:composition-manager')
/**
* Unicode String for a ZERO_WIDTH_SPACE
*
* @type {String}
*/
const ZERO_WIDTH_SPACE = String.fromCharCode(65279)
/**
* https://github.com/facebook/draft-js/commit/cda13cb8ff9c896cdb9ff832d1edeaa470d3b871
*/
const flushControlled = ReactDOM.unstable_flushControlled
function renderSync(editor, fn) {
flushControlled(() => {
fn()
editor.controller.flush()
})
}
/**
* Takes text from a dom node and an offset within that text and returns an
* object with fixed text and fixed offset which removes zero width spaces
* and adjusts the offset.
*
* Optionally, if an `isLastNode` argument is passed in, it will also remove
* a trailing newline.
*
* @param {String} text
* @param {Number} offset
* @param {Boolean} isLastNode
*/
function fixTextAndOffset(prevText, prevOffset = 0, isLastNode = false) {
let nextOffset = prevOffset
let nextText = prevText
let index = 0
while (index !== -1) {
index = nextText.indexOf(ZERO_WIDTH_SPACE, index)
if (index === -1) break
if (nextOffset > index) nextOffset--
nextText = `${nextText.slice(0, index)}${nextText.slice(index + 1)}`
}
// remove the last newline if we are in the last node of a block
const lastChar = nextText.charAt(nextText.length - 1)
if (isLastNode && lastChar === '\n') {
nextText = nextText.slice(0, -1)
}
const maxOffset = nextText.length
if (nextOffset > maxOffset) nextOffset = maxOffset
return { text: nextText, offset: nextOffset }
}
/**
* Based loosely on:
*
* https://github.com/facebook/draft-js/blob/master/src/component/handlers/composition/DOMObserver.js
* https://github.com/ProseMirror/prosemirror-view/blob/master/src/domobserver.js
*
* But is an analysis mainly for `backspace` and `enter` as we handle
* compositions as a single operation.
*
* @param {} element
*/
function CompositionManager(editor) {
/**
* A MutationObserver that flushes to the method `flush`
*
* @type {MutationObserver}
*/
const observer = new window.MutationObserver(flush)
let win = null
/**
* Object that keeps track of the most recent state
*
* @type {Range}
*/
const last = {
rootEl: null, // root element that MutationObserver is attached to
diff: null, // last text node diff between Slate and DOM
range: null, // last range selected
domNode: null, // last DOM node the cursor was in
}
/**
* Connect the MutationObserver to a specific editor root element
*/
function connect() {
debug('connect', { rootEl })
const rootEl = editor.findDOMNode([])
if (last.rootEl === rootEl) return
debug('connect:run')
win = getWindow(rootEl)
observer.observe(rootEl, {
childList: true,
characterData: true,
attributes: true,
subtree: true,
characterDataOldValue: true,
})
}
function disconnect() {
debug('disconnect')
observer.disconnect()
last.rootEl = null
}
function clearDiff() {
debug('clearDIff')
last.diff = null
}
/**
* Clear the `last` properties related to an action only
*/
function clearAction() {
debug('clearAction')
last.diff = null
last.domNode = null
}
/**
* Apply the last `diff`
*
* We don't want to apply the `diff` at the time it is created because we
* may be in a composition. There are a few things that trigger the applying
* of the saved diff. Sometimeson its own and sometimes immediately before
* doing something else with the Editor.
*
* - `onCompositionEnd` event
* - `onSelect` event only when the user has moved into a different node
* - The user hits `enter`
* - The user hits `backspace` and removes an inline node
* - The user hits `backspace` and merges two blocks
*/
function applyDiff() {
debug('applyDiff')
const { diff } = last
if (diff == null) return
debug('applyDiff:run')
const { document } = editor.value
let entire = editor.value.selection
.moveAnchorTo(diff.path, diff.start)
.moveFocusTo(diff.path, diff.end)
entire = document.resolveRange(entire)
editor.insertTextAtRange(entire, diff.insertText)
}
/**
* Handle `enter` that splits block
*/
function splitBlock() {
debug('splitBlock')
renderSync(editor, () => {
applyDiff()
if (last.range) {
editor.select(last.range)
} else {
debug('splitBlock:NO-SELECTION')
}
editor
.splitBlock()
.focus()
.restoreDOM()
clearAction()
})
}
/**
* Handle `backspace` that merges blocks
*/
function mergeBlock() {
debug('mergeBlock')
/**
* The delay is required because hitting `enter`, `enter` then `backspace`
* in a word results in the cursor being one position to the right in
* Android 9.
*
* Slate sets the position to `0` and we even check it immediately after
* setting it and it is correct, but somewhere Android moves it to the right.
*
* This happens only when using the virtual keyboard. Hitting enter on a
* hardware keyboard does not trigger this bug.
*
* The call to `focus` is required because when we switch examples then
* merge a block, we lose focus in Android 9 (possibly others).
*/
win.requestAnimationFrame(() => {
renderSync(editor, () => {
applyDiff()
editor
.select(last.range)
.deleteBackward()
.focus()
.restoreDOM()
clearAction()
})
})
}
/**
* The requestId used to the save selection
*
* @type {Any}
*/
let onSelectTimeoutId = null
let bufferedMutations = []
let startActionFrameId = null
let isFlushing = false
/**
* Mark the beginning of an action. The action happens when the
* `requestAnimationFrame` expires.
*
* If `startAction` is called again, it pushes the `action` to a new
* `requestAnimationFrame` and cancels the old one.
*/
function startAction() {
if (onSelectTimeoutId) {
window.cancelAnimationFrame(onSelectTimeoutId)
onSelectTimeoutId = null
}
isFlushing = true
if (startActionFrameId) window.cancelAnimationFrame(startActionFrameId)
startActionFrameId = window.requestAnimationFrame(() => {
if (bufferedMutations.length > 0) {
flushAction(bufferedMutations)
}
startActionFrameId = null
bufferedMutations = []
isFlushing = false
})
}
/**
* Handle MutationObserver flush
*
* @param {MutationList} mutations
*/
function flush(mutations) {
debug('flush')
bufferedMutations.push(...mutations)
startAction()
}
/**
* Handle a `requestAnimationFrame` long batch of mutations.
*
* @param {Array} mutations
*/
function flushAction(mutations) {
debug('flushAction', mutations.length, mutations)
// If there is an expanded collection, delete it
if (last.range && !last.range.isCollapsed) {
renderSync(editor, () => {
editor
.select(last.range)
.deleteBackward()
.focus()
.restoreDOM()
})
return
}
if (mutations.length > 1) {
// check if one of the mutations matches the signature of an `enter`
// which we use to signify a `splitBlock`
const splitBlockMutation = mutations.find(m => {
if (m.type !== 'childList') return false
if (m.addedNodes.length === 0) return false
const addedNode = m.addedNodes[0]
// If a text node is created anywhere with a newline in it, it's an
// enter
if (
addedNode.nodeType === window.Node.TEXT_NODE &&
addedNode.textContent === '\n'
)
return true
// If an element is created with a key that matches a block in our
// document, that means the mutation is splitting an existing block
// by creating a new element with the same key.
if (addedNode.nodeType !== window.Node.ELEMENT_NODE) return false
const dataset = addedNode.dataset
const key = dataset.key
if (key == null) return false
const block = editor.value.document.getClosestBlock(key)
return !!block
})
if (splitBlockMutation) {
splitBlock()
return
}
}
// If we haven't matched a more specific mutation already, these general
// mutation catchers will try and determine what the user was trying to
// do.
const firstMutation = mutations[0]
if (firstMutation.type === 'characterData') {
resolveDOMNode(firstMutation.target.parentNode)
} else if (firstMutation.type === 'childList') {
if (firstMutation.removedNodes.length > 0) {
if (mutations.length === 1) {
removeNode(firstMutation.removedNodes[0])
} else {
mergeBlock()
}
} else if (firstMutation.addedNodes.length > 0) {
splitBlock()
}
}
}
/**
* Takes a DOM Node and resolves it against Slate's Document.
*
* Saves the changes to `last.diff` which can be applied later using
* `applyDiff()`
*
* @param {DOMNode} domNode
*/
function resolveDOMNode(domNode) {
debug('resolveDOMNode')
const { value } = editor
const { document } = value
const dataElement = domNode.closest(`[data-key]`)
const key = dataElement.dataset.key
const path = document.getPath(key)
const block = document.getClosestBlock(key)
const node = document.getDescendant(key)
const prevText = node.text
// COMPAT: If this is the last leaf, and the DOM text ends in a new line,
// we will have added another new line in <Leaf>'s render method to account
// for browsers collapsing a single trailing new lines, so remove it.
const isLastNode = block.nodes.last() === node
const fix = fixTextAndOffset(domNode.textContent, 0, isLastNode)
const nextText = fix.text
// If the text is no different, there is no diff.
if (nextText === prevText) {
last.diff = null
return
}
const diff = diffText(prevText, nextText)
last.diff = {
path,
start: diff.start,
end: diff.end,
insertText: diff.insertText,
}
debug('resolveDOMNode:diff', last.diff)
}
/**
* Remove an Inline DOM Node.
*
* Happens when you delete the last character in an Inline DOM Node
*/
function removeNode(domNode) {
debug('removeNode')
if (domNode.nodeType !== window.Node.ELEMENT_NODE) return
const { value } = editor
const { document, selection } = value
const node = editor.findNode(domNode)
const nodeSelection = document.resolveRange(
selection.moveToRangeOfNode(node)
)
renderSync(editor, () => {
editor
.select(nodeSelection)
.delete()
.restoreDOM()
})
}
/**
* handle `onCompositionStart`
*/
function onCompositionStart() {
debug('onCompositionStart')
}
/**
* handle `onCompositionEnd`
*/
function onCompositionEnd() {
debug('onCompositionEnd')
/**
* The timing on the `setTimeout` with `20` ms is sensitive.
*
* It cannot use `requestAnimationFrame` because it is too short.
*
* Android 9, for example, when you type `it ` the space will first trigger
* a `compositionEnd` for the `it` part before the mutation for the ` `.
* This means that we end up with `it` if we trigger too soon because it
* is on the wrong value.
*/
window.setTimeout(() => {
if (last.diff) {
debug('onCompositionEnd:applyDiff')
renderSync(editor, () => {
applyDiff()
const domRange = win.getSelection().getRangeAt(0)
const domText = domRange.startContainer.textContent
const offset = domRange.startOffset
const fix = fixTextAndOffset(domText, offset)
const range = editor
.findRange({
anchorNode: domRange.startContainer,
anchorOffset: 0,
focusNode: domRange.startContainer,
focusOffset: 0,
isCollapsed: true,
})
.moveTo(fix.offset)
/**
* We must call `restoreDOM` even though this is applying a `diff` which
* should not require it. But if you type `it me. no.` on a blank line
* with a block following it, the next line will merge with the this
* line. A mysterious `keydown` with `input` of backspace appears in the
* event stream which the user not React caused.
*
* `focus` is required as well because otherwise we lose focus on hitting
* `enter` in such a scenario.
*/
editor
.select(range)
.focus()
.restoreDOM()
})
}
clearAction()
}, 20)
}
/**
* Handle `onSelect` event
*
* Save the selection after a `requestAnimationFrame`
*
* - If we're not in the middle of flushing mutations
* - and cancel save if a mutation runs before the `requestAnimationFrame`
*/
function onSelect(event) {
debug('onSelect:try')
// Event can be Synthetic React or native. Grab only the native one so
// that we don't have to call `event.perist` for performance.
event = event.nativeEvent ? event.nativeEvent : event
window.cancelAnimationFrame(onSelectTimeoutId)
onSelectTimeoutId = null
// Don't capture the last selection if the selection was made during the
// flushing of DOM mutations. This means it is all part of one user action.
if (isFlushing) return
onSelectTimeoutId = window.requestAnimationFrame(() => {
debug('onSelect:save-selection')
const domSelection = getWindow(event.target).getSelection()
let range = editor.findRange(domSelection)
const anchorFix = fixTextAndOffset(
domSelection.anchorNode.textContent,
domSelection.anchorOffset
)
const focusFix = fixTextAndOffset(
domSelection.focusNode.textContent,
domSelection.focusOffset
)
if (range.anchor.offset !== anchorFix.offset) {
range = range.set(
'anchor',
range.anchor.set('offset', anchorFix.offset)
)
}
if (range.focus.offset !== focusFix.offset) {
range = range.set('focus', range.focus.set('offset', focusFix.offset))
}
debug('onSelect:save-data', {
domSelection: normalizeDOMSelection(domSelection),
range: range.toJS(),
})
// If the `domSelection` has moved into a new node, then reconcile with
// `applyDiff`
if (
domSelection.isCollapsed &&
last.node !== domSelection.anchorNode &&
last.diff != null
) {
debug('onSelect:applyDiff', last.diff)
applyDiff()
editor.select(range)
clearAction()
}
last.range = range
last.node = domSelection.anchorNode
})
}
return {
clearDiff,
connect,
disconnect,
onKeyDown: startAction,
onCompositionStart,
onCompositionEnd,
onSelect,
}
}
function normalizeDOMSelection(selection) {
return {
anchorNode: selection.anchorNode,
anchorOffset: selection.anchorOffset,
focusNode: selection.focusNode,
focusOffset: selection.focusOffset,
}
}
export default CompositionManager

View File

@@ -0,0 +1,32 @@
# CompositionManager
Android version 8 and 9 use `CompositionManager` for compatibility.
## MutationObserver
The `CompositionManager` looks at mutations using `MutationObserver` and
bypasses all other event processing from the `dom` plugin.
It uses mutations to determine how to modify the Editor `value` where `dom`
uses events to determine how to modify the Editor `value`.
## How It Works
We try to avoid an Editor `render` in the middle of a composition. At the same
time we want to make sure all mutations are eventually reflected in the Editor
`value`.
### Actions
`MutationObserver` emits batches of `mutations`.
We also batch these batches into actions. Each action contains all the
mutations that are emitted within a single `requestAnimationFrame`.
We do this because some actions like hitting `enter` might trigger multiple
batches of `mutations` but these multiple batches of `mutations` are all part
of one `enter` action.
We get so many mutations because there are many DOM manipulations required to
split a node. The browser has to split the current text node in two and it
also has to clone all of the surrounding mark, inline and block nodes.

View File

@@ -0,0 +1,97 @@
/**
* Returns the number of characters that are the same at the beginning of the
* String.
*
* @param {String} prev
* @param {String} next
*/
function getDiffStart(prev, next) {
const length = Math.min(prev.length, next.length)
for (let i = 0; i < length; i++) {
if (prev.charAt(i) !== next.charAt(i)) return i
}
if (prev.length !== next.length) return length
return null
}
/**
* Returns the number of characters that are the same at the end of the String
* up to `max`. Max prevents double-counting characters when there are
* multiple duplicate characters around the diff area.
*
* @param {String} prev
* @param {String} next
* @param {Number} max
*/
function getDiffEnd(prev, next, max) {
const prevLength = prev.length
const nextLength = next.length
const length = Math.min(prevLength, nextLength, max)
for (let i = 0; i < length; i++) {
const prevChar = prev.charAt(prevLength - i - 1)
const nextChar = next.charAt(nextLength - i - 1)
if (prevChar !== nextChar) return i
}
if (prev.length !== next.length) return length
return null
}
/**
* Takes two strings and returns an object representing two offsets. The
* first, `start` represents the number of characters that are the same at
* the front of the String. The `end` represents the number of characters
* that are the same at the end of the String.
*
* Returns null if they are identical.
*
* @param {String} prev
* @param {String} next
*/
function getDiffOffsets(prev, next) {
if (prev === next) return null
const start = getDiffStart(prev, next)
const maxEnd = Math.min(prev.length - start, next.length - start)
const end = getDiffEnd(prev, next, maxEnd)
return { start, end, total: start + end }
}
/**
* Takes a text string and returns a slice from the string at the given offses
*
* @param {String} text
* @param {Object} offsets
*/
function sliceText(text, offsets) {
return text.slice(offsets.start, text.length - offsets.end)
}
/**
* Takes two strings and returns a smart diff that can be used to describe the
* change in a way that can be used as operations like inserting, removing or
* replacing text.
*
* @param {String} prev
* @param {String} next
*/
export default function diff(prev, next) {
const offsets = getDiffOffsets(prev, next)
if (offsets == null) return null
const insertText = sliceText(next, offsets)
const removeText = sliceText(prev, offsets)
return {
start: offsets.start,
end: prev.length - offsets.end,
cursor: offsets.start + insertText.length,
insertText,
removeText,
}
}

View File

@@ -1,32 +0,0 @@
/**
* Fixes a selection within the DOM when the cursor is in Slate's special
* zero-width block. Slate handles empty blocks in a special manner and the
* cursor can end up either before or after the non-breaking space. This
* causes different behavior in Android and so we make sure the seleciton is
* always before the zero-width space.
*
* @param {Window} window
*/
export default function fixSelectionInZeroWidthBlock(window) {
const domSelection = window.getSelection()
const { anchorNode } = domSelection
const { dataset } = anchorNode.parentElement
const isZeroWidth = dataset ? dataset.slateZeroWidth === 'n' : false
// We are doing three checks to see if we need to move the cursor.
// Is this a zero-width slate span?
// Is the current cursor position not at the start of it?
// Is there more than one character (i.e. the zero-width space char) in here?
if (
isZeroWidth &&
anchorNode.textContent.length === 1 &&
domSelection.anchorOffset !== 0
) {
const range = window.document.createRange()
range.setStart(anchorNode, 0)
range.setEnd(anchorNode, 0)
domSelection.removeAllRanges()
domSelection.addRange(range)
}
}

View File

@@ -1,623 +1,122 @@
import Debug from 'debug'
import getWindow from 'get-window'
import pick from 'lodash/pick'
import { ANDROID_API_VERSION } from 'slate-dev-environment'
import fixSelectionInZeroWidthBlock from './fix-selection-in-zero-width-block'
import getSelectionFromDom from '../../utils/get-selection-from-dom'
import isInputDataEnter from './is-input-data-enter'
import isInputDataLastChar from './is-input-data-last-char'
import DomSnapshot from './dom-snapshot'
import Executor from './executor'
const debug = Debug('slate:android')
debug.reconcile = Debug('slate:reconcile')
debug('ANDROID_API_VERSION', { ANDROID_API_VERSION })
import CompositionManager from './composition-manager'
/**
* Define variables related to composition state.
* Fixes a selection within the DOM when the cursor is in Slate's special
* zero-width block. Slate handles empty blocks in a special manner and the
* cursor can end up either before or after the non-breaking space. This
* causes different behavior in Android and so we make sure the seleciton is
* always before the zero-width space.
*
* @param {Window} window
*/
const NONE = 0
const COMPOSING = 1
function AndroidPlugin() {
/**
* The current state of composition.
*
* @type {NONE|COMPOSING|WAITING}
*/
let status = NONE
/**
* The set of nodes that we need to process when we next reconcile.
* Usually this is soon after the `onCompositionEnd` event.
*
* @type {Set} set containing Node objects
*/
const nodes = new window.Set()
/**
* Keep a snapshot after a composition end for API 26/27. If a `beforeInput`
* gets called with data that ends in an ENTER then we need to use this
* snapshot to revert the DOM so that React doesn't get out of sync with the
* DOM. We also need to cancel the `reconcile` operation as it interferes in
* certain scenarios like hitting 'enter' at the end of a word.
*
* @type {DomSnapshot} [compositionEndSnapshot]
*/
let compositionEndSnapshot = null
/**
* When there is a `compositionEnd` we ened to reconcile Slate's Document
* with the DOM. The `reconciler` is an instance of `Executor` that does
* this for us. It is created on every `compositionEnd` and executes on the
* next `requestAnimationFrame`. The `Executor` can be cancelled and resumed
* which some methods do.
*
* @type {Executor}
*/
let reconciler = null
/**
* A snapshot that gets taken when there is a `keydown` event in API26/27.
* If an `input` gets called with `inputType` of `deleteContentBackward`
* we need to undo the delete that Android does to keep React in sync with
* the DOM.
*
* @type {DomSnapshot}
*/
let keyDownSnapshot = null
/**
* The deleter is an instace of `Executor` that will execute a delete
* operation on the next `requestAnimationFrame`. It has to wait because
* we need Android to finish all of its DOM operations to do with deletion
* before we revert them to a Snapshot. After reverting, we then execute
* Slate's version of delete.
*
* @type {Executor}
*/
let deleter = null
/**
* Because Slate implements its own event handler for `beforeInput` in
* addition to React's version, we actually get two. If we cancel the
* first native version, the React one will still fire. We set this to
* `true` if we don't want that to happen. Remember that when we prevent it,
* we need to tell React to `preventDefault` so the event doesn't continue
* through React's event system.
*
* type {Boolean}
*/
let preventNextBeforeInput = false
/**
* When a composition ends, in some API versions we may need to know what we
* have learned so far about the composition and what we want to do because of
* some actions that may come later.
*
* For example in API 26/27, if we get a `beforeInput` that tells us that the
* input was a `.`, then we want the reconcile to happen even if there are
* `onInput:delete` events that follow. In this case, we would set
* `compositionEndAction` to `period`. During the `onInput` we would check if
* the `compositionEndAction` says `period` and if so we would not start the
* `delete` action.
*
* @type {(String|null)}
*/
let compositionEndAction = null
/**
* Looks at the `nodes` we have collected, usually the things we have edited
* during the course of a composition, and then updates Slate's internal
* Document based on the text values in these DOM nodes and also updates
* Slate's Selection based on the current cursor position in the Editor.
*
* @param {Window} window
* @param {Editor} editor
* @param {String} options.from - where reconcile was called from for debug
*/
function reconcile(window, editor, { from }) {
debug.reconcile({ from })
const domSelection = window.getSelection()
const selection = editor.findSelection(domSelection)
nodes.forEach(node => {
editor.reconcileDOMNode(node)
})
editor.select(selection)
nodes.clear()
}
/**
* On before input.
*
* Check `components/content` because some versions of Android attach a
* native `beforeinput` event on the Editor. In this case, you might need
* to distinguish whether the event coming through is the native or React
* version of the event. Also, if you cancel the native version that does
* not necessarily mean that the React version is cancelled.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onBeforeInput(event, editor, next) {
const isNative = !event.nativeEvent
debug('onBeforeInput', {
isNative,
event,
status,
e: pick(event, ['data', 'inputType', 'isComposing', 'nativeEvent']),
})
const window = getWindow(event.target)
if (preventNextBeforeInput) {
event.preventDefault()
preventNextBeforeInput = false
return
}
switch (ANDROID_API_VERSION) {
case 25:
// prevent onBeforeInput to allow selecting an alternate suggest to
// work.
break
case 26:
case 27:
if (deleter) {
deleter.cancel()
reconciler.resume()
}
// This analyses Android's native `beforeInput` which Slate adds
// on in the `Content` component. It only fires if the cursor is at
// the end of a block. Otherwise, the code below checks.
if (isNative) {
if (
event.inputType === 'insertParagraph' ||
event.inputType === 'insertLineBreak'
) {
debug('onBeforeInput:enter:native', {})
const domSelection = window.getSelection()
const selection = getSelectionFromDom(window, editor, domSelection)
preventNextBeforeInput = true
event.preventDefault()
editor.moveTo(selection.anchor.path, selection.anchor.offset)
editor.splitBlock()
}
} else {
if (isInputDataLastChar(event.data, ['.'])) {
debug('onBeforeInput:period')
reconciler.cancel()
compositionEndAction = 'period'
return
}
// This looks at the beforeInput event's data property and sees if it
// ends in a linefeed which is character code 10. This appears to be
// the only way to detect that enter has been pressed except at end
// of line where it doesn't work.
const isEnter = isInputDataEnter(event.data)
if (isEnter) {
if (reconciler) reconciler.cancel()
window.requestAnimationFrame(() => {
debug('onBeforeInput:enter:react', {})
compositionEndSnapshot.apply(editor)
editor.splitBlock()
})
}
}
break
case 28:
// If a `beforeInput` event fires after an `input:deleteContentBackward`
// event, it appears to be a good indicator that it is some sort of
// special combined Android event. If this is the case, then we don't
// want to have a deletion to happen, we just want to wait until Android
// has done its thing and then at the end we just want to reconcile.
if (deleter) {
deleter.cancel()
reconciler.resume()
}
break
default:
if (status !== COMPOSING) next()
}
}
/**
* On Composition end. By default, when a `compositionEnd` event happens,
* we start a reconciler. The reconciler will update Slate's Document using
* the DOM as the source of truth. In some cases, the reconciler needs to
* be cancelled and can also be resumed. For example, when a delete
* immediately followed a `compositionEnd`, we don't want to reconcile.
* Instead, we want the `delete` to take precedence.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onCompositionEnd(event, editor, next) {
debug('onCompositionEnd', { event })
const window = getWindow(event.target)
const domSelection = window.getSelection()
const { anchorNode } = domSelection
switch (ANDROID_API_VERSION) {
case 26:
case 27:
compositionEndSnapshot = new DomSnapshot(window, editor)
// fixes a bug in Android API 26 & 27 where a `compositionEnd` is triggered
// without the corresponding `compositionStart` event when clicking a
// suggestion.
//
// If we don't add this, the `onBeforeInput` is triggered and passes
// through to the `before` plugin.
status = COMPOSING
break
}
compositionEndAction = 'reconcile'
nodes.add(anchorNode)
reconciler = new Executor(window, () => {
status = NONE
reconcile(window, editor, { from: 'onCompositionEnd:reconciler' })
compositionEndAction = null
})
}
/**
* On composition start.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onCompositionStart(event, editor, next) {
debug('onCompositionStart', { event })
status = COMPOSING
nodes.clear()
}
/**
* On composition update.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onCompositionUpdate(event, editor, next) {
debug('onCompositionUpdate', { event })
}
/**
* On input.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onInput(event, editor, next) {
debug('onInput', {
event,
status,
e: pick(event, ['data', 'nativeEvent', 'inputType', 'isComposing']),
})
switch (ANDROID_API_VERSION) {
case 24:
case 25:
break
case 26:
case 27:
case 28:
const { nativeEvent } = event
if (ANDROID_API_VERSION === 28) {
// NOTE API 28:
// When a user hits space and then backspace in `middle` we end up
// with `midle`.
//
// This is because when the user hits space, the composition is not
// ended because `compositionEnd` is not called yet. When backspace is
// hit, the `compositionEnd` is called. We need to revert the DOM but
// the reconciler has not had a chance to run from the
// `compositionEnd` because it is set to run on the next
// `requestAnimationFrame`. When the backspace is carried out on the
// Slate Value, Slate doesn't know about the space yet so the
// backspace is carried out without the space cuasing us to lose a
// character.
//
// This fix forces Android to reconcile immediately after hitting
// the space.
//
// NOTE API 27:
// It is confirmed that this bug does not present itself on API27.
// A space fires a `compositionEnd` (as well as other events including
// an input that is a delete) so the reconciliation happens.
//
if (
nativeEvent.inputType === 'insertText' &&
nativeEvent.data === ' '
) {
if (reconciler) reconciler.cancel()
if (deleter) deleter.cancel()
reconcile(window, editor, { from: 'onInput:space' })
return
}
}
if (ANDROID_API_VERSION === 26 || ANDROID_API_VERSION === 27) {
if (compositionEndAction === 'period') {
debug('onInput:period:abort')
// This means that there was a `beforeInput` that indicated the
// period was pressed. When a period is pressed, you get a bunch
// of delete actions mixed in. We want to ignore those. At this
// point, we add the current node to the list of things we need to
// resolve at the next compositionEnd. We know that a new
// composition will start right after this event so it is safe to
// do this.
const { anchorNode } = window.getSelection()
nodes.add(anchorNode)
return
}
}
if (nativeEvent.inputType === 'deleteContentBackward') {
debug('onInput:delete', { keyDownSnapshot })
const window = getWindow(event.target)
if (reconciler) reconciler.cancel()
if (deleter) deleter.cancel()
deleter = new Executor(
window,
() => {
debug('onInput:delete:callback', { keyDownSnapshot })
keyDownSnapshot.apply(editor)
editor.deleteBackward()
deleter = null
},
{
onCancel() {
deleter = null
},
}
)
return
}
if (status === COMPOSING) {
const { anchorNode } = window.getSelection()
nodes.add(anchorNode)
return
}
// Some keys like '.' are input without compositions. This happens
// in API28. It might be happening in API 27 as well. Check by typing
// `It me. No.` On a blank line.
if (ANDROID_API_VERSION === 28) {
debug('onInput:fallback')
const { anchorNode } = window.getSelection()
nodes.add(anchorNode)
window.requestAnimationFrame(() => {
debug('onInput:fallback:callback')
reconcile(window, editor, { from: 'onInput:fallback' })
})
return
}
break
default:
if (status === COMPOSING) return
next()
}
}
/**
* On key down.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onKeyDown(event, editor, next) {
debug('onKeyDown', {
event,
status,
e: pick(event, [
'char',
'charCode',
'code',
'key',
'keyCode',
'keyIdentifier',
'keyLocation',
'location',
'nativeEvent',
'which',
]),
})
const window = getWindow(event.target)
switch (ANDROID_API_VERSION) {
// 1. We want to allow enter keydown to allows go through
// 2. We want to deny keydown, I think, when it fires before the composition
// or something. Need to remember what it was.
case 25:
// in API25 prevent other keys to fix clicking a word and then
// selecting an alternate suggestion.
//
// NOTE:
// The `setSelectionFromDom` is to allow hitting `Enter` to work
// because the selection needs to be in the right place; however,
// for now we've removed the cancelling of `onSelect` and everything
// appears to be working. Not sure why we removed `onSelect` though
// in API25.
if (event.key === 'Enter') {
// const window = getWindow(event.target)
// const selection = window.getSelection()
// setSelectionFromDom(window, editor, selection)
next()
}
break
case 26:
case 27:
if (event.key === 'Enter') {
debug('onKeyDown:enter', {})
if (deleter) {
// If a `deleter` exists which means there was an onInput with
// `deleteContentBackward` that hasn't fired yet, then we know
// this is one of the cases where we have to revert to before
// the split.
deleter.cancel()
event.preventDefault()
window.requestAnimationFrame(() => {
debug('onKeyDown:enter:callback')
compositionEndSnapshot.apply(editor)
editor.splitBlock()
})
} else {
event.preventDefault()
// If there is no deleter, all we have to do is prevent the
// action before applying or splitBlock. In this scenario, we
// have to grab the selection from the DOM.
const domSelection = window.getSelection()
const selection = getSelectionFromDom(window, editor, domSelection)
editor.moveTo(selection.anchor.path, selection.anchor.offset)
editor.splitBlock()
}
return
}
// We need to take a snapshot of the current selection and the
// element before when the user hits the backspace key. This is because
// we only know if the user hit backspace if the `onInput` event that
// follows has an `inputType` of `deleteContentBackward`. At that time
// it's too late to stop the event.
keyDownSnapshot = new DomSnapshot(window, editor, {
before: true,
})
// If we let 'Enter' through it breaks handling of hitting
// enter at the beginning of a word so we need to stop it.
break
case 28:
{
if (event.key === 'Enter') {
debug('onKeyDown:enter')
event.preventDefault()
if (reconciler) reconciler.cancel()
if (deleter) deleter.cancel()
window.requestAnimationFrame(() => {
reconcile(window, editor, { from: 'onKeyDown:enter' })
editor.splitBlock()
})
return
}
// We need to take a snapshot of the current selection and the
// element before when the user hits the backspace key. This is because
// we only know if the user hit backspace if the `onInput` event that
// follows has an `inputType` of `deleteContentBackward`. At that time
// it's too late to stop the event.
keyDownSnapshot = new DomSnapshot(window, editor, {
before: true,
})
debug('onKeyDown:snapshot', { keyDownSnapshot })
}
// If we let 'Enter' through it breaks handling of hitting
// enter at the beginning of a word so we need to stop it.
break
default:
if (status !== COMPOSING) {
next()
}
}
}
/**
* On select.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onSelect(event, editor, next) {
debug('onSelect', { event, status })
switch (ANDROID_API_VERSION) {
// We don't want to have the selection move around in an onSelect because
// it happens after we press `enter` in the same transaction. This
// causes the cursor position to not be properly placed.
case 26:
case 27:
case 28:
const window = getWindow(event.target)
fixSelectionInZeroWidthBlock(window)
break
default:
break
}
}
/**
* Return the plugin.
*
* @type {Object}
*/
return {
onBeforeInput,
onCompositionEnd,
onCompositionStart,
onCompositionUpdate,
onInput,
onKeyDown,
onSelect,
function fixSelectionInZeroWidthBlock(window) {
const domSelection = window.getSelection()
const { anchorNode } = domSelection
if (anchorNode == null) return
const { dataset } = anchorNode.parentElement
const isZeroWidth = dataset ? dataset.slateZeroWidth === 'n' : false
if (
isZeroWidth &&
anchorNode.textContent.length === 1 &&
domSelection.anchorOffset !== 0
) {
const range = window.document.createRange()
range.setStart(anchorNode, 0)
range.setEnd(anchorNode, 0)
domSelection.removeAllRanges()
domSelection.addRange(range)
}
}
/**
* Export.
* Android Plugin
*
* @type {Function}
* @param {Editor} options.editor
*/
function AndroidPlugin({ editor }) {
const observer = new CompositionManager(editor)
/**
* handle `onCompositionStart`
*/
function onCompositionStart() {
observer.onCompositionStart()
}
/**
* handle `onCompositionEnd`
*/
function onCompositionEnd() {
observer.onCompositionEnd()
}
/**
* handle `onSelect`
*
* @param {Event} event
*/
function onSelect(event) {
const window = getWindow(event.target)
fixSelectionInZeroWidthBlock(window)
observer.onSelect(event)
}
/**
* handle `onComponentDidMount`
*/
function onComponentDidMount() {
observer.connect()
}
/**
* handle `onComponentDidUpdate`
*/
function onComponentDidUpdate() {
observer.connect()
}
/**
* handle `onComponentWillUnmount`
*
* @param {Event} event
*/
function onComponentWillUnmount() {
observer.disconnect()
}
/**
* handle `onRender`
*
* @param {Event} event
*/
function onRender() {
observer.disconnect()
// We don't want the `diff` from a previous render to apply to a
// potentially different value (e.g. when we switch examples)
observer.clearDiff()
}
return {
onComponentDidMount,
onComponentDidUpdate,
onComponentWillUnmount,
onCompositionEnd,
onCompositionStart,
onRender,
onSelect,
}
}
export default AndroidPlugin

View File

@@ -1,19 +0,0 @@
/**
* In Android API 26 and 27 we can tell if the input key was pressed by
* waiting for the `beforeInput` event and seeing that the last character
* of its `data` property is char code `10`.
*
* Note that at this point it is too late to prevent the event from affecting
* the DOM so we use other methods to clean the DOM up after we have detected
* the input.
*
* @param {String} data
* @return {Boolean}
*/
export default function isInputDataEnter(data) {
if (data == null) return false
const lastChar = data[data.length - 1]
const charCode = lastChar.charCodeAt(0)
return charCode === 10
}

View File

@@ -1,17 +0,0 @@
/**
* In Android sometimes the only way to tell what the user is trying to do
* is to look at an event's `data` property and see if the last characters
* matches a character. This method helps us make that determination.
*
* @param {String} data
* @param {[String]} chars
* @return {Boolean}
*/
export default function isInputDataLastChar(data, chars) {
if (!Array.isArray(chars))
throw new Error(`chars must be an array of one character strings`)
if (data == null) return false
const lastChar = data[data.length - 1]
return chars.includes(lastChar)
}

View File

@@ -1,5 +1,8 @@
import { IS_ANDROID } from 'slate-dev-environment'
import AndroidPlugin from '../android'
import NoopPlugin from '../debug/noop'
import AfterPlugin from './after'
import BeforePlugin from './before'
@@ -18,9 +21,11 @@ function DOMPlugin(options = {}) {
// COMPAT: Add Android specific handling separately before it gets to the
// other plugins because it is specific (other browser don't need it) and
// finicky (it has to come before other plugins to work).
const beforeBeforePlugins = IS_ANDROID ? [AndroidPlugin()] : []
const androidPlugins = IS_ANDROID
? [AndroidPlugin(options), NoopPlugin(options)]
: []
return [...beforeBeforePlugins, beforePlugin, ...plugins, afterPlugin]
return [...androidPlugins, beforePlugin, ...plugins, afterPlugin]
}
/**

View File

@@ -1,6 +1,7 @@
import Debug from 'debug'
import PlaceholderPlugin from 'slate-react-placeholder'
import { IS_ANDROID } from 'slate-dev-environment'
import PlaceholderPlugin from 'slate-react-placeholder'
import EditorPropsPlugin from './editor-props'
import RenderingPlugin from './rendering'
import CommandsPlugin from './commands'
@@ -19,7 +20,7 @@ import DebugMutationsPlugin from '../debug/debug-mutations'
*/
function ReactPlugin(options = {}) {
const { placeholder = '', plugins = [] } = options
const { placeholder = '' } = options
const debugEventsPlugin = Debug.enabled('slate:events')
? DebugEventsPlugin(options)
: null
@@ -33,23 +34,28 @@ function ReactPlugin(options = {}) {
const commandsPlugin = CommandsPlugin(options)
const queriesPlugin = QueriesPlugin(options)
const editorPropsPlugin = EditorPropsPlugin(options)
const domPlugin = DOMPlugin({
plugins: [editorPropsPlugin, ...plugins],
})
const domPlugin = DOMPlugin(options)
const restoreDomPlugin = RestoreDOMPlugin()
const placeholderPlugin = PlaceholderPlugin({
placeholder,
when: (editor, node) =>
node.object === 'document' &&
node.text === '' &&
node.nodes.size === 1 &&
Array.from(node.texts()).length === 1,
})
// Disable placeholder for Android because it messes with reconciliation
// and doesn't disappear until composition is complete.
// e.g. In empty, type "h" and autocomplete on Android 9 and deletes all text.
const placeholderPlugin = IS_ANDROID
? null
: PlaceholderPlugin({
placeholder,
when: (editor, node) =>
node.object === 'document' &&
node.text === '' &&
node.nodes.size === 1 &&
Array.from(node.texts()).length === 1,
})
return [
debugEventsPlugin,
debugBatchEventsPlugin,
debugMutationsPlugin,
editorPropsPlugin,
domPlugin,
restoreDomPlugin,
placeholderPlugin,

View File

@@ -8,7 +8,8 @@ function RestoreDOMPlugin() {
*/
function restoreDOM(editor) {
editor.setState({ contentKey: editor.state.contentKey + 1 })
const tmp = editor.tmp.contentRef.current.tmp
tmp.contentKey = tmp.contentKey + 1
}
return {