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:
@@ -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": {
|
||||
|
@@ -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}
|
||||
|
@@ -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 `` 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 `𐃁` 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"`.
|
610
packages/slate-react/src/plugins/android/composition-manager.js
Normal file
610
packages/slate-react/src/plugins/android/composition-manager.js
Normal 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
|
@@ -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.
|
97
packages/slate-react/src/plugins/android/diff-text.js
Normal file
97
packages/slate-react/src/plugins/android/diff-text.js
Normal 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,
|
||||
}
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
@@ -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
|
||||
|
@@ -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
|
||||
}
|
@@ -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)
|
||||
}
|
@@ -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]
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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,
|
||||
|
@@ -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 {
|
||||
|
Reference in New Issue
Block a user