mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-08-11 09:43:58 +02:00
Android input handing rewrite (#4988)
* wip * wip * wip - fully working without hard marks * fix editor crashes when inserting/deleting at the edges of marks * fix various restore dom related crashes * fix delete with pending changes, zero widths on android, mutation tracking * track placeholder delete in detached strings, zero-widths * wip mark placeholders * get rid of mutation detection in favor of beforeinput * fix various selection race conditions * fix various crashes when deleting at the beginning of nodes * wip diff transforms, selection handling fixes * cleanup restoreDOM and fix noop restore edge-case * fix mark placeholders * fix toSlatePoint edge-case * properly flush user select with pending changes * Prevent editor crash when deleting before a non-contenteditable element * wip markdown shortcut example * transform pending changes and selection by remote changes, simplify pending actions, handle all input types * improve change transform, mark(-placeholder) handling * manually handle gboard bug, fix restoredom nested editor * fix parent mutation condition * cleanup, mark placeholder fixes * mark placeholder fixes * fix mark placeholder condition * hide placeholder if we have pending diffs * cleanup * yarn install * add workaround for swiftkey placeholder issue * cleanup * add changeset * feat(slate-react): fix edge-case crash, add androidPendingDiffs, rename scheduleFlushPendingChanges * flush pending selection on same line without pending changes/action * keep formatting of pending diffs when adding/removing selection marks * unref selection ref on unmatching dom state * improve markdown shortcut example flush trigger to show how a more generic solution would work * fix markdown shortcut example trigger logic * fix isInsertAfterMarkPlaceholder logic
This commit is contained in:
6
.changeset/fresh-taxis-itch.md
Normal file
6
.changeset/fresh-taxis-itch.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'slate-react': minor
|
||||
'slate': patch
|
||||
---
|
||||
|
||||
Android input handling rewrite, replace composition insert prefixes with decoration based mark placeholders
|
@@ -1,617 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Editor, Element, Node, Range, Transforms, Path, Text } from 'slate'
|
||||
import throttle from 'lodash/throttle'
|
||||
import debounce from 'lodash/debounce'
|
||||
import scrollIntoView from 'scroll-into-view-if-needed'
|
||||
|
||||
import { DefaultPlaceholder, ReactEditor } from '../..'
|
||||
import { ReadOnlyContext } from '../../hooks/use-read-only'
|
||||
import { useSlate } from '../../hooks/use-slate'
|
||||
import { useIsomorphicLayoutEffect } from '../../hooks/use-isomorphic-layout-effect'
|
||||
import { DecorateContext } from '../../hooks/use-decorate'
|
||||
import {
|
||||
DOMElement,
|
||||
isDOMElement,
|
||||
isDOMNode,
|
||||
getDefaultView,
|
||||
getClipboardData,
|
||||
} from '../../utils/dom'
|
||||
import {
|
||||
EDITOR_TO_ELEMENT,
|
||||
EDITOR_TO_WINDOW,
|
||||
ELEMENT_TO_NODE,
|
||||
IS_FOCUSED,
|
||||
IS_READ_ONLY,
|
||||
NODE_TO_ELEMENT,
|
||||
PLACEHOLDER_SYMBOL,
|
||||
IS_COMPOSING,
|
||||
IS_ON_COMPOSITION_END,
|
||||
EDITOR_ON_COMPOSITION_TEXT,
|
||||
} from '../../utils/weak-maps'
|
||||
import { normalizeTextInsertionRange } from './diff-text'
|
||||
|
||||
import { EditableProps, hasTarget } from '../editable'
|
||||
import useChildren from '../../hooks/use-children'
|
||||
import {
|
||||
defaultDecorate,
|
||||
hasEditableTarget,
|
||||
isEventHandled,
|
||||
isDOMEventHandled,
|
||||
isTargetInsideNonReadonlyVoid,
|
||||
} from '../editable'
|
||||
|
||||
import { useAndroidInputManager } from './use-android-input-manager'
|
||||
import { useContentKey } from '../../hooks/use-content-key'
|
||||
|
||||
/**
|
||||
* Editable.
|
||||
*/
|
||||
|
||||
// https://github.com/facebook/draft-js/blob/main/src/component/handlers/composition/DraftEditorCompositionHandler.js#L41
|
||||
// When using keyboard English association function, conpositionEnd triggered too fast, resulting in after `insertText` still maintain association state.
|
||||
const RESOLVE_DELAY = 20
|
||||
|
||||
export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
||||
const {
|
||||
autoFocus,
|
||||
decorate = defaultDecorate,
|
||||
onDOMBeforeInput: propsOnDOMBeforeInput,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
renderElement,
|
||||
renderLeaf,
|
||||
renderPlaceholder = props => <DefaultPlaceholder {...props} />,
|
||||
style = {},
|
||||
as: Component = 'div',
|
||||
...attributes
|
||||
} = props
|
||||
const editor = useSlate()
|
||||
// Rerender editor when composition status changed
|
||||
const [isComposing, setIsComposing] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const inputManager = useAndroidInputManager(ref)
|
||||
|
||||
// Update internal state on each render.
|
||||
IS_READ_ONLY.set(editor, readOnly)
|
||||
|
||||
// Keep track of some state for the event handler logic.
|
||||
const state = useMemo(
|
||||
() => ({
|
||||
isComposing: false,
|
||||
isUpdatingSelection: false,
|
||||
latestElement: null as DOMElement | null,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const contentKey = useContentKey(editor)
|
||||
|
||||
// Whenever the editor updates...
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
// Update element-related weak maps with the DOM element ref.
|
||||
let window
|
||||
|
||||
if (ref.current && (window = getDefaultView(ref.current))) {
|
||||
EDITOR_TO_WINDOW.set(editor, window)
|
||||
EDITOR_TO_ELEMENT.set(editor, ref.current)
|
||||
NODE_TO_ELEMENT.set(editor, ref.current)
|
||||
ELEMENT_TO_NODE.set(ref.current, editor)
|
||||
} else {
|
||||
NODE_TO_ELEMENT.delete(editor)
|
||||
}
|
||||
|
||||
try {
|
||||
// Make sure the DOM selection state is in sync.
|
||||
const { selection } = editor
|
||||
const root = ReactEditor.findDocumentOrShadowRoot(editor)
|
||||
const domSelection = root.getSelection()
|
||||
|
||||
if (
|
||||
state.isComposing ||
|
||||
!domSelection ||
|
||||
!ReactEditor.isFocused(editor)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasDomSelection = domSelection.type !== 'None'
|
||||
|
||||
// If the DOM selection is properly unset, we're done.
|
||||
if (!selection && !hasDomSelection) {
|
||||
return
|
||||
}
|
||||
|
||||
// verify that the dom selection is in the editor
|
||||
const editorElement = EDITOR_TO_ELEMENT.get(editor)!
|
||||
let hasDomSelectionInEditor = false
|
||||
if (
|
||||
editorElement.contains(domSelection.anchorNode) &&
|
||||
editorElement.contains(domSelection.focusNode)
|
||||
) {
|
||||
hasDomSelectionInEditor = true
|
||||
}
|
||||
|
||||
// If the DOM selection is in the editor and the editor selection is already correct, we're done.
|
||||
if (hasDomSelection && hasDomSelectionInEditor && selection) {
|
||||
const slateRange = ReactEditor.toSlateRange(editor, domSelection, {
|
||||
exactMatch: true,
|
||||
suppressThrow: true,
|
||||
})
|
||||
if (slateRange && Range.equals(slateRange, selection)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// when <Editable/> is being controlled through external value
|
||||
// then its children might just change - DOM responds to it on its own
|
||||
// but Slate's value is not being updated through any operation
|
||||
// and thus it doesn't transform selection on its own
|
||||
if (selection && !ReactEditor.hasRange(editor, selection)) {
|
||||
editor.selection = ReactEditor.toSlateRange(editor, domSelection, {
|
||||
exactMatch: false,
|
||||
suppressThrow: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise the DOM selection is out of sync, so update it.
|
||||
const el = ReactEditor.toDOMNode(editor, editor)
|
||||
state.isUpdatingSelection = true
|
||||
|
||||
const newDomRange = selection && ReactEditor.toDOMRange(editor, selection)
|
||||
|
||||
if (newDomRange) {
|
||||
if (Range.isBackward(selection!)) {
|
||||
domSelection.setBaseAndExtent(
|
||||
newDomRange.endContainer,
|
||||
newDomRange.endOffset,
|
||||
newDomRange.startContainer,
|
||||
newDomRange.startOffset
|
||||
)
|
||||
} else {
|
||||
domSelection.setBaseAndExtent(
|
||||
newDomRange.startContainer,
|
||||
newDomRange.startOffset,
|
||||
newDomRange.endContainer,
|
||||
newDomRange.endOffset
|
||||
)
|
||||
}
|
||||
const leafEl = newDomRange.startContainer.parentElement!
|
||||
leafEl.getBoundingClientRect = newDomRange.getBoundingClientRect.bind(
|
||||
newDomRange
|
||||
)
|
||||
scrollIntoView(leafEl, {
|
||||
scrollMode: 'if-needed',
|
||||
boundary: el,
|
||||
})
|
||||
// @ts-ignore
|
||||
delete leafEl.getBoundingClientRect
|
||||
} else {
|
||||
domSelection.removeAllRanges()
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
state.isUpdatingSelection = false
|
||||
})
|
||||
} catch {
|
||||
// Failed to update selection, likely due to reconciliation error
|
||||
state.isUpdatingSelection = false
|
||||
}
|
||||
})
|
||||
|
||||
// The autoFocus TextareaHTMLAttribute doesn't do anything on a div, so it
|
||||
// needs to be manually focused.
|
||||
useEffect(() => {
|
||||
if (ref.current && autoFocus) {
|
||||
ref.current.focus()
|
||||
}
|
||||
}, [autoFocus])
|
||||
|
||||
// Listen on the native `selectionchange` event to be able to update any time
|
||||
// the selection changes. This is required because React's `onSelect` is leaky
|
||||
// and non-standard so it doesn't fire until after a selection has been
|
||||
// released. This causes issues in situations where another change happens
|
||||
// while a selection is being dragged.
|
||||
const onDOMSelectionChange = useCallback(
|
||||
throttle(() => {
|
||||
try {
|
||||
if (
|
||||
!state.isComposing &&
|
||||
!state.isUpdatingSelection &&
|
||||
!inputManager.isReconciling.current
|
||||
) {
|
||||
const root = ReactEditor.findDocumentOrShadowRoot(editor)
|
||||
const { activeElement } = root
|
||||
const el = ReactEditor.toDOMNode(editor, editor)
|
||||
const domSelection = root.getSelection()
|
||||
|
||||
if (activeElement === el) {
|
||||
state.latestElement = activeElement
|
||||
IS_FOCUSED.set(editor, true)
|
||||
} else {
|
||||
IS_FOCUSED.delete(editor)
|
||||
}
|
||||
|
||||
if (!domSelection) {
|
||||
return Transforms.deselect(editor)
|
||||
}
|
||||
|
||||
const { anchorNode, focusNode } = domSelection
|
||||
|
||||
const anchorNodeSelectable =
|
||||
hasEditableTarget(editor, anchorNode) ||
|
||||
isTargetInsideNonReadonlyVoid(editor, anchorNode)
|
||||
|
||||
const focusNodeSelectable =
|
||||
hasEditableTarget(editor, focusNode) ||
|
||||
isTargetInsideNonReadonlyVoid(editor, focusNode)
|
||||
|
||||
if (anchorNodeSelectable && focusNodeSelectable) {
|
||||
const range = ReactEditor.toSlateRange(editor, domSelection, {
|
||||
exactMatch: false,
|
||||
suppressThrow: false,
|
||||
})
|
||||
Transforms.select(editor, range)
|
||||
} else {
|
||||
Transforms.deselect(editor)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Failed to update selection, likely due to reconciliation error
|
||||
}
|
||||
}, 100),
|
||||
[readOnly]
|
||||
)
|
||||
|
||||
const scheduleOnDOMSelectionChange = useMemo(
|
||||
() => debounce(onDOMSelectionChange, 0),
|
||||
[onDOMSelectionChange]
|
||||
)
|
||||
|
||||
// Listen on the native `beforeinput` event to get real "Level 2" events. This
|
||||
// is required because React's `beforeinput` is fake and never really attaches
|
||||
// to the real event sadly. (2019/11/01)
|
||||
// https://github.com/facebook/react/issues/11211
|
||||
const onDOMBeforeInput = useCallback(
|
||||
(event: InputEvent) => {
|
||||
if (
|
||||
!readOnly &&
|
||||
hasEditableTarget(editor, event.target) &&
|
||||
!isDOMEventHandled(event, propsOnDOMBeforeInput)
|
||||
) {
|
||||
// Some IMEs/Chrome extensions like e.g. Grammarly set the selection immediately before
|
||||
// triggering a `beforeinput` expecting the change to be applied to the immediately before
|
||||
// set selection.
|
||||
scheduleOnDOMSelectionChange.flush()
|
||||
|
||||
inputManager.onUserInput()
|
||||
}
|
||||
},
|
||||
[readOnly, propsOnDOMBeforeInput]
|
||||
)
|
||||
|
||||
// Attach a native DOM event handler for `beforeinput` events, because React's
|
||||
// built-in `onBeforeInput` is actually a leaky polyfill that doesn't expose
|
||||
// real `beforeinput` events sadly... (2019/11/04)
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
const node = ref.current
|
||||
|
||||
// @ts-ignore The `beforeinput` event isn't recognized.
|
||||
node?.addEventListener('beforeinput', onDOMBeforeInput)
|
||||
|
||||
// @ts-ignore The `beforeinput` event isn't recognized.
|
||||
return () => node?.removeEventListener('beforeinput', onDOMBeforeInput)
|
||||
}, [contentKey, propsOnDOMBeforeInput])
|
||||
|
||||
// Attach a native DOM event handler for `selectionchange`, because React's
|
||||
// built-in `onSelect` handler doesn't fire for all selection changes. It's a
|
||||
// leaky polyfill that only fires on keypresses or clicks. Instead, we want to
|
||||
// fire for any change to the selection inside the editor. (2019/11/04)
|
||||
// https://github.com/facebook/react/issues/5785
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
const window = ReactEditor.getWindow(editor)
|
||||
window.document.addEventListener(
|
||||
'selectionchange',
|
||||
scheduleOnDOMSelectionChange
|
||||
)
|
||||
|
||||
return () => {
|
||||
window.document.removeEventListener(
|
||||
'selectionchange',
|
||||
scheduleOnDOMSelectionChange
|
||||
)
|
||||
}
|
||||
}, [scheduleOnDOMSelectionChange])
|
||||
|
||||
const decorations = decorate([editor, []])
|
||||
|
||||
if (
|
||||
placeholder &&
|
||||
editor.children.length === 1 &&
|
||||
Array.from(Node.texts(editor)).length === 1 &&
|
||||
Node.string(editor) === '' &&
|
||||
!isComposing
|
||||
) {
|
||||
const start = Editor.start(editor, [])
|
||||
decorations.push({
|
||||
[PLACEHOLDER_SYMBOL]: true,
|
||||
placeholder,
|
||||
anchor: start,
|
||||
focus: start,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ReadOnlyContext.Provider value={readOnly}>
|
||||
<DecorateContext.Provider value={decorate}>
|
||||
<Component
|
||||
key={contentKey}
|
||||
role={readOnly ? undefined : 'textbox'}
|
||||
{...attributes}
|
||||
spellCheck={attributes.spellCheck}
|
||||
autoCorrect={attributes.autoCorrect}
|
||||
autoCapitalize={attributes.autoCapitalize}
|
||||
data-slate-editor
|
||||
data-slate-node="value"
|
||||
contentEditable={readOnly ? undefined : true}
|
||||
suppressContentEditableWarning
|
||||
ref={ref}
|
||||
style={{
|
||||
// Allow positioning relative to the editable element.
|
||||
position: 'relative',
|
||||
// Prevent the default outline styles.
|
||||
outline: 'none',
|
||||
// Preserve adjacent whitespace and new lines.
|
||||
whiteSpace: 'pre-wrap',
|
||||
// Allow words to break if they are too long.
|
||||
wordWrap: 'break-word',
|
||||
// Allow for passed-in styles to override anything.
|
||||
...style,
|
||||
}}
|
||||
onCopy={useCallback(
|
||||
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
hasEditableTarget(editor, event.target) &&
|
||||
!isEventHandled(event, attributes.onCopy)
|
||||
) {
|
||||
event.preventDefault()
|
||||
ReactEditor.setFragmentData(editor, event.clipboardData, 'copy')
|
||||
}
|
||||
},
|
||||
[attributes.onCopy]
|
||||
)}
|
||||
onCut={useCallback(
|
||||
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!readOnly &&
|
||||
hasEditableTarget(editor, event.target) &&
|
||||
!isEventHandled(event, attributes.onCut)
|
||||
) {
|
||||
event.preventDefault()
|
||||
ReactEditor.setFragmentData(editor, event.clipboardData, 'cut')
|
||||
const { selection } = editor
|
||||
|
||||
if (selection) {
|
||||
if (Range.isExpanded(selection)) {
|
||||
Editor.deleteFragment(editor)
|
||||
} else {
|
||||
const node = Node.parent(editor, selection.anchor.path)
|
||||
if (Editor.isVoid(editor, node)) {
|
||||
Transforms.delete(editor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[readOnly, attributes.onCut]
|
||||
)}
|
||||
onFocus={useCallback(
|
||||
(event: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!readOnly &&
|
||||
!state.isUpdatingSelection &&
|
||||
hasEditableTarget(editor, event.target) &&
|
||||
!isEventHandled(event, attributes.onFocus)
|
||||
) {
|
||||
const root = ReactEditor.findDocumentOrShadowRoot(editor)
|
||||
state.latestElement = root.activeElement
|
||||
|
||||
IS_FOCUSED.set(editor, true)
|
||||
}
|
||||
},
|
||||
[readOnly, attributes.onFocus]
|
||||
)}
|
||||
onBlur={useCallback(
|
||||
(event: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
readOnly ||
|
||||
state.isUpdatingSelection ||
|
||||
!hasEditableTarget(editor, event.target) ||
|
||||
isEventHandled(event, attributes.onBlur)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// COMPAT: If the current `activeElement` is still the previous
|
||||
// one, this is due to the window being blurred when the tab
|
||||
// itself becomes unfocused, so we want to abort early to allow to
|
||||
// editor to stay focused when the tab becomes focused again.
|
||||
const root = ReactEditor.findDocumentOrShadowRoot(editor)
|
||||
if (state.latestElement === root.activeElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const { relatedTarget } = event
|
||||
const el = ReactEditor.toDOMNode(editor, editor)
|
||||
|
||||
// COMPAT: The event should be ignored if the focus is returning
|
||||
// to the editor from an embedded editable element (eg. an <input>
|
||||
// element inside a void node).
|
||||
if (relatedTarget === el) {
|
||||
return
|
||||
}
|
||||
|
||||
// COMPAT: The event should be ignored if the focus is moving from
|
||||
// the editor to inside a void node's spacer element.
|
||||
if (
|
||||
isDOMElement(relatedTarget) &&
|
||||
relatedTarget.hasAttribute('data-slate-spacer')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// COMPAT: The event should be ignored if the focus is moving to a
|
||||
// non- editable section of an element that isn't a void node (eg.
|
||||
// a list item of the check list example).
|
||||
if (
|
||||
relatedTarget != null &&
|
||||
isDOMNode(relatedTarget) &&
|
||||
ReactEditor.hasDOMNode(editor, relatedTarget)
|
||||
) {
|
||||
const node = ReactEditor.toSlateNode(editor, relatedTarget)
|
||||
|
||||
if (Element.isElement(node) && !editor.isVoid(node)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
IS_FOCUSED.delete(editor)
|
||||
},
|
||||
[readOnly, attributes.onBlur]
|
||||
)}
|
||||
onClick={useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!readOnly &&
|
||||
hasTarget(editor, event.target) &&
|
||||
!isEventHandled(event, attributes.onClick) &&
|
||||
isDOMNode(event.target)
|
||||
) {
|
||||
const node = ReactEditor.toSlateNode(editor, event.target)
|
||||
const path = ReactEditor.findPath(editor, node)
|
||||
|
||||
// At this time, the Slate document may be arbitrarily different,
|
||||
// because onClick handlers can change the document before we get here.
|
||||
// Therefore we must check that this path actually exists,
|
||||
// and that it still refers to the same node.
|
||||
if (Editor.hasPath(editor, path)) {
|
||||
const lookupNode = Node.get(editor, path)
|
||||
if (lookupNode === node) {
|
||||
const start = Editor.start(editor, path)
|
||||
const end = Editor.end(editor, path)
|
||||
|
||||
const startVoid = Editor.void(editor, { at: start })
|
||||
const endVoid = Editor.void(editor, { at: end })
|
||||
|
||||
if (
|
||||
startVoid &&
|
||||
endVoid &&
|
||||
Path.equals(startVoid[1], endVoid[1])
|
||||
) {
|
||||
const range = Editor.range(editor, start)
|
||||
Transforms.select(editor, range)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[readOnly, attributes.onClick]
|
||||
)}
|
||||
onCompositionEnd={useCallback(
|
||||
(event: React.CompositionEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
hasEditableTarget(editor, event.target) &&
|
||||
!isEventHandled(event, attributes.onCompositionEnd)
|
||||
) {
|
||||
scheduleOnDOMSelectionChange.flush()
|
||||
setTimeout(() => {
|
||||
state.isComposing && setIsComposing(false)
|
||||
state.isComposing = false
|
||||
|
||||
IS_COMPOSING.set(editor, false)
|
||||
IS_ON_COMPOSITION_END.set(editor, true)
|
||||
|
||||
const insertedText =
|
||||
EDITOR_ON_COMPOSITION_TEXT.get(editor) || []
|
||||
|
||||
// `insertedText` is set in `MutationObserver` constructor.
|
||||
// If open phone keyboard association function, `CompositionEvent` will be triggered.
|
||||
if (!insertedText.length) {
|
||||
return
|
||||
}
|
||||
|
||||
EDITOR_ON_COMPOSITION_TEXT.set(editor, [])
|
||||
|
||||
const { selection } = editor
|
||||
|
||||
insertedText.forEach(insertion => {
|
||||
const text = insertion.text.insertText
|
||||
const at = normalizeTextInsertionRange(
|
||||
editor,
|
||||
selection,
|
||||
insertion
|
||||
)
|
||||
Transforms.setSelection(editor, at)
|
||||
Editor.insertText(editor, text)
|
||||
})
|
||||
}, RESOLVE_DELAY)
|
||||
}
|
||||
},
|
||||
[attributes.onCompositionEnd]
|
||||
)}
|
||||
onCompositionUpdate={useCallback(
|
||||
(event: React.CompositionEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
hasEditableTarget(editor, event.target) &&
|
||||
!isEventHandled(event, attributes.onCompositionUpdate)
|
||||
) {
|
||||
!state.isComposing && setIsComposing(true)
|
||||
state.isComposing = true
|
||||
IS_COMPOSING.set(editor, true)
|
||||
}
|
||||
},
|
||||
[attributes.onCompositionUpdate]
|
||||
)}
|
||||
onCompositionStart={useCallback(
|
||||
(event: React.CompositionEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
hasEditableTarget(editor, event.target) &&
|
||||
!isEventHandled(event, attributes.onCompositionStart)
|
||||
) {
|
||||
!state.isComposing && setIsComposing(true)
|
||||
state.isComposing = true
|
||||
IS_COMPOSING.set(editor, true)
|
||||
}
|
||||
},
|
||||
[attributes.onCompositionStart]
|
||||
)}
|
||||
onPaste={useCallback(
|
||||
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
// this will make application/x-slate-fragment exist when onPaste attributes is passed
|
||||
event.clipboardData = getClipboardData(event.clipboardData)
|
||||
// This unfortunately needs to be handled with paste events instead.
|
||||
if (
|
||||
hasEditableTarget(editor, event.target) &&
|
||||
!isEventHandled(event, attributes.onPaste) &&
|
||||
!readOnly
|
||||
) {
|
||||
event.preventDefault()
|
||||
ReactEditor.insertData(editor, event.clipboardData)
|
||||
}
|
||||
},
|
||||
[readOnly, attributes.onPaste]
|
||||
)}
|
||||
>
|
||||
{useChildren({
|
||||
decorations,
|
||||
node: editor,
|
||||
renderElement,
|
||||
renderPlaceholder,
|
||||
renderLeaf,
|
||||
selection: editor.selection,
|
||||
})}
|
||||
</Component>
|
||||
</DecorateContext.Provider>
|
||||
</ReadOnlyContext.Provider>
|
||||
)
|
||||
}
|
@@ -1,206 +0,0 @@
|
||||
import { ReactEditor } from '../../plugin/react-editor'
|
||||
import { Editor, Range, Transforms, Text } from 'slate'
|
||||
import {
|
||||
IS_ON_COMPOSITION_END,
|
||||
EDITOR_ON_COMPOSITION_TEXT,
|
||||
} from '../../utils/weak-maps'
|
||||
|
||||
import { DOMNode } from '../../utils/dom'
|
||||
|
||||
import {
|
||||
normalizeTextInsertionRange,
|
||||
combineInsertedText,
|
||||
TextInsertion,
|
||||
} from './diff-text'
|
||||
import {
|
||||
gatherMutationData,
|
||||
isDeletion,
|
||||
isLineBreak,
|
||||
isRemoveLeafNodes,
|
||||
isReplaceExpandedSelection,
|
||||
isTextInsertion,
|
||||
} from './mutation-detection'
|
||||
|
||||
// Replace with `const debug = console.log` to debug
|
||||
const debug = (...message: any[]) => {}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* The input manager attempts to map observed mutations on the document to a
|
||||
* set of operations in order to reconcile Slate's internal value with the DOM.
|
||||
*
|
||||
* Mutations are processed synchronously as they come in. Only mutations that occur
|
||||
* during a user input loop are processed, as other mutations can occur within the
|
||||
* document that were not initiated by user input.
|
||||
*
|
||||
* The mutation reconciliation process attempts to match mutations to the following
|
||||
* patterns:
|
||||
*
|
||||
* - Text updates
|
||||
* - Deletions
|
||||
* - Line breaks
|
||||
*
|
||||
* @param editor
|
||||
* @param restoreDOM
|
||||
*/
|
||||
|
||||
export class AndroidInputManager {
|
||||
constructor(private editor: ReactEditor, private restoreDOM: () => void) {
|
||||
this.editor = editor
|
||||
this.restoreDOM = restoreDOM
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MutationObserver flush
|
||||
*
|
||||
* @param mutations
|
||||
*/
|
||||
|
||||
flush = (mutations: MutationRecord[]) => {
|
||||
debug('flush')
|
||||
|
||||
try {
|
||||
this.reconcileMutations(mutations)
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err)
|
||||
|
||||
// Failed to reconcile mutations, restore DOM to its previous state
|
||||
this.restoreDOM()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a batch of mutations
|
||||
*
|
||||
* @param mutations
|
||||
*/
|
||||
|
||||
private reconcileMutations = (mutations: MutationRecord[]) => {
|
||||
const mutationData = gatherMutationData(this.editor, mutations)
|
||||
const { insertedText, removedNodes } = mutationData
|
||||
|
||||
debug('processMutations', mutations, mutationData)
|
||||
|
||||
if (isReplaceExpandedSelection(this.editor, mutationData)) {
|
||||
const text = combineInsertedText(insertedText)
|
||||
this.replaceExpandedSelection(text)
|
||||
} else if (isLineBreak(this.editor, mutationData)) {
|
||||
this.insertBreak()
|
||||
} else if (isRemoveLeafNodes(this.editor, mutationData)) {
|
||||
this.removeLeafNodes(removedNodes)
|
||||
} else if (isDeletion(this.editor, mutationData)) {
|
||||
this.deleteBackward()
|
||||
} else if (isTextInsertion(this.editor, mutationData)) {
|
||||
this.insertText(insertedText)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply text diff
|
||||
*/
|
||||
|
||||
private insertText = (insertedText: TextInsertion[]) => {
|
||||
debug('insertText')
|
||||
|
||||
const { selection } = this.editor
|
||||
|
||||
// If it is in composing or after `onCompositionend`, set `EDITOR_ON_COMPOSITION_TEXT` and return.
|
||||
// Text will be inserted on compositionend event.
|
||||
if (
|
||||
ReactEditor.isComposing(this.editor) ||
|
||||
IS_ON_COMPOSITION_END.get(this.editor)
|
||||
) {
|
||||
EDITOR_ON_COMPOSITION_TEXT.set(this.editor, insertedText)
|
||||
IS_ON_COMPOSITION_END.set(this.editor, false)
|
||||
return
|
||||
}
|
||||
|
||||
// Insert the batched text diffs
|
||||
insertedText.forEach(insertion => {
|
||||
const text = insertion.text.insertText
|
||||
const at = normalizeTextInsertionRange(this.editor, selection, insertion)
|
||||
Transforms.setSelection(this.editor, at)
|
||||
Editor.insertText(this.editor, text)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle line breaks
|
||||
*/
|
||||
|
||||
private insertBreak = () => {
|
||||
debug('insertBreak')
|
||||
|
||||
const { selection } = this.editor
|
||||
|
||||
Editor.insertBreak(this.editor)
|
||||
|
||||
this.restoreDOM()
|
||||
|
||||
if (selection) {
|
||||
// Compat: Move selection to the newly inserted block if it has not moved
|
||||
setTimeout(() => {
|
||||
if (
|
||||
this.editor.selection &&
|
||||
Range.equals(selection, this.editor.selection)
|
||||
) {
|
||||
Transforms.move(this.editor)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle expanded selection being deleted or replaced by text
|
||||
*/
|
||||
|
||||
private replaceExpandedSelection = (text: string) => {
|
||||
debug('replaceExpandedSelection')
|
||||
|
||||
// Delete expanded selection
|
||||
Editor.deleteFragment(this.editor)
|
||||
|
||||
if (text.length) {
|
||||
// Selection was replaced by text, insert the entire text diff
|
||||
Editor.insertText(this.editor, text)
|
||||
}
|
||||
|
||||
this.restoreDOM()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `backspace` that merges blocks
|
||||
*/
|
||||
|
||||
private deleteBackward = () => {
|
||||
debug('deleteBackward')
|
||||
|
||||
Editor.deleteBackward(this.editor)
|
||||
ReactEditor.focus(this.editor)
|
||||
|
||||
this.restoreDOM()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mutations that remove specific leaves
|
||||
*/
|
||||
private removeLeafNodes = (nodes: DOMNode[]) => {
|
||||
for (const node of nodes) {
|
||||
const slateNode = ReactEditor.toSlateNode(this.editor, node)
|
||||
|
||||
if (slateNode) {
|
||||
const path = ReactEditor.findPath(this.editor, slateNode)
|
||||
|
||||
Transforms.delete(this.editor, { at: path })
|
||||
this.restoreDOM()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AndroidInputManager
|
@@ -1,225 +0,0 @@
|
||||
import { Editor, Path, Range, Text } from 'slate'
|
||||
|
||||
import { ReactEditor } from '../../'
|
||||
import { DOMNode } from '../../utils/dom'
|
||||
|
||||
export type Diff = {
|
||||
start: number
|
||||
end: number
|
||||
insertText: string
|
||||
removeText: string
|
||||
}
|
||||
|
||||
export interface TextInsertion {
|
||||
text: Diff
|
||||
path: Path
|
||||
}
|
||||
|
||||
type TextRange = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of characters that are the same at the beginning of the
|
||||
* String.
|
||||
*
|
||||
* @param prev the previous text
|
||||
* @param next the next text
|
||||
* @returns the offset of the start of the difference; null if there is no difference
|
||||
*/
|
||||
function getDiffStart(prev: string, next: string): number | null {
|
||||
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 prev the previous text
|
||||
* @param next the next text
|
||||
* @param max the max length to test.
|
||||
* @returns number of characters that are the same at the end of the string
|
||||
*/
|
||||
function getDiffEnd(prev: string, next: string, max: number): number | null {
|
||||
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 prev the previous text
|
||||
* @param next the next text
|
||||
* @returns the difference text range; null if there are no differences.
|
||||
*/
|
||||
function getDiffOffsets(prev: string, next: string): TextRange | null {
|
||||
if (prev === next) return null
|
||||
const start = getDiffStart(prev, next)
|
||||
if (start === null) return null
|
||||
const maxEnd = Math.min(prev.length - start, next.length - start)
|
||||
const end = getDiffEnd(prev, next, maxEnd)!
|
||||
if (end === null) return null
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a text string and returns a slice from the string at the given text range
|
||||
*
|
||||
* @param text the text
|
||||
* @param offsets the text range
|
||||
* @returns the text slice at text range
|
||||
*/
|
||||
function sliceText(text: string, offsets: TextRange): string {
|
||||
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 prev the previous text
|
||||
* @param next the next text
|
||||
* @returns the text difference
|
||||
*/
|
||||
export function diffText(prev?: string, next?: string): Diff | null {
|
||||
if (prev === undefined || next === undefined) return null
|
||||
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,
|
||||
insertText,
|
||||
removeText,
|
||||
}
|
||||
}
|
||||
|
||||
export function combineInsertedText(insertedText: TextInsertion[]): string {
|
||||
return insertedText.reduce((acc, { text }) => `${acc}${text.insertText}`, '')
|
||||
}
|
||||
|
||||
export function getTextInsertion<T extends Editor>(
|
||||
editor: T,
|
||||
domNode: DOMNode
|
||||
): TextInsertion | undefined {
|
||||
const node = ReactEditor.toSlateNode(editor, domNode)
|
||||
|
||||
if (!Text.isText(node)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const prevText = node.text
|
||||
let nextText = domNode.textContent!
|
||||
|
||||
// textContent will pad an extra \n when the textContent ends with an \n
|
||||
if (nextText.endsWith('\n')) {
|
||||
nextText = nextText.slice(0, nextText.length - 1)
|
||||
}
|
||||
|
||||
// If the text is no different, there is no diff.
|
||||
if (nextText !== prevText) {
|
||||
const textDiff = diffText(prevText, nextText)
|
||||
if (textDiff !== null) {
|
||||
const textPath = ReactEditor.findPath(editor, node)
|
||||
|
||||
return {
|
||||
text: textDiff,
|
||||
path: textPath,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function normalizeTextInsertionRange(
|
||||
editor: Editor,
|
||||
range: Range | null,
|
||||
{ path, text }: TextInsertion
|
||||
) {
|
||||
const insertionRange = {
|
||||
anchor: { path, offset: text.start },
|
||||
focus: { path, offset: text.end },
|
||||
}
|
||||
|
||||
if (!range || !Range.isCollapsed(range)) {
|
||||
return insertionRange
|
||||
}
|
||||
|
||||
const { insertText, removeText } = text
|
||||
const isSingleCharacterInsertion =
|
||||
insertText.length === 1 || removeText.length === 1
|
||||
|
||||
/**
|
||||
* This code handles edge cases that arise from text diffing when the
|
||||
* inserted or removed character is a single character, and the character
|
||||
* right before or after the anchor is the same as the one being inserted or
|
||||
* removed.
|
||||
*
|
||||
* Take this example: hello|o
|
||||
*
|
||||
* If another `o` is inserted at the selection's anchor in the example above,
|
||||
* it should be inserted at the anchor, but using text diffing, we actually
|
||||
* detect that the character was inserted after the second `o`:
|
||||
*
|
||||
* helloo[o]|
|
||||
*
|
||||
* Instead, in these very specific edge cases, we assume that the character
|
||||
* needs to be inserted after the anchor rather than where the diff was found:
|
||||
*
|
||||
* hello[o]|o
|
||||
*/
|
||||
if (isSingleCharacterInsertion && Path.equals(range.anchor.path, path)) {
|
||||
const [text] = Array.from(
|
||||
Editor.nodes(editor, { at: range, match: Text.isText })
|
||||
)
|
||||
|
||||
if (text) {
|
||||
const [node] = text
|
||||
const { anchor } = range
|
||||
const characterBeforeAnchor = node.text[anchor.offset - 1]
|
||||
const characterAfterAnchor = node.text[anchor.offset]
|
||||
|
||||
if (insertText.length === 1 && insertText === characterAfterAnchor) {
|
||||
// Assume text should be inserted at the anchor
|
||||
return range
|
||||
}
|
||||
|
||||
if (removeText.length === 1 && removeText === characterBeforeAnchor) {
|
||||
// Assume text should be removed right before the anchor
|
||||
return {
|
||||
anchor: { path, offset: anchor.offset - 1 },
|
||||
focus: { path, offset: anchor.offset },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return insertionRange
|
||||
}
|
@@ -1 +0,0 @@
|
||||
export { AndroidEditable } from './android-editable'
|
@@ -1,142 +0,0 @@
|
||||
import { Editor, Node, Path, Range } from 'slate'
|
||||
|
||||
import { DOMNode } from '../../utils/dom'
|
||||
import { ReactEditor } from '../..'
|
||||
import { TextInsertion, getTextInsertion } from './diff-text'
|
||||
|
||||
interface MutationData {
|
||||
addedNodes: DOMNode[]
|
||||
removedNodes: DOMNode[]
|
||||
insertedText: TextInsertion[]
|
||||
characterDataMutations: MutationRecord[]
|
||||
}
|
||||
|
||||
type MutationDetection = (editor: Editor, mutationData: MutationData) => boolean
|
||||
|
||||
export function gatherMutationData(
|
||||
editor: Editor,
|
||||
mutations: MutationRecord[]
|
||||
): MutationData {
|
||||
const addedNodes: DOMNode[] = []
|
||||
const removedNodes: DOMNode[] = []
|
||||
const insertedText: TextInsertion[] = []
|
||||
const characterDataMutations: MutationRecord[] = []
|
||||
|
||||
mutations.forEach(mutation => {
|
||||
switch (mutation.type) {
|
||||
case 'childList': {
|
||||
if (mutation.addedNodes.length) {
|
||||
mutation.addedNodes.forEach(addedNode => {
|
||||
addedNodes.push(addedNode)
|
||||
})
|
||||
}
|
||||
|
||||
mutation.removedNodes.forEach(removedNode => {
|
||||
removedNodes.push(removedNode)
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
case 'characterData': {
|
||||
characterDataMutations.push(mutation)
|
||||
|
||||
// Changes to text nodes should consider the parent element
|
||||
const { parentNode } = mutation.target
|
||||
|
||||
if (!parentNode) {
|
||||
return
|
||||
}
|
||||
|
||||
const textInsertion = getTextInsertion(editor, parentNode)
|
||||
|
||||
if (!textInsertion) {
|
||||
return
|
||||
}
|
||||
|
||||
// If we've already detected a diff at that path, we can return early
|
||||
if (
|
||||
insertedText.some(({ path }) => Path.equals(path, textInsertion.path))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add the text diff to the array of detected text insertions that need to be reconciled
|
||||
insertedText.push(textInsertion)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { addedNodes, removedNodes, insertedText, characterDataMutations }
|
||||
}
|
||||
|
||||
/**
|
||||
* In general, when a line break occurs, there will be more `addedNodes` than `removedNodes`.
|
||||
*
|
||||
* This isn't always the case however. In some cases, there will be more `removedNodes` than
|
||||
* `addedNodes`.
|
||||
*
|
||||
* To account for these edge cases, the most reliable strategy to detect line break mutations
|
||||
* is to check whether a new block was inserted of the same type as the current block.
|
||||
*/
|
||||
export const isLineBreak: MutationDetection = (editor, { addedNodes }) => {
|
||||
const { selection } = editor
|
||||
const parentNode = selection
|
||||
? Node.parent(editor, selection.anchor.path)
|
||||
: null
|
||||
const parentDOMNode = parentNode
|
||||
? ReactEditor.toDOMNode(editor, parentNode)
|
||||
: null
|
||||
|
||||
if (!parentDOMNode) {
|
||||
return false
|
||||
}
|
||||
|
||||
return addedNodes.some(
|
||||
addedNode =>
|
||||
addedNode instanceof HTMLElement &&
|
||||
addedNode.tagName === parentDOMNode?.tagName
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* So long as we check for line break mutations before deletion mutations,
|
||||
* we can safely assume that a set of mutations was a deletion if there are
|
||||
* removed nodes.
|
||||
*/
|
||||
export const isDeletion: MutationDetection = (_, { removedNodes }) => {
|
||||
return removedNodes.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* If the selection was expanded and there are removed nodes,
|
||||
* the contents of the selection need to be replaced with the diff
|
||||
*/
|
||||
export const isReplaceExpandedSelection: MutationDetection = (
|
||||
{ selection },
|
||||
{ removedNodes }
|
||||
) => {
|
||||
return selection
|
||||
? Range.isExpanded(selection) && removedNodes.length > 0
|
||||
: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain text insertion
|
||||
*/
|
||||
export const isTextInsertion: MutationDetection = (_, { insertedText }) => {
|
||||
return insertedText.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge case. Detect mutations that remove leaf nodes and also update character data
|
||||
*/
|
||||
export const isRemoveLeafNodes: MutationDetection = (
|
||||
_,
|
||||
{ addedNodes, characterDataMutations, removedNodes }
|
||||
) => {
|
||||
return (
|
||||
removedNodes.length > 0 &&
|
||||
addedNodes.length === 0 &&
|
||||
characterDataMutations.length > 0
|
||||
)
|
||||
}
|
@@ -1,54 +0,0 @@
|
||||
import { RefObject, useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { useSlateStatic } from '../../hooks/use-slate-static'
|
||||
|
||||
import { AndroidInputManager } from './android-input-manager'
|
||||
import { useRestoreDom } from './use-restore-dom'
|
||||
import { useMutationObserver } from './use-mutation-observer'
|
||||
import { useTrackUserInput } from './use-track-user-input'
|
||||
|
||||
const MUTATION_OBSERVER_CONFIG: MutationObserverInit = {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
characterDataOldValue: true,
|
||||
subtree: true,
|
||||
}
|
||||
|
||||
export function useAndroidInputManager(node: RefObject<HTMLElement>) {
|
||||
const editor = useSlateStatic()
|
||||
|
||||
const { receivedUserInput, onUserInput } = useTrackUserInput()
|
||||
const restoreDom = useRestoreDom(node, receivedUserInput)
|
||||
|
||||
const inputManager = useMemo(
|
||||
() => new AndroidInputManager(editor, restoreDom),
|
||||
[restoreDom, editor]
|
||||
)
|
||||
|
||||
const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isReconciling = useRef(false)
|
||||
const flush = useCallback((mutations: MutationRecord[]) => {
|
||||
if (!receivedUserInput.current) {
|
||||
return
|
||||
}
|
||||
|
||||
isReconciling.current = true
|
||||
inputManager.flush(mutations)
|
||||
|
||||
if (timeoutId.current) {
|
||||
clearTimeout(timeoutId.current)
|
||||
}
|
||||
|
||||
timeoutId.current = setTimeout(() => {
|
||||
isReconciling.current = false
|
||||
timeoutId.current = null
|
||||
}, 250)
|
||||
}, [])
|
||||
|
||||
useMutationObserver(node, flush, MUTATION_OBSERVER_CONFIG)
|
||||
|
||||
return {
|
||||
isReconciling,
|
||||
onUserInput,
|
||||
}
|
||||
}
|
@@ -1,81 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import { Node as SlateNode, Path } from 'slate'
|
||||
import { ReactEditor, useSlateStatic } from '../..'
|
||||
import { DOMNode, isDOMElement } from '../../utils/dom'
|
||||
import { ELEMENT_TO_NODE, NODE_TO_RESTORE_DOM } from '../../utils/weak-maps'
|
||||
import { useMutationObserver } from './use-mutation-observer'
|
||||
|
||||
const MUTATION_OBSERVER_CONFIG: MutationObserverInit = {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
}
|
||||
|
||||
function findClosestKnowSlateNode(domNode: DOMNode): SlateNode | null {
|
||||
let domEl = isDOMElement(domNode) ? domNode : domNode.parentElement
|
||||
|
||||
if (domEl && !domEl.hasAttribute('data-slate-node')) {
|
||||
domEl = domEl.closest(`[data-slate-node]`)
|
||||
}
|
||||
|
||||
const slateNode = domEl && ELEMENT_TO_NODE.get(domEl as HTMLElement)
|
||||
if (slateNode) {
|
||||
return slateNode
|
||||
}
|
||||
|
||||
// Unknown dom element with a slate-slate-node attribute => the IME
|
||||
// most likely duplicated the node so we have to restore the parent
|
||||
return domEl?.parentElement
|
||||
? findClosestKnowSlateNode(domEl.parentElement)
|
||||
: null
|
||||
}
|
||||
|
||||
export function useRestoreDom(
|
||||
node: React.RefObject<HTMLElement>,
|
||||
receivedUserInput: React.RefObject<boolean>
|
||||
) {
|
||||
const editor = useSlateStatic()
|
||||
const mutatedNodes = useRef<Set<SlateNode>>(new Set())
|
||||
|
||||
const handleDOMMutation = useCallback((mutations: MutationRecord[]) => {
|
||||
if (!receivedUserInput.current) {
|
||||
return
|
||||
}
|
||||
|
||||
mutations.forEach(({ target }) => {
|
||||
const slateNode = findClosestKnowSlateNode(target)
|
||||
if (!slateNode) {
|
||||
return
|
||||
}
|
||||
|
||||
return mutatedNodes.current.add(slateNode)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useMutationObserver(node, handleDOMMutation, MUTATION_OBSERVER_CONFIG)
|
||||
|
||||
// Clear mutated nodes on every render
|
||||
mutatedNodes.current.clear()
|
||||
const restore = useCallback(() => {
|
||||
const mutated = Array.from(mutatedNodes.current.values())
|
||||
|
||||
// Filter out child nodes of nodes that will be restored anyway
|
||||
const nodesToRestore = mutated.filter(
|
||||
n =>
|
||||
!mutated.some(m =>
|
||||
Path.isParent(
|
||||
ReactEditor.findPath(editor, m),
|
||||
ReactEditor.findPath(editor, n)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
nodesToRestore.forEach(n => {
|
||||
NODE_TO_RESTORE_DOM.get(n)?.()
|
||||
})
|
||||
|
||||
mutatedNodes.current.clear()
|
||||
}, [])
|
||||
|
||||
return restore
|
||||
}
|
@@ -1,38 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { ReactEditor } from '../..'
|
||||
import { useSlateStatic } from '../../hooks/use-slate-static'
|
||||
|
||||
export function useTrackUserInput() {
|
||||
const editor = useSlateStatic()
|
||||
const receivedUserInput = useRef<boolean>(false)
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
const onUserInput = useCallback(() => {
|
||||
if (receivedUserInput.current === false) {
|
||||
const window = ReactEditor.getWindow(editor)
|
||||
|
||||
receivedUserInput.current = true
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
window.cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
|
||||
animationFrameRef.current = window.requestAnimationFrame(() => {
|
||||
receivedUserInput.current = false
|
||||
animationFrameRef.current = null
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Reset user input tracking on every render
|
||||
if (receivedUserInput.current) {
|
||||
receivedUserInput.current = false
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
receivedUserInput,
|
||||
onUserInput,
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,6 @@ import {
|
||||
RenderLeafProps,
|
||||
RenderPlaceholderProps,
|
||||
} from './editable'
|
||||
import { useContentKey } from '../hooks/use-content-key'
|
||||
import { IS_ANDROID } from '../utils/environment'
|
||||
|
||||
/**
|
||||
@@ -133,14 +132,7 @@ const Element = (props: {
|
||||
}
|
||||
})
|
||||
|
||||
const content = renderElement({ attributes, children, element })
|
||||
|
||||
if (IS_ANDROID) {
|
||||
const contentKey = useContentKey(element)
|
||||
return <Fragment key={contentKey}>{content}</Fragment>
|
||||
}
|
||||
|
||||
return content
|
||||
return renderElement({ attributes, children, element })
|
||||
}
|
||||
|
||||
const MemoizedElement = React.memo(Element, (prev, next) => {
|
||||
|
@@ -1,8 +1,12 @@
|
||||
import React, { useRef, useEffect } from 'react'
|
||||
import { Element, Text } from 'slate'
|
||||
import String from './string'
|
||||
import { PLACEHOLDER_SYMBOL } from '../utils/weak-maps'
|
||||
import {
|
||||
PLACEHOLDER_SYMBOL,
|
||||
EDITOR_TO_PLACEHOLDER_ELEMENT,
|
||||
} from '../utils/weak-maps'
|
||||
import { RenderLeafProps, RenderPlaceholderProps } from './editable'
|
||||
import { useSlateStatic } from '../hooks/use-slate-static'
|
||||
|
||||
/**
|
||||
* Individual leaves in a text node with unique formatting.
|
||||
@@ -26,6 +30,7 @@ const Leaf = (props: {
|
||||
} = props
|
||||
|
||||
const placeholderRef = useRef<HTMLSpanElement | null>(null)
|
||||
const editor = useSlateStatic()
|
||||
|
||||
useEffect(() => {
|
||||
const placeholderEl = placeholderRef?.current
|
||||
@@ -38,9 +43,11 @@ const Leaf = (props: {
|
||||
}
|
||||
|
||||
editorEl.style.minHeight = `${placeholderEl.clientHeight}px`
|
||||
EDITOR_TO_PLACEHOLDER_ELEMENT.set(editor, placeholderEl)
|
||||
|
||||
return () => {
|
||||
editorEl.style.minHeight = 'auto'
|
||||
EDITOR_TO_PLACEHOLDER_ELEMENT.delete(editor)
|
||||
}
|
||||
}, [placeholderRef, leaf])
|
||||
|
||||
|
@@ -0,0 +1,58 @@
|
||||
import { RefObject } from 'react'
|
||||
import { ReactEditor } from '../../plugin/react-editor'
|
||||
import { isTrackedMutation } from '../../utils/dom'
|
||||
|
||||
export type RestoreDOMManager = {
|
||||
registerMutations: (mutations: MutationRecord[]) => void
|
||||
restoreDOM: () => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export const createRestoreDomManager = (
|
||||
editor: ReactEditor,
|
||||
receivedUserInput: RefObject<boolean>
|
||||
): RestoreDOMManager => {
|
||||
let bufferedMutations: MutationRecord[] = []
|
||||
|
||||
const clear = () => {
|
||||
bufferedMutations = []
|
||||
}
|
||||
|
||||
const registerMutations = (mutations: MutationRecord[]) => {
|
||||
if (!receivedUserInput.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const trackedMutations = mutations.filter(mutation =>
|
||||
isTrackedMutation(editor, mutation, mutations)
|
||||
)
|
||||
|
||||
bufferedMutations.push(...trackedMutations)
|
||||
}
|
||||
|
||||
function restoreDOM() {
|
||||
bufferedMutations.reverse().forEach(mutation => {
|
||||
if (mutation.type === 'characterData') {
|
||||
mutation.target.textContent = mutation.oldValue
|
||||
return
|
||||
}
|
||||
|
||||
mutation.removedNodes.forEach(node => {
|
||||
mutation.target.insertBefore(node, mutation.nextSibling)
|
||||
})
|
||||
|
||||
mutation.addedNodes.forEach(node => {
|
||||
mutation.target.removeChild(node)
|
||||
})
|
||||
})
|
||||
|
||||
// Clear buffered mutations to ensure we don't undo them twice
|
||||
clear()
|
||||
}
|
||||
|
||||
return {
|
||||
registerMutations,
|
||||
restoreDOM,
|
||||
clear,
|
||||
}
|
||||
}
|
@@ -0,0 +1,77 @@
|
||||
import React, { Component, ComponentType, ContextType, RefObject } from 'react'
|
||||
import { EditorContext } from '../../hooks/use-slate-static'
|
||||
import { IS_ANDROID } from '../../utils/environment'
|
||||
import {
|
||||
createRestoreDomManager,
|
||||
RestoreDOMManager,
|
||||
} from './restore-dom-manager'
|
||||
|
||||
const MUTATION_OBSERVER_CONFIG: MutationObserverInit = {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
characterDataOldValue: true,
|
||||
}
|
||||
|
||||
type RestoreDOMProps = {
|
||||
receivedUserInput: RefObject<boolean>
|
||||
node: RefObject<HTMLDivElement>
|
||||
}
|
||||
|
||||
// We have to use a class component here since we rely on `getSnapshotBeforeUpdate` which has no FC equivalent
|
||||
// to run code synchronously immediately before react commits the component update to the DOM.
|
||||
class RestoreDOMComponent extends Component<RestoreDOMProps> {
|
||||
static contextType = EditorContext
|
||||
context: ContextType<typeof EditorContext> = null
|
||||
|
||||
private manager: RestoreDOMManager | null = null
|
||||
private mutationObserver: MutationObserver | null = null
|
||||
|
||||
observe() {
|
||||
const { node } = this.props
|
||||
if (!node.current) {
|
||||
throw new Error('Failed to attach MutationObserver, `node` is undefined')
|
||||
}
|
||||
|
||||
this.mutationObserver?.observe(node.current, MUTATION_OBSERVER_CONFIG)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { receivedUserInput } = this.props
|
||||
const editor = this.context!
|
||||
|
||||
this.manager = createRestoreDomManager(editor, receivedUserInput)
|
||||
this.mutationObserver = new MutationObserver(this.manager.registerMutations)
|
||||
|
||||
this.observe()
|
||||
}
|
||||
|
||||
getSnapshotBeforeUpdate() {
|
||||
const pendingMutations = this.mutationObserver?.takeRecords()
|
||||
if (pendingMutations?.length) {
|
||||
this.manager?.registerMutations(pendingMutations)
|
||||
}
|
||||
|
||||
this.mutationObserver?.disconnect()
|
||||
this.manager?.restoreDOM()
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.manager?.clear()
|
||||
this.observe()
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mutationObserver?.disconnect()
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export const RestoreDOM: ComponentType<RestoreDOMProps> = IS_ANDROID
|
||||
? RestoreDOMComponent
|
||||
: ({ children }) => <>{children}</>
|
@@ -3,6 +3,8 @@ import { Editor, Text, Path, Element, Node } from 'slate'
|
||||
|
||||
import { ReactEditor, useSlateStatic } from '..'
|
||||
import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect'
|
||||
import { IS_ANDROID } from '../utils/environment'
|
||||
import { MARK_PLACEHOLDER_SYMBOL } from '../utils/weak-maps'
|
||||
|
||||
/**
|
||||
* Leaf content strings.
|
||||
@@ -18,6 +20,7 @@ const String = (props: {
|
||||
const editor = useSlateStatic()
|
||||
const path = ReactEditor.findPath(editor, text)
|
||||
const parentPath = Path.parent(path)
|
||||
const isMarkPlaceholder = leaf[MARK_PLACEHOLDER_SYMBOL] === true
|
||||
|
||||
// COMPAT: Render text inside void nodes with a zero-width space.
|
||||
// So the node can contain selection but the text is not visible.
|
||||
@@ -34,14 +37,14 @@ const String = (props: {
|
||||
!editor.isInline(parent) &&
|
||||
Editor.string(editor, parentPath) === ''
|
||||
) {
|
||||
return <ZeroWidthString isLineBreak />
|
||||
return <ZeroWidthString isLineBreak isMarkPlaceholder={isMarkPlaceholder} />
|
||||
}
|
||||
|
||||
// COMPAT: If the text is empty, it's because it's on the edge of an inline
|
||||
// node, so we render a zero-width space so that the selection can be
|
||||
// inserted next to it still.
|
||||
if (leaf.text === '') {
|
||||
return <ZeroWidthString />
|
||||
return <ZeroWidthString isMarkPlaceholder={isMarkPlaceholder} />
|
||||
}
|
||||
|
||||
// COMPAT: Browsers will collapse trailing new lines at the end of blocks,
|
||||
@@ -104,14 +107,25 @@ const TextString = (props: { text: string; isTrailing?: boolean }) => {
|
||||
* Leaf strings without text, render as zero-width strings.
|
||||
*/
|
||||
|
||||
const ZeroWidthString = (props: { length?: number; isLineBreak?: boolean }) => {
|
||||
const { length = 0, isLineBreak = false } = props
|
||||
export const ZeroWidthString = (props: {
|
||||
length?: number
|
||||
isLineBreak?: boolean
|
||||
isMarkPlaceholder?: boolean
|
||||
}) => {
|
||||
const { length = 0, isLineBreak = false, isMarkPlaceholder = false } = props
|
||||
|
||||
const attributes = {
|
||||
'data-slate-zero-width': isLineBreak ? 'n' : 'z',
|
||||
'data-slate-length': length,
|
||||
}
|
||||
|
||||
if (isMarkPlaceholder) {
|
||||
attributes['data-slate-mark-placeholder'] = true
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slate-zero-width={isLineBreak ? 'n' : 'z'}
|
||||
data-slate-length={length}
|
||||
>
|
||||
{'\uFEFF'}
|
||||
<span {...attributes}>
|
||||
{!IS_ANDROID || !isLineBreak ? '\uFEFF' : null}
|
||||
{isLineBreak ? <br /> : null}
|
||||
</span>
|
||||
)
|
||||
|
@@ -1,18 +1,15 @@
|
||||
import React, { useRef } from 'react'
|
||||
import { Range, Element, Text as SlateText } from 'slate'
|
||||
|
||||
import Leaf from './leaf'
|
||||
import { Element, Range, Text as SlateText } from 'slate'
|
||||
import { ReactEditor, useSlateStatic } from '..'
|
||||
import { RenderLeafProps, RenderPlaceholderProps } from './editable'
|
||||
import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect'
|
||||
import {
|
||||
NODE_TO_ELEMENT,
|
||||
ELEMENT_TO_NODE,
|
||||
EDITOR_TO_KEY_TO_ELEMENT,
|
||||
} from '../utils/weak-maps'
|
||||
import { isDecoratorRangeListEqual } from '../utils/range-list'
|
||||
import { useContentKey } from '../hooks/use-content-key'
|
||||
import { IS_ANDROID } from '../utils/environment'
|
||||
import {
|
||||
EDITOR_TO_KEY_TO_ELEMENT,
|
||||
ELEMENT_TO_NODE,
|
||||
NODE_TO_ELEMENT,
|
||||
} from '../utils/weak-maps'
|
||||
import { RenderLeafProps, RenderPlaceholderProps } from './editable'
|
||||
import Leaf from './leaf'
|
||||
|
||||
/**
|
||||
* Text.
|
||||
@@ -69,10 +66,8 @@ const Text = (props: {
|
||||
}
|
||||
})
|
||||
|
||||
const contentKey = IS_ANDROID ? useContentKey(text) : undefined
|
||||
|
||||
return (
|
||||
<span data-slate-node="text" ref={ref} key={contentKey}>
|
||||
<span data-slate-node="text" ref={ref}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
@@ -0,0 +1,644 @@
|
||||
import { DebouncedFunc } from 'lodash'
|
||||
import { Editor, Node, Path, Point, Range, Text, Transforms } from 'slate'
|
||||
import { ReactEditor } from '../../plugin/react-editor'
|
||||
import {
|
||||
mergeStringDiffs,
|
||||
normalizePoint,
|
||||
normalizeRange,
|
||||
normalizeStringDiff,
|
||||
StringDiff,
|
||||
targetRange,
|
||||
TextDiff,
|
||||
verifyDiffState,
|
||||
} from '../../utils/diff-text'
|
||||
import { isDOMSelection, isTrackedMutation } from '../../utils/dom'
|
||||
import {
|
||||
EDITOR_TO_FORCE_RENDER,
|
||||
EDITOR_TO_PENDING_INSERTION_MARKS,
|
||||
EDITOR_TO_PENDING_ACTION,
|
||||
EDITOR_TO_PENDING_DIFFS,
|
||||
EDITOR_TO_PENDING_SELECTION,
|
||||
EDITOR_TO_PLACEHOLDER_ELEMENT,
|
||||
EDITOR_TO_USER_MARKS,
|
||||
IS_COMPOSING,
|
||||
} from '../../utils/weak-maps'
|
||||
|
||||
export type Action = { at: Point | Range; run: () => void }
|
||||
|
||||
// https://github.com/facebook/draft-js/blob/main/src/component/handlers/composition/DraftEditorCompositionHandler.js#L41
|
||||
// When using keyboard English association function, conpositionEnd triggered too fast, resulting in after `insertText` still maintain association state.
|
||||
const RESOLVE_DELAY = 25
|
||||
|
||||
// Time with no user interaction before the current user action is considered as done.
|
||||
const FLUSH_DELAY = 200
|
||||
|
||||
// Replace with `const debug = console.log` to debug
|
||||
const debug = (..._: unknown[]) => {}
|
||||
|
||||
export type CreateAndroidInputManagerOptions = {
|
||||
editor: ReactEditor
|
||||
|
||||
scheduleOnDOMSelectionChange: DebouncedFunc<() => void>
|
||||
onDOMSelectionChange: DebouncedFunc<() => void>
|
||||
}
|
||||
|
||||
export type AndroidInputManager = {
|
||||
flush: () => void
|
||||
scheduleFlush: () => void
|
||||
|
||||
hasPendingDiffs: () => boolean
|
||||
hasPendingAction: () => boolean
|
||||
isFlushing: () => boolean | 'action'
|
||||
|
||||
handleUserSelect: (range: Range | null) => void
|
||||
handleCompositionEnd: (event: React.CompositionEvent<HTMLDivElement>) => void
|
||||
handleCompositionStart: (
|
||||
event: React.CompositionEvent<HTMLDivElement>
|
||||
) => void
|
||||
handleDOMBeforeInput: (event: InputEvent) => void
|
||||
|
||||
handleDomMutations: (mutations: MutationRecord[]) => void
|
||||
handleInput: () => void
|
||||
}
|
||||
|
||||
export function forceSwiftKeyUpdate(editor: ReactEditor) {
|
||||
const { document } = ReactEditor.getWindow(editor)
|
||||
debug('force ime update')
|
||||
|
||||
const div = document.createElement('div')
|
||||
div.setAttribute('contenteditable', 'true')
|
||||
div.setAttribute('display', 'none')
|
||||
div.setAttribute('position', 'absolute')
|
||||
div.setAttribute('top', '0')
|
||||
div.setAttribute('left', '0')
|
||||
div.textContent = ' '
|
||||
|
||||
document.body.appendChild(div)
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(div)
|
||||
const selection = window.getSelection()
|
||||
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
div.parentElement?.removeChild(div)
|
||||
|
||||
ReactEditor.focus(editor)
|
||||
}
|
||||
|
||||
export function createAndroidInputManager({
|
||||
editor,
|
||||
scheduleOnDOMSelectionChange,
|
||||
onDOMSelectionChange,
|
||||
}: CreateAndroidInputManagerOptions): AndroidInputManager {
|
||||
let flushing: 'action' | boolean = false
|
||||
|
||||
let compositionEndTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let flushTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let actionTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let idCounter = 0
|
||||
let isInsertAfterMarkPlaceholder = false
|
||||
|
||||
const applyPendingSelection = () => {
|
||||
const pendingSelection = EDITOR_TO_PENDING_SELECTION.get(editor)
|
||||
EDITOR_TO_PENDING_SELECTION.delete(editor)
|
||||
|
||||
if (pendingSelection) {
|
||||
const { selection } = editor
|
||||
const normalized = normalizeRange(editor, pendingSelection)
|
||||
|
||||
debug('apply pending selection', pendingSelection, normalized)
|
||||
|
||||
if (normalized && (!selection || !Range.equals(normalized, selection))) {
|
||||
Transforms.select(editor, normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const performAction = () => {
|
||||
const action = EDITOR_TO_PENDING_ACTION.get(editor)
|
||||
EDITOR_TO_PENDING_ACTION.delete(editor)
|
||||
if (!action) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = Point.isPoint(action.at)
|
||||
? normalizePoint(editor, action.at)
|
||||
: normalizeRange(editor, action.at)
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetRange = Editor.range(editor, target)
|
||||
if (!editor.selection || !Range.equals(editor.selection, targetRange)) {
|
||||
Transforms.select(editor, target)
|
||||
}
|
||||
|
||||
action.run()
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
if (flushTimeoutId) {
|
||||
clearTimeout(flushTimeoutId)
|
||||
flushTimeoutId = null
|
||||
}
|
||||
if (actionTimeoutId) {
|
||||
clearTimeout(actionTimeoutId)
|
||||
actionTimeoutId = null
|
||||
}
|
||||
|
||||
if (!hasPendingDiffs() && !hasPendingAction()) {
|
||||
applyPendingSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if (!flushing) {
|
||||
flushing = true
|
||||
setTimeout(() => (flushing = false))
|
||||
}
|
||||
if (hasPendingAction()) {
|
||||
flushing = 'action'
|
||||
}
|
||||
|
||||
const selectionRef =
|
||||
editor.selection &&
|
||||
Editor.rangeRef(editor, editor.selection, { affinity: 'forward' })
|
||||
EDITOR_TO_USER_MARKS.set(editor, editor.marks)
|
||||
|
||||
debug(
|
||||
'flush',
|
||||
EDITOR_TO_PENDING_ACTION.get(editor),
|
||||
EDITOR_TO_PENDING_DIFFS.get(editor)
|
||||
)
|
||||
|
||||
let scheduleSelectionChange = !!EDITOR_TO_PENDING_DIFFS.get(editor)?.length
|
||||
|
||||
let diff: TextDiff | undefined
|
||||
while ((diff = EDITOR_TO_PENDING_DIFFS.get(editor)?.[0])) {
|
||||
const pendingMarks = EDITOR_TO_PENDING_INSERTION_MARKS.get(editor)
|
||||
|
||||
if (pendingMarks !== undefined) {
|
||||
EDITOR_TO_PENDING_INSERTION_MARKS.delete(editor)
|
||||
editor.marks = pendingMarks
|
||||
}
|
||||
|
||||
if (pendingMarks) {
|
||||
isInsertAfterMarkPlaceholder = true
|
||||
}
|
||||
|
||||
const range = targetRange(diff)
|
||||
if (!editor.selection || !Range.equals(editor.selection, range)) {
|
||||
Transforms.select(editor, range)
|
||||
}
|
||||
|
||||
if (diff.diff.text) {
|
||||
Editor.insertText(editor, diff.diff.text)
|
||||
} else {
|
||||
Editor.deleteFragment(editor)
|
||||
}
|
||||
|
||||
// Remove diff only after we have applied it to account for it when transforming
|
||||
// pending ranges.
|
||||
EDITOR_TO_PENDING_DIFFS.set(
|
||||
editor,
|
||||
EDITOR_TO_PENDING_DIFFS.get(editor)?.filter(
|
||||
({ id }) => id !== diff!.id
|
||||
)!
|
||||
)
|
||||
|
||||
if (!verifyDiffState(editor, diff)) {
|
||||
debug('invalid diff state')
|
||||
scheduleSelectionChange = false
|
||||
EDITOR_TO_PENDING_ACTION.delete(editor)
|
||||
EDITOR_TO_USER_MARKS.delete(editor)
|
||||
flushing = 'action'
|
||||
|
||||
// Ensure we don't restore the pending user (dom) selection
|
||||
// since the document and dom state do not match.
|
||||
EDITOR_TO_PENDING_SELECTION.delete(editor)
|
||||
scheduleOnDOMSelectionChange.cancel()
|
||||
onDOMSelectionChange.cancel()
|
||||
selectionRef?.unref()
|
||||
}
|
||||
}
|
||||
|
||||
const selection = selectionRef?.unref()
|
||||
if (
|
||||
selection &&
|
||||
(!editor.selection || !Range.equals(selection, editor.selection))
|
||||
) {
|
||||
Transforms.select(editor, selection)
|
||||
}
|
||||
|
||||
if (hasPendingAction()) {
|
||||
performAction()
|
||||
return
|
||||
}
|
||||
|
||||
// COMPAT: The selectionChange event is fired after the action is performed,
|
||||
// so we have to manually schedule it to ensure we don't 'throw away' the selection
|
||||
// while rendering if we have pending changes.
|
||||
if (scheduleSelectionChange) {
|
||||
debug('scheduleOnDOMSelectionChange pending changes')
|
||||
scheduleOnDOMSelectionChange()
|
||||
}
|
||||
|
||||
scheduleOnDOMSelectionChange.flush()
|
||||
onDOMSelectionChange.flush()
|
||||
|
||||
applyPendingSelection()
|
||||
|
||||
const userMarks = EDITOR_TO_USER_MARKS.get(editor)
|
||||
EDITOR_TO_USER_MARKS.delete(editor)
|
||||
if (userMarks !== undefined) {
|
||||
editor.marks = userMarks
|
||||
}
|
||||
}
|
||||
|
||||
const handleCompositionEnd = (
|
||||
_event: React.CompositionEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (compositionEndTimeoutId) {
|
||||
clearTimeout(compositionEndTimeoutId)
|
||||
}
|
||||
|
||||
compositionEndTimeoutId = setTimeout(() => {
|
||||
IS_COMPOSING.set(editor, false)
|
||||
flush()
|
||||
}, RESOLVE_DELAY)
|
||||
}
|
||||
|
||||
const handleCompositionStart = (
|
||||
_event: React.CompositionEvent<HTMLDivElement>
|
||||
) => {
|
||||
debug('composition start')
|
||||
|
||||
IS_COMPOSING.set(editor, true)
|
||||
|
||||
if (compositionEndTimeoutId) {
|
||||
clearTimeout(compositionEndTimeoutId)
|
||||
compositionEndTimeoutId = null
|
||||
}
|
||||
}
|
||||
|
||||
const updatePlaceholderVisibility = () => {
|
||||
const placeholderElement = EDITOR_TO_PLACEHOLDER_ELEMENT.get(editor)
|
||||
if (!placeholderElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasPendingDiffs()) {
|
||||
placeholderElement.style.visibility = 'hidden'
|
||||
return
|
||||
}
|
||||
|
||||
placeholderElement.style.removeProperty('visibility')
|
||||
}
|
||||
|
||||
const storeDiff = (path: Path, diff: StringDiff) => {
|
||||
debug('storeDiff', path, diff)
|
||||
|
||||
const pendingDiffs = EDITOR_TO_PENDING_DIFFS.get(editor) ?? []
|
||||
EDITOR_TO_PENDING_DIFFS.set(editor, pendingDiffs)
|
||||
|
||||
const target = Node.leaf(editor, path)
|
||||
const idx = pendingDiffs.findIndex(change => Path.equals(change.path, path))
|
||||
if (idx < 0) {
|
||||
const normalized = normalizeStringDiff(target.text, diff)
|
||||
if (normalized) {
|
||||
pendingDiffs.push({ path, diff, id: idCounter++ })
|
||||
}
|
||||
|
||||
updatePlaceholderVisibility()
|
||||
return
|
||||
}
|
||||
|
||||
const merged = mergeStringDiffs(target.text, pendingDiffs[idx].diff, diff)
|
||||
if (!merged) {
|
||||
pendingDiffs.splice(idx, 1)
|
||||
updatePlaceholderVisibility()
|
||||
return
|
||||
}
|
||||
|
||||
pendingDiffs[idx] = {
|
||||
...pendingDiffs[idx],
|
||||
diff: merged,
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleAction = (at: Point | Range, run: () => void): void => {
|
||||
debug('scheduleAction', { at, run })
|
||||
|
||||
EDITOR_TO_PENDING_SELECTION.delete(editor)
|
||||
scheduleOnDOMSelectionChange.cancel()
|
||||
onDOMSelectionChange.cancel()
|
||||
|
||||
if (hasPendingAction()) {
|
||||
flush()
|
||||
}
|
||||
|
||||
EDITOR_TO_PENDING_ACTION.set(editor, { at, run })
|
||||
|
||||
// COMPAT: When deleting before a non-contenteditable element chrome only fires a beforeinput,
|
||||
// (no input) and doesn't perform any dom mutations. Without a flush timeout we would never flush
|
||||
// in this case and thus never actually perform the action.
|
||||
actionTimeoutId = setTimeout(flush)
|
||||
}
|
||||
|
||||
const handleDOMBeforeInput = (event: InputEvent): void => {
|
||||
if (flushTimeoutId) {
|
||||
clearTimeout(flushTimeoutId)
|
||||
flushTimeoutId = null
|
||||
}
|
||||
|
||||
const { inputType: type } = event
|
||||
let targetRange: Range | null = null
|
||||
const data = (event as any).dataTransfer || event.data || undefined
|
||||
|
||||
let [nativeTargetRange] = (event as any).getTargetRanges()
|
||||
if (nativeTargetRange) {
|
||||
targetRange = ReactEditor.toSlateRange(editor, nativeTargetRange, {
|
||||
exactMatch: false,
|
||||
suppressThrow: true,
|
||||
})
|
||||
}
|
||||
|
||||
// COMPAT: SelectionChange event is fired after the action is performed, so we
|
||||
// have to manually get the selection here to ensure it's up-to-date.
|
||||
const window = ReactEditor.getWindow(editor)
|
||||
const domSelection = window.getSelection()
|
||||
if (!targetRange && domSelection) {
|
||||
nativeTargetRange = domSelection
|
||||
targetRange = ReactEditor.toSlateRange(editor, domSelection, {
|
||||
exactMatch: false,
|
||||
suppressThrow: true,
|
||||
})
|
||||
}
|
||||
|
||||
targetRange = targetRange ?? editor.selection
|
||||
if (!targetRange) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Range.isExpanded(targetRange) && type.startsWith('delete')) {
|
||||
const [start, end] = Range.edges(targetRange)
|
||||
const leaf = Node.leaf(editor, start.path)
|
||||
|
||||
if (leaf.text.length === start.offset && end.offset === 0) {
|
||||
const next = Editor.next(editor, { at: start.path, match: Text.isText })
|
||||
if (next && Path.equals(next[1], end.path)) {
|
||||
targetRange = { anchor: end, focus: end }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Range.isExpanded(targetRange) && type.startsWith('delete')) {
|
||||
if (Path.equals(targetRange.anchor.path, targetRange.focus.path)) {
|
||||
const [start, end] = Range.edges(targetRange)
|
||||
return storeDiff(targetRange.anchor.path, {
|
||||
text: '',
|
||||
end: end.offset,
|
||||
start: start.offset,
|
||||
})
|
||||
}
|
||||
|
||||
const direction = type.endsWith('Backward') ? 'backward' : 'forward'
|
||||
return scheduleAction(targetRange, () =>
|
||||
Editor.deleteFragment(editor, { direction })
|
||||
)
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'deleteByComposition':
|
||||
case 'deleteByCut':
|
||||
case 'deleteByDrag': {
|
||||
return scheduleAction(targetRange, () => Editor.deleteFragment(editor))
|
||||
}
|
||||
|
||||
case 'deleteContent':
|
||||
case 'deleteContentForward': {
|
||||
const { anchor } = targetRange
|
||||
if (Range.isCollapsed(targetRange)) {
|
||||
const targetNode = Node.leaf(editor, anchor.path)
|
||||
|
||||
if (anchor.offset < targetNode.text.length) {
|
||||
return storeDiff(anchor.path, {
|
||||
text: '',
|
||||
start: anchor.offset,
|
||||
end: anchor.offset + 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return scheduleAction(targetRange, () => Editor.deleteForward(editor))
|
||||
}
|
||||
|
||||
case 'deleteContentBackward': {
|
||||
const { anchor } = targetRange
|
||||
|
||||
// If we have a mismatch between the native and slate selection being collapsed
|
||||
// we are most likely deleting a zero-width placeholder and thus should perform it
|
||||
// as an action to ensure correct behavior (mostly happens with mark placeholders)
|
||||
const nativeCollapsed = isDOMSelection(nativeTargetRange)
|
||||
? nativeTargetRange.isCollapsed
|
||||
: !!nativeTargetRange?.collapsed
|
||||
|
||||
if (
|
||||
nativeCollapsed &&
|
||||
Range.isCollapsed(targetRange) &&
|
||||
anchor.offset > 0
|
||||
) {
|
||||
return storeDiff(anchor.path, {
|
||||
text: '',
|
||||
start: anchor.offset - 1,
|
||||
end: anchor.offset,
|
||||
})
|
||||
}
|
||||
|
||||
return scheduleAction(targetRange, () => Editor.deleteBackward(editor))
|
||||
}
|
||||
|
||||
case 'deleteEntireSoftLine': {
|
||||
return scheduleAction(targetRange, () => {
|
||||
Editor.deleteBackward(editor, { unit: 'line' })
|
||||
Editor.deleteForward(editor, { unit: 'line' })
|
||||
})
|
||||
}
|
||||
|
||||
case 'deleteHardLineBackward': {
|
||||
return scheduleAction(targetRange, () =>
|
||||
Editor.deleteBackward(editor, { unit: 'block' })
|
||||
)
|
||||
}
|
||||
|
||||
case 'deleteSoftLineBackward': {
|
||||
return scheduleAction(targetRange, () =>
|
||||
Editor.deleteBackward(editor, { unit: 'line' })
|
||||
)
|
||||
}
|
||||
|
||||
case 'deleteHardLineForward': {
|
||||
return scheduleAction(targetRange, () =>
|
||||
Editor.deleteForward(editor, { unit: 'block' })
|
||||
)
|
||||
}
|
||||
|
||||
case 'deleteSoftLineForward': {
|
||||
return scheduleAction(targetRange, () =>
|
||||
Editor.deleteForward(editor, { unit: 'line' })
|
||||
)
|
||||
}
|
||||
|
||||
case 'deleteWordBackward': {
|
||||
return scheduleAction(targetRange, () =>
|
||||
Editor.deleteBackward(editor, { unit: 'word' })
|
||||
)
|
||||
}
|
||||
|
||||
case 'deleteWordForward': {
|
||||
return scheduleAction(targetRange, () =>
|
||||
Editor.deleteForward(editor, { unit: 'word' })
|
||||
)
|
||||
}
|
||||
|
||||
case 'insertLineBreak': {
|
||||
return scheduleAction(targetRange, () => Editor.insertSoftBreak(editor))
|
||||
}
|
||||
|
||||
case 'insertParagraph': {
|
||||
return scheduleAction(targetRange, () => Editor.insertBreak(editor))
|
||||
}
|
||||
case 'insertCompositionText':
|
||||
case 'deleteCompositionText':
|
||||
case 'insertFromComposition':
|
||||
case 'insertFromDrop':
|
||||
case 'insertFromPaste':
|
||||
case 'insertFromYank':
|
||||
case 'insertReplacementText':
|
||||
case 'insertText': {
|
||||
if (data?.constructor.name === 'DataTransfer') {
|
||||
return scheduleAction(targetRange, () =>
|
||||
ReactEditor.insertData(editor, data)
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof data === 'string' && data.includes('\n')) {
|
||||
return scheduleAction(Range.end(targetRange), () =>
|
||||
Editor.insertSoftBreak(editor)
|
||||
)
|
||||
}
|
||||
|
||||
let text = data ?? ''
|
||||
|
||||
// COMPAT: If we are writing inside a placeholder, the ime inserts the text inside
|
||||
// the placeholder itself and thus includes the zero-width space inside edit events.
|
||||
if (EDITOR_TO_PENDING_INSERTION_MARKS.get(editor)) {
|
||||
text = text.replace('\uFEFF', '')
|
||||
}
|
||||
|
||||
if (Path.equals(targetRange.anchor.path, targetRange.focus.path)) {
|
||||
// COMPAT: Swiftkey has a weird bug where the target range of the 2nd word
|
||||
// inserted after a mark placeholder is inserted with a anchor offset off by 1.
|
||||
// So writing 'some text' will result in 'some ttext'. If we force a IME update
|
||||
// after inserting the first word, swiftkey will insert with the correct offset
|
||||
if (text.endsWith(' ') && isInsertAfterMarkPlaceholder) {
|
||||
isInsertAfterMarkPlaceholder = false
|
||||
forceSwiftKeyUpdate(editor)
|
||||
return scheduleAction(targetRange, () =>
|
||||
Editor.insertText(editor, text)
|
||||
)
|
||||
}
|
||||
|
||||
const [start, end] = Range.edges(targetRange)
|
||||
return storeDiff(start.path, {
|
||||
start: start.offset,
|
||||
end: end.offset,
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
return scheduleAction(targetRange, () =>
|
||||
Editor.insertText(editor, text)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasPendingAction = () => {
|
||||
return !!EDITOR_TO_PENDING_ACTION.get(editor) || !!actionTimeoutId
|
||||
}
|
||||
|
||||
const hasPendingDiffs = () => {
|
||||
return !!EDITOR_TO_PENDING_DIFFS.get(editor)?.length
|
||||
}
|
||||
|
||||
const isFlushing = () => {
|
||||
return flushing
|
||||
}
|
||||
|
||||
const handleUserSelect = (range: Range | null) => {
|
||||
EDITOR_TO_PENDING_SELECTION.set(editor, range)
|
||||
|
||||
if (flushTimeoutId) {
|
||||
clearTimeout(flushTimeoutId)
|
||||
flushTimeoutId = null
|
||||
}
|
||||
|
||||
const pathChanged =
|
||||
range &&
|
||||
(!editor.selection ||
|
||||
!Path.equals(editor.selection.anchor.path, range?.anchor.path))
|
||||
|
||||
if (pathChanged) {
|
||||
isInsertAfterMarkPlaceholder = false
|
||||
}
|
||||
|
||||
if (pathChanged || !hasPendingDiffs()) {
|
||||
flushTimeoutId = setTimeout(flush, FLUSH_DELAY)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = () => {
|
||||
if (hasPendingAction() || !hasPendingDiffs()) {
|
||||
debug('flush input')
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (!hasPendingAction()) {
|
||||
actionTimeoutId = setTimeout(flush)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDomMutations = (mutations: MutationRecord[]) => {
|
||||
if (hasPendingDiffs() || hasPendingAction()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
mutations.some(mutation => isTrackedMutation(editor, mutation, mutations))
|
||||
) {
|
||||
// Cause a re-render to restore the dom state if we encounter tracked mutations without
|
||||
// a corresponding pending action.
|
||||
EDITOR_TO_FORCE_RENDER.get(editor)?.()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
flush,
|
||||
scheduleFlush,
|
||||
|
||||
hasPendingDiffs,
|
||||
hasPendingAction,
|
||||
isFlushing,
|
||||
|
||||
handleUserSelect,
|
||||
handleCompositionEnd,
|
||||
handleCompositionStart,
|
||||
handleDOMBeforeInput,
|
||||
|
||||
handleDomMutations,
|
||||
handleInput,
|
||||
}
|
||||
}
|
@@ -0,0 +1,55 @@
|
||||
import { RefObject, useState } from 'react'
|
||||
import { useSlateStatic } from '../use-slate-static'
|
||||
import { IS_ANDROID } from '../../utils/environment'
|
||||
import { EDITOR_TO_SCHEDULE_FLUSH } from '../../utils/weak-maps'
|
||||
import {
|
||||
createAndroidInputManager,
|
||||
CreateAndroidInputManagerOptions,
|
||||
} from './android-input-manager'
|
||||
import { useIsMounted } from '../use-is-mounted'
|
||||
import { useMutationObserver } from '../use-mutation-observer'
|
||||
|
||||
type UseAndroidInputManagerOptions = {
|
||||
node: RefObject<HTMLElement>
|
||||
} & Omit<
|
||||
CreateAndroidInputManagerOptions,
|
||||
'editor' | 'onUserInput' | 'receivedUserInput'
|
||||
>
|
||||
|
||||
const MUTATION_OBSERVER_CONFIG: MutationObserverInit = {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
}
|
||||
|
||||
export function useAndroidInputManager({
|
||||
node,
|
||||
...options
|
||||
}: UseAndroidInputManagerOptions) {
|
||||
if (!IS_ANDROID) {
|
||||
return null
|
||||
}
|
||||
|
||||
const editor = useSlateStatic()
|
||||
const isMounted = useIsMounted()
|
||||
|
||||
const [inputManager] = useState(() =>
|
||||
createAndroidInputManager({
|
||||
editor,
|
||||
...options,
|
||||
})
|
||||
)
|
||||
|
||||
useMutationObserver(
|
||||
node,
|
||||
inputManager.handleDomMutations,
|
||||
MUTATION_OBSERVER_CONFIG
|
||||
)
|
||||
|
||||
EDITOR_TO_SCHEDULE_FLUSH.set(editor, inputManager.scheduleFlush)
|
||||
if (isMounted) {
|
||||
inputManager.flush()
|
||||
}
|
||||
|
||||
return inputManager
|
||||
}
|
@@ -3,7 +3,7 @@ import { Editor, Range, Element, Ancestor, Descendant } from 'slate'
|
||||
|
||||
import ElementComponent from '../components/element'
|
||||
import TextComponent from '../components/text'
|
||||
import { ReactEditor } from '..'
|
||||
import { ReactEditor } from '../plugin/react-editor'
|
||||
import { useSlateStatic } from './use-slate-static'
|
||||
import { useDecorate } from './use-decorate'
|
||||
import { NODE_TO_INDEX, NODE_TO_PARENT } from '../utils/weak-maps'
|
||||
|
@@ -1,38 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Node as SlateNode } from 'slate'
|
||||
import { NODE_TO_RESTORE_DOM } from '../utils/weak-maps'
|
||||
|
||||
export function useContentKey(node: SlateNode) {
|
||||
const contentKeyRef = useRef<number>(0)
|
||||
const updateAnimationFrameRef = useRef<number | null>(null)
|
||||
|
||||
const [, setForceRerenderCounter] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
NODE_TO_RESTORE_DOM.set(node, () => {
|
||||
// Update is already queued and node hasn't re-render yet
|
||||
if (updateAnimationFrameRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
updateAnimationFrameRef.current = requestAnimationFrame(() => {
|
||||
setForceRerenderCounter(state => state + 1)
|
||||
updateAnimationFrameRef.current = null
|
||||
})
|
||||
|
||||
contentKeyRef.current++
|
||||
})
|
||||
|
||||
return () => {
|
||||
NODE_TO_RESTORE_DOM.delete(node)
|
||||
}
|
||||
}, [node])
|
||||
|
||||
// Node was restored => clear scheduled update
|
||||
if (updateAnimationFrameRef.current) {
|
||||
cancelAnimationFrame(updateAnimationFrameRef.current)
|
||||
updateAnimationFrameRef.current = null
|
||||
}
|
||||
|
||||
return contentKeyRef.current
|
||||
}
|
14
packages/slate-react/src/hooks/use-is-mounted.tsx
Normal file
14
packages/slate-react/src/hooks/use-is-mounted.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export function useIsMounted() {
|
||||
const isMountedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return isMountedRef.current
|
||||
}
|
@@ -1,5 +1,7 @@
|
||||
import { RefObject, useEffect, useState } from 'react'
|
||||
import { useIsomorphicLayoutEffect } from '../../hooks/use-isomorphic-layout-effect'
|
||||
import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect'
|
||||
import { isDOMElement } from '../utils/dom'
|
||||
import { ReactEditor } from '../plugin/react-editor'
|
||||
|
||||
export function useMutationObserver(
|
||||
node: RefObject<HTMLElement>,
|
||||
@@ -9,8 +11,9 @@ export function useMutationObserver(
|
||||
const [mutationObserver] = useState(() => new MutationObserver(callback))
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
// Disconnect mutation observer during render phase
|
||||
mutationObserver.disconnect()
|
||||
// Discard mutations caused during render phase. This works due to react calling
|
||||
// useLayoutEffect synchronously after the render phase before the next tick.
|
||||
mutationObserver.takeRecords()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -18,10 +21,7 @@ export function useMutationObserver(
|
||||
throw new Error('Failed to attach MutationObserver, `node` is undefined')
|
||||
}
|
||||
|
||||
// Attach mutation observer after render phase has finished
|
||||
mutationObserver.observe(node.current, options)
|
||||
|
||||
// Clean up after effect
|
||||
return mutationObserver.disconnect.bind(mutationObserver)
|
||||
})
|
||||
return () => mutationObserver.disconnect()
|
||||
}, [])
|
||||
}
|
32
packages/slate-react/src/hooks/use-track-user-input.ts
Normal file
32
packages/slate-react/src/hooks/use-track-user-input.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import { ReactEditor } from '../plugin/react-editor'
|
||||
import { useSlateStatic } from './use-slate-static'
|
||||
|
||||
export function useTrackUserInput() {
|
||||
const editor = useSlateStatic()
|
||||
|
||||
const receivedUserInput = useRef<boolean>(false)
|
||||
const animationFrameIdRef = useRef<number>(0)
|
||||
|
||||
const onUserInput = useCallback(() => {
|
||||
if (receivedUserInput.current) {
|
||||
return
|
||||
}
|
||||
|
||||
receivedUserInput.current = true
|
||||
|
||||
const window = ReactEditor.getWindow(editor)
|
||||
window.cancelAnimationFrame(animationFrameIdRef.current)
|
||||
|
||||
animationFrameIdRef.current = window.requestAnimationFrame(() => {
|
||||
receivedUserInput.current = false
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => cancelAnimationFrame(animationFrameIdRef.current), [])
|
||||
|
||||
return {
|
||||
receivedUserInput,
|
||||
onUserInput,
|
||||
}
|
||||
}
|
@@ -1,18 +1,12 @@
|
||||
// Components
|
||||
// Environment-dependent Editable
|
||||
import { Editable as DefaultEditable } from './components/editable'
|
||||
import { AndroidEditable } from './components/android/android-editable'
|
||||
import { IS_ANDROID } from './utils/environment'
|
||||
|
||||
export const Editable = IS_ANDROID ? AndroidEditable : DefaultEditable
|
||||
export {
|
||||
Editable as DefaultEditable,
|
||||
Editable,
|
||||
RenderElementProps,
|
||||
RenderLeafProps,
|
||||
RenderPlaceholderProps,
|
||||
DefaultPlaceholder,
|
||||
} from './components/editable'
|
||||
export { AndroidEditable } from './components/android/android-editable'
|
||||
|
||||
export { DefaultElement } from './components/element'
|
||||
export { DefaultLeaf } from './components/leaf'
|
||||
export { Slate } from './components/slate'
|
||||
|
@@ -21,6 +21,8 @@ import {
|
||||
EDITOR_TO_WINDOW,
|
||||
EDITOR_TO_KEY_TO_ELEMENT,
|
||||
IS_COMPOSING,
|
||||
EDITOR_TO_SCHEDULE_FLUSH,
|
||||
EDITOR_TO_PENDING_DIFFS,
|
||||
} from '../utils/weak-maps'
|
||||
import {
|
||||
DOMElement,
|
||||
@@ -34,7 +36,7 @@ import {
|
||||
normalizeDOMPoint,
|
||||
hasShadowRoot,
|
||||
} from '../utils/dom'
|
||||
import { IS_CHROME, IS_FIREFOX } from '../utils/environment'
|
||||
import { IS_CHROME, IS_FIREFOX, IS_ANDROID } from '../utils/environment'
|
||||
|
||||
/**
|
||||
* A React and DOM-specific version of the `Editor` interface.
|
||||
@@ -324,7 +326,8 @@ export const ReactEditor = {
|
||||
const texts = Array.from(el.querySelectorAll(selector))
|
||||
let start = 0
|
||||
|
||||
for (const text of texts) {
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
const text = texts[i]
|
||||
const domNode = text.childNodes[0] as HTMLElement
|
||||
|
||||
if (domNode == null || domNode.textContent == null) {
|
||||
@@ -336,6 +339,20 @@ export const ReactEditor = {
|
||||
const trueLength = attr == null ? length : parseInt(attr, 10)
|
||||
const end = start + trueLength
|
||||
|
||||
// Prefer putting the selection inside the mark placeholder to ensure
|
||||
// composed text is displayed with the correct marks.
|
||||
const nextText = texts[i + 1]
|
||||
if (
|
||||
point.offset === end &&
|
||||
nextText?.hasAttribute('data-slate-mark-placeholder')
|
||||
) {
|
||||
domPoint = [
|
||||
nextText,
|
||||
nextText.textContent?.startsWith('\uFEFF') ? 1 : 0,
|
||||
]
|
||||
break
|
||||
}
|
||||
|
||||
if (point.offset <= end) {
|
||||
const offset = Math.min(length, Math.max(0, point.offset - start))
|
||||
domPoint = [domNode, offset]
|
||||
@@ -540,6 +557,22 @@ export const ReactEditor = {
|
||||
]
|
||||
|
||||
removals.forEach(el => {
|
||||
// COMPAT: While composing at the start of a text node, some keyboards put
|
||||
// the text content inside the zero width space.
|
||||
if (
|
||||
IS_ANDROID &&
|
||||
!exactMatch &&
|
||||
el.hasAttribute('data-slate-zero-width') &&
|
||||
el.textContent.length > 0 &&
|
||||
el.textContext !== '\uFEFF'
|
||||
) {
|
||||
if (el.textContent.startsWith('\uFEFF')) {
|
||||
el.textContent = el.textContent.slice(1)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
el!.parentNode!.removeChild(el)
|
||||
})
|
||||
|
||||
@@ -580,6 +613,11 @@ export const ReactEditor = {
|
||||
if (
|
||||
domNode &&
|
||||
offset === domNode.textContent!.length &&
|
||||
// COMPAT: Android IMEs might remove the zero width space while composing,
|
||||
// and we don't add it for line-breaks.
|
||||
IS_ANDROID &&
|
||||
domNode.getAttribute('data-slate-zero-width') === 'z' &&
|
||||
domNode.textContent?.startsWith('\uFEFF') &&
|
||||
// COMPAT: If the parent node is a Slate zero-width space, editor is
|
||||
// because the text node should have no characters. However, during IME
|
||||
// composition the ASCII characters will be prepended to the zero-width
|
||||
@@ -595,6 +633,26 @@ export const ReactEditor = {
|
||||
}
|
||||
}
|
||||
|
||||
if (IS_ANDROID && !textNode && !exactMatch) {
|
||||
const node = parentNode.hasAttribute('data-slate-node')
|
||||
? parentNode
|
||||
: parentNode.closest('[data-slate-node]')
|
||||
|
||||
if (node && ReactEditor.hasDOMNode(editor, node, { editable: true })) {
|
||||
const slateNode = ReactEditor.toSlateNode(editor, node)
|
||||
let { path, offset } = Editor.start(
|
||||
editor,
|
||||
ReactEditor.findPath(editor, slateNode)
|
||||
)
|
||||
|
||||
if (!node.querySelector('[data-slate-leaf]')) {
|
||||
offset = nearestOffset
|
||||
}
|
||||
|
||||
return { path, offset } as T extends true ? Point | null : Point
|
||||
}
|
||||
}
|
||||
|
||||
if (!textNode) {
|
||||
if (suppressThrow) {
|
||||
return null as T extends true ? Point | null : Point
|
||||
@@ -713,4 +771,18 @@ export const ReactEditor = {
|
||||
Editor.hasPath(editor, anchor.path) && Editor.hasPath(editor, focus.path)
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Experimental and android specific: Flush all pending diffs and cancel composition at the next possible time.
|
||||
*/
|
||||
androidScheduleFlush(editor: Editor) {
|
||||
EDITOR_TO_SCHEDULE_FLUSH.get(editor)?.()
|
||||
},
|
||||
|
||||
/**
|
||||
* Experimental and android specific: Get pending diffs
|
||||
*/
|
||||
androidPendingDiffs(editor: Editor) {
|
||||
return EDITOR_TO_PENDING_DIFFS.get(editor)
|
||||
},
|
||||
}
|
||||
|
@@ -1,21 +1,31 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Editor, Node, Path, Operation, Transforms, Range } from 'slate'
|
||||
|
||||
import { ReactEditor } from './react-editor'
|
||||
import { Editor, Node, Operation, Path, Point, Range, Transforms } from 'slate'
|
||||
import {
|
||||
TextDiff,
|
||||
transformPendingPoint,
|
||||
transformPendingRange,
|
||||
transformTextDiff,
|
||||
} from '../utils/diff-text'
|
||||
import {
|
||||
getPlainText,
|
||||
getSlateFragmentAttribute,
|
||||
isDOMText,
|
||||
} from '../utils/dom'
|
||||
import { Key } from '../utils/key'
|
||||
import { findCurrentLineRange } from '../utils/lines'
|
||||
import {
|
||||
EDITOR_TO_KEY_TO_ELEMENT,
|
||||
EDITOR_TO_ON_CHANGE,
|
||||
NODE_TO_KEY,
|
||||
EDITOR_TO_PENDING_ACTION,
|
||||
EDITOR_TO_PENDING_DIFFS,
|
||||
EDITOR_TO_PENDING_SELECTION,
|
||||
EDITOR_TO_USER_MARKS,
|
||||
EDITOR_TO_USER_SELECTION,
|
||||
NODE_TO_KEY,
|
||||
EDITOR_TO_SCHEDULE_FLUSH,
|
||||
EDITOR_TO_PENDING_INSERTION_MARKS,
|
||||
} from '../utils/weak-maps'
|
||||
import {
|
||||
isDOMText,
|
||||
getPlainText,
|
||||
getSlateFragmentAttribute,
|
||||
} from '../utils/dom'
|
||||
import { findCurrentLineRange } from '../utils/lines'
|
||||
|
||||
import { ReactEditor } from './react-editor'
|
||||
/**
|
||||
* `withReact` adds React and DOM specific behaviors to the editor.
|
||||
*
|
||||
@@ -27,12 +37,44 @@ import { findCurrentLineRange } from '../utils/lines'
|
||||
|
||||
export const withReact = <T extends Editor>(editor: T) => {
|
||||
const e = editor as T & ReactEditor
|
||||
const { apply, onChange, deleteBackward } = e
|
||||
const { apply, onChange, deleteBackward, addMark, removeMark } = e
|
||||
|
||||
// The WeakMap which maps a key to a specific HTMLElement must be scoped to the editor instance to
|
||||
// avoid collisions between editors in the DOM that share the same value.
|
||||
EDITOR_TO_KEY_TO_ELEMENT.set(e, new WeakMap())
|
||||
|
||||
e.addMark = (key, value) => {
|
||||
EDITOR_TO_SCHEDULE_FLUSH.get(e)?.()
|
||||
|
||||
if (
|
||||
!EDITOR_TO_PENDING_INSERTION_MARKS.get(e) &&
|
||||
EDITOR_TO_PENDING_DIFFS.get(e)?.length
|
||||
) {
|
||||
// Ensure the current pending diffs originating from changes before the addMark
|
||||
// are applied with the current formatting
|
||||
EDITOR_TO_PENDING_INSERTION_MARKS.set(e, null)
|
||||
}
|
||||
|
||||
EDITOR_TO_USER_MARKS.delete(editor)
|
||||
|
||||
addMark(key, value)
|
||||
}
|
||||
|
||||
e.removeMark = key => {
|
||||
if (
|
||||
!EDITOR_TO_PENDING_INSERTION_MARKS.get(e) &&
|
||||
EDITOR_TO_PENDING_DIFFS.get(e)?.length
|
||||
) {
|
||||
// Ensure the current pending diffs originating from changes before the addMark
|
||||
// are applied with the current formatting
|
||||
EDITOR_TO_PENDING_INSERTION_MARKS.set(e, null)
|
||||
}
|
||||
|
||||
EDITOR_TO_USER_MARKS.delete(editor)
|
||||
|
||||
removeMark(key)
|
||||
}
|
||||
|
||||
e.deleteBackward = unit => {
|
||||
if (unit !== 'line') {
|
||||
return deleteBackward(unit)
|
||||
@@ -66,6 +108,31 @@ export const withReact = <T extends Editor>(editor: T) => {
|
||||
e.apply = (op: Operation) => {
|
||||
const matches: [Path, Key][] = []
|
||||
|
||||
const pendingDiffs = EDITOR_TO_PENDING_DIFFS.get(editor)
|
||||
if (pendingDiffs?.length) {
|
||||
const transformed = pendingDiffs
|
||||
.map(textDiff => transformTextDiff(textDiff, op))
|
||||
.filter(Boolean) as TextDiff[]
|
||||
|
||||
EDITOR_TO_PENDING_DIFFS.set(editor, transformed)
|
||||
}
|
||||
|
||||
const pendingSelection = EDITOR_TO_PENDING_SELECTION.get(editor)
|
||||
if (pendingSelection) {
|
||||
EDITOR_TO_PENDING_SELECTION.set(
|
||||
editor,
|
||||
transformPendingRange(editor, pendingSelection, op)
|
||||
)
|
||||
}
|
||||
const pendingAction = EDITOR_TO_PENDING_ACTION.get(editor)
|
||||
if (pendingAction) {
|
||||
const at = Point.isPoint(pendingAction?.at)
|
||||
? transformPendingPoint(editor, pendingAction.at, op)
|
||||
: transformPendingRange(editor, pendingAction.at, op)
|
||||
|
||||
EDITOR_TO_PENDING_ACTION.set(editor, at ? { ...pendingAction, at } : null)
|
||||
}
|
||||
|
||||
switch (op.type) {
|
||||
case 'insert_text':
|
||||
case 'remove_text':
|
||||
|
417
packages/slate-react/src/utils/diff-text.ts
Normal file
417
packages/slate-react/src/utils/diff-text.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import { Editor, Node, Operation, Path, Point, Range, Text } from 'slate'
|
||||
import { EDITOR_TO_PENDING_DIFFS } from './weak-maps'
|
||||
|
||||
export type StringDiff = {
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export type TextDiff = {
|
||||
id: number
|
||||
path: Path
|
||||
diff: StringDiff
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a text diff was applied in a way we can perform the pending action on /
|
||||
* recover the pending selection.
|
||||
*/
|
||||
export function verifyDiffState(editor: Editor, textDiff: TextDiff): boolean {
|
||||
const { path, diff } = textDiff
|
||||
if (!Editor.hasPath(editor, path)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const node = Node.get(editor, path)
|
||||
if (!Text.isText(node)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (diff.start !== node.text.length || diff.text.length === 0) {
|
||||
return (
|
||||
node.text.slice(diff.start, diff.start + diff.text.length) === diff.text
|
||||
)
|
||||
}
|
||||
|
||||
const nextPath = Path.next(path)
|
||||
if (!Editor.hasPath(editor, nextPath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const nextNode = Node.get(editor, nextPath)
|
||||
return Text.isText(nextNode) && nextNode.text.startsWith(diff.text)
|
||||
}
|
||||
|
||||
function applyStringDiff(text: string, ...diffs: StringDiff[]) {
|
||||
return diffs.reduce(
|
||||
(text, diff) =>
|
||||
text.slice(0, diff.start) + diff.text + text.slice(diff.end),
|
||||
text
|
||||
)
|
||||
}
|
||||
|
||||
function longestCommonPrefixLength(str: string, another: string) {
|
||||
const length = Math.min(str.length, another.length)
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (str.charAt(i) !== another.charAt(i)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return length
|
||||
}
|
||||
|
||||
function longestCommonSuffixLength(
|
||||
str: string,
|
||||
another: string,
|
||||
max: number
|
||||
): number {
|
||||
const length = Math.min(str.length, another.length, max)
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (
|
||||
str.charAt(str.length - i - 1) !== another.charAt(another.length - i - 1)
|
||||
) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return length
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove redundant changes from the diff so that it spans the minimal possible range
|
||||
*/
|
||||
export function normalizeStringDiff(targetText: string, diff: StringDiff) {
|
||||
const { start, end, text } = diff
|
||||
const removedText = targetText.slice(start, end)
|
||||
|
||||
const prefixLength = longestCommonPrefixLength(removedText, text)
|
||||
const max = Math.min(
|
||||
removedText.length - prefixLength,
|
||||
text.length - prefixLength
|
||||
)
|
||||
const suffixLength = longestCommonSuffixLength(removedText, text, max)
|
||||
|
||||
const normalized: StringDiff = {
|
||||
start: start + prefixLength,
|
||||
end: end - suffixLength,
|
||||
text: text.slice(prefixLength, text.length - suffixLength),
|
||||
}
|
||||
|
||||
if (normalized.start === normalized.end && normalized.text.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a string diff that is equivalent to applying b after a spanning the range of
|
||||
* both changes
|
||||
*/
|
||||
export function mergeStringDiffs(
|
||||
targetText: string,
|
||||
a: StringDiff,
|
||||
b: StringDiff
|
||||
): StringDiff | null {
|
||||
const start = Math.min(a.start, b.start)
|
||||
const overlap = Math.max(
|
||||
0,
|
||||
Math.min(a.start + a.text.length, b.end) - b.start
|
||||
)
|
||||
|
||||
const applied = applyStringDiff(targetText, a, b)
|
||||
const sliceEnd = Math.max(
|
||||
b.start + b.text.length,
|
||||
a.start +
|
||||
a.text.length +
|
||||
(a.start + a.text.length > b.start ? b.text.length : 0) -
|
||||
overlap
|
||||
)
|
||||
|
||||
const text = applied.slice(start, sliceEnd)
|
||||
const end = Math.max(a.end, b.end - a.text.length + (a.end - a.start))
|
||||
return normalizeStringDiff(targetText, { start, end, text })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the slate range the text diff spans.
|
||||
*/
|
||||
export function targetRange(textDiff: TextDiff): Range {
|
||||
const { path, diff } = textDiff
|
||||
return {
|
||||
anchor: { path, offset: diff.start },
|
||||
focus: { path, offset: diff.end },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a 'pending point' a.k.a a point based on the dom state before applying
|
||||
* the pending diffs. Since the pending diffs might have been inserted with different
|
||||
* marks we have to 'walk' the offset from the starting position to ensure we still
|
||||
* have a valid point inside the document
|
||||
*/
|
||||
export function normalizePoint(editor: Editor, point: Point): Point | null {
|
||||
let { path, offset } = point
|
||||
if (!Editor.hasPath(editor, path)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let leaf = Node.get(editor, path)
|
||||
if (!Text.isText(leaf)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parentBlock = Editor.above(editor, {
|
||||
match: n => Editor.isBlock(editor, n),
|
||||
at: path,
|
||||
})
|
||||
|
||||
if (!parentBlock) {
|
||||
return null
|
||||
}
|
||||
|
||||
while (offset > leaf.text.length) {
|
||||
const entry = Editor.next(editor, { at: path, match: Text.isText })
|
||||
if (!entry || !Path.isDescendant(entry[1], parentBlock[1])) {
|
||||
return null
|
||||
}
|
||||
|
||||
offset -= leaf.text.length
|
||||
leaf = entry[0]
|
||||
path = entry[1]
|
||||
}
|
||||
|
||||
return { path, offset }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a 'pending selection' to ensure it's valid in the current document state.
|
||||
*/
|
||||
export function normalizeRange(editor: Editor, range: Range): Range | null {
|
||||
const anchor = normalizePoint(editor, range.anchor)
|
||||
if (!anchor) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Range.isCollapsed(range)) {
|
||||
return { anchor, focus: anchor }
|
||||
}
|
||||
|
||||
const focus = normalizePoint(editor, range.focus)
|
||||
if (!focus) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { anchor, focus }
|
||||
}
|
||||
|
||||
export function transformPendingPoint(
|
||||
editor: Editor,
|
||||
point: Point,
|
||||
op: Operation
|
||||
): Point | null {
|
||||
const pendingDiffs = EDITOR_TO_PENDING_DIFFS.get(editor)
|
||||
const textDiff = pendingDiffs?.find(({ path }) =>
|
||||
Path.equals(path, point.path)
|
||||
)
|
||||
|
||||
if (!textDiff || point.offset <= textDiff.diff.start) {
|
||||
return Point.transform(point, op, { affinity: 'backward' })
|
||||
}
|
||||
|
||||
const { diff } = textDiff
|
||||
// Point references location inside the diff => transform the point based on the location
|
||||
// the diff will be applied to and add the offset inside the diff.
|
||||
if (point.offset <= diff.start + diff.text.length) {
|
||||
const anchor = { path: point.path, offset: diff.start }
|
||||
const transformed = Point.transform(anchor, op, {
|
||||
affinity: 'backward',
|
||||
})
|
||||
|
||||
if (!transformed) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
path: transformed.path,
|
||||
offset: transformed.offset + point.offset - diff.start,
|
||||
}
|
||||
}
|
||||
|
||||
// Point references location after the diff
|
||||
const anchor = {
|
||||
path: point.path,
|
||||
offset: point.offset - diff.text.length + diff.end - diff.start,
|
||||
}
|
||||
const transformed = Point.transform(anchor, op, {
|
||||
affinity: 'backward',
|
||||
})
|
||||
if (!transformed) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
op.type === 'split_node' &&
|
||||
Path.equals(op.path, point.path) &&
|
||||
anchor.offset < op.position &&
|
||||
diff.start < op.position
|
||||
) {
|
||||
return transformed
|
||||
}
|
||||
|
||||
return {
|
||||
path: transformed.path,
|
||||
offset: transformed.offset + diff.text.length - diff.end + diff.start,
|
||||
}
|
||||
}
|
||||
|
||||
export function transformPendingRange(
|
||||
editor: Editor,
|
||||
range: Range,
|
||||
op: Operation
|
||||
): Range | null {
|
||||
const anchor = transformPendingPoint(editor, range.anchor, op)
|
||||
if (!anchor) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Range.isCollapsed(range)) {
|
||||
return { anchor, focus: anchor }
|
||||
}
|
||||
|
||||
const focus = transformPendingPoint(editor, range.focus, op)
|
||||
if (!focus) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { anchor, focus }
|
||||
}
|
||||
|
||||
export function transformTextDiff(
|
||||
textDiff: TextDiff,
|
||||
op: Operation
|
||||
): TextDiff | null {
|
||||
const { path, diff, id } = textDiff
|
||||
|
||||
switch (op.type) {
|
||||
case 'insert_text': {
|
||||
if (!Path.equals(op.path, path) || op.offset >= diff.end) {
|
||||
return textDiff
|
||||
}
|
||||
|
||||
if (op.offset <= diff.start) {
|
||||
return {
|
||||
diff: {
|
||||
start: op.text.length + diff.start,
|
||||
end: op.text.length + diff.end,
|
||||
text: diff.text,
|
||||
},
|
||||
id,
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
diff: {
|
||||
start: diff.start,
|
||||
end: diff.end + op.text.length,
|
||||
text: diff.text,
|
||||
},
|
||||
id,
|
||||
path,
|
||||
}
|
||||
}
|
||||
case 'remove_text': {
|
||||
if (!Path.equals(op.path, path) || op.offset >= diff.end) {
|
||||
return textDiff
|
||||
}
|
||||
|
||||
if (op.offset + op.text.length <= diff.start) {
|
||||
return {
|
||||
diff: {
|
||||
start: diff.start - op.text.length,
|
||||
end: diff.end - op.text.length,
|
||||
text: diff.text,
|
||||
},
|
||||
id,
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
diff: {
|
||||
start: diff.start,
|
||||
end: diff.end - op.text.length,
|
||||
text: diff.text,
|
||||
},
|
||||
id,
|
||||
path,
|
||||
}
|
||||
}
|
||||
case 'split_node': {
|
||||
if (!Path.equals(op.path, path) || op.position >= diff.end) {
|
||||
return {
|
||||
diff,
|
||||
id,
|
||||
path: Path.transform(path, op, { affinity: 'backward' })!,
|
||||
}
|
||||
}
|
||||
|
||||
if (op.position > diff.start) {
|
||||
return {
|
||||
diff: {
|
||||
start: diff.start,
|
||||
end: Math.min(op.position, diff.end),
|
||||
text: diff.text,
|
||||
},
|
||||
id,
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
diff: {
|
||||
start: diff.start - op.position,
|
||||
end: diff.end - op.position,
|
||||
text: diff.text,
|
||||
},
|
||||
id,
|
||||
path: Path.transform(path, op, { affinity: 'forward' })!,
|
||||
}
|
||||
}
|
||||
case 'merge_node': {
|
||||
if (!Path.equals(op.path, path)) {
|
||||
return {
|
||||
diff,
|
||||
id,
|
||||
path: Path.transform(path, op)!,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
diff: {
|
||||
start: diff.start + op.position,
|
||||
end: diff.end + op.position,
|
||||
text: diff.text,
|
||||
},
|
||||
id,
|
||||
path: Path.transform(path, op)!,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newPath = Path.transform(path, op)
|
||||
if (!newPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
diff,
|
||||
path: newPath,
|
||||
id,
|
||||
}
|
||||
}
|
@@ -12,6 +12,7 @@ import DOMText = globalThis.Text
|
||||
import DOMRange = globalThis.Range
|
||||
import DOMSelection = globalThis.Selection
|
||||
import DOMStaticRange = globalThis.StaticRange
|
||||
import { ReactEditor } from '../plugin/react-editor'
|
||||
|
||||
export {
|
||||
DOMNode,
|
||||
@@ -264,3 +265,44 @@ export const getClipboardData = (dataTransfer: DataTransfer): DataTransfer => {
|
||||
}
|
||||
return dataTransfer
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a mutation originates from a editable element inside the editor.
|
||||
*/
|
||||
|
||||
export const isTrackedMutation = (
|
||||
editor: ReactEditor,
|
||||
mutation: MutationRecord,
|
||||
batch: MutationRecord[]
|
||||
): boolean => {
|
||||
const { target } = mutation
|
||||
if (isDOMElement(target) && target.matches('[contentEditable="false"]')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { document } = ReactEditor.getWindow(editor)
|
||||
if (document.contains(target)) {
|
||||
return ReactEditor.hasDOMNode(editor, target, { editable: true })
|
||||
}
|
||||
|
||||
const parentMutation = batch.find(({ addedNodes, removedNodes }) => {
|
||||
for (const node of addedNodes) {
|
||||
if (node === target || node.contains(target)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of removedNodes) {
|
||||
if (node === target || node.contains(target)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!parentMutation || parentMutation === mutation) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Target add/remove is tracked. Track the mutation if we track the parent mutation.
|
||||
return isTrackedMutation(editor, parentMutation, batch)
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { Range, Editor } from 'slate'
|
||||
import { ReactEditor } from '..'
|
||||
import { ReactEditor } from '../plugin/react-editor'
|
||||
|
||||
const doRectsIntersect = (rect: DOMRect, compareRect: DOMRect) => {
|
||||
const middle = (compareRect.top + compareRect.bottom) / 2
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { Ancestor, Editor, Node, RangeRef } from 'slate'
|
||||
import { Ancestor, Editor, Node, Range, RangeRef, Text } from 'slate'
|
||||
import { Action } from '../hooks/android-input-manager/android-input-manager'
|
||||
import { TextDiff } from './diff-text'
|
||||
import { Key } from './key'
|
||||
import { TextInsertion } from '../components/android/diff-text'
|
||||
|
||||
/**
|
||||
* Two weak maps that allow us rebuild a path given a node. They are populated
|
||||
@@ -17,6 +18,10 @@ export const NODE_TO_PARENT: WeakMap<Node, Ancestor> = new WeakMap()
|
||||
export const EDITOR_TO_WINDOW: WeakMap<Editor, Window> = new WeakMap()
|
||||
export const EDITOR_TO_ELEMENT: WeakMap<Editor, HTMLElement> = new WeakMap()
|
||||
export const EDITOR_TO_PLACEHOLDER: WeakMap<Editor, string> = new WeakMap()
|
||||
export const EDITOR_TO_PLACEHOLDER_ELEMENT: WeakMap<
|
||||
Editor,
|
||||
HTMLElement
|
||||
> = new WeakMap()
|
||||
export const ELEMENT_TO_NODE: WeakMap<HTMLElement, Node> = new WeakMap()
|
||||
export const NODE_TO_ELEMENT: WeakMap<Node, HTMLElement> = new WeakMap()
|
||||
export const NODE_TO_KEY: WeakMap<Node, Key> = new WeakMap()
|
||||
@@ -34,17 +39,10 @@ export const IS_FOCUSED: WeakMap<Editor, boolean> = new WeakMap()
|
||||
export const IS_DRAGGING: WeakMap<Editor, boolean> = new WeakMap()
|
||||
export const IS_CLICKING: WeakMap<Editor, boolean> = new WeakMap()
|
||||
export const IS_COMPOSING: WeakMap<Editor, boolean> = new WeakMap()
|
||||
export const IS_ON_COMPOSITION_END: WeakMap<Editor, boolean> = new WeakMap()
|
||||
|
||||
export const EDITOR_TO_USER_SELECTION: WeakMap<Editor, RangeRef> = new WeakMap()
|
||||
|
||||
/**
|
||||
* Weak maps for saving text on composition stage.
|
||||
*/
|
||||
|
||||
export const EDITOR_ON_COMPOSITION_TEXT: WeakMap<
|
||||
export const EDITOR_TO_USER_SELECTION: WeakMap<
|
||||
Editor,
|
||||
TextInsertion[]
|
||||
RangeRef | null
|
||||
> = new WeakMap()
|
||||
|
||||
/**
|
||||
@@ -53,10 +51,51 @@ export const EDITOR_ON_COMPOSITION_TEXT: WeakMap<
|
||||
|
||||
export const EDITOR_TO_ON_CHANGE = new WeakMap<Editor, () => void>()
|
||||
|
||||
export const NODE_TO_RESTORE_DOM = new WeakMap<Node, () => void>()
|
||||
/**
|
||||
* Weak maps for saving pending state on composition stage.
|
||||
*/
|
||||
|
||||
export const EDITOR_TO_SCHEDULE_FLUSH: WeakMap<
|
||||
Editor,
|
||||
() => void
|
||||
> = new WeakMap()
|
||||
|
||||
export const EDITOR_TO_PENDING_INSERTION_MARKS: WeakMap<
|
||||
Editor,
|
||||
Partial<Text> | null
|
||||
> = new WeakMap()
|
||||
|
||||
export const EDITOR_TO_USER_MARKS: WeakMap<
|
||||
Editor,
|
||||
Partial<Text> | null
|
||||
> = new WeakMap()
|
||||
|
||||
/**
|
||||
* Android input handling specific weak-maps
|
||||
*/
|
||||
|
||||
export const EDITOR_TO_PENDING_DIFFS: WeakMap<
|
||||
Editor,
|
||||
TextDiff[]
|
||||
> = new WeakMap()
|
||||
|
||||
export const EDITOR_TO_PENDING_ACTION: WeakMap<
|
||||
Editor,
|
||||
Action | null
|
||||
> = new WeakMap()
|
||||
|
||||
export const EDITOR_TO_PENDING_SELECTION: WeakMap<
|
||||
Editor,
|
||||
Range | null
|
||||
> = new WeakMap()
|
||||
|
||||
export const EDITOR_TO_FORCE_RENDER: WeakMap<Editor, () => void> = new WeakMap()
|
||||
|
||||
/**
|
||||
* Symbols.
|
||||
*/
|
||||
|
||||
export const PLACEHOLDER_SYMBOL = (Symbol('placeholder') as unknown) as string
|
||||
export const MARK_PLACEHOLDER_SYMBOL = (Symbol(
|
||||
'mark-placeholder'
|
||||
) as unknown) as string
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { createEditor, Element, Transforms } from 'slate'
|
||||
import { create, act, ReactTestRenderer } from 'react-test-renderer'
|
||||
import { Slate, withReact, DefaultEditable } from '../src'
|
||||
import { Slate, withReact, Editable } from '../src'
|
||||
|
||||
const createNodeMock = () => ({
|
||||
ownerDocument: global.document,
|
||||
@@ -21,7 +21,7 @@ describe('slate-react', () => {
|
||||
act(() => {
|
||||
el = create(
|
||||
<Slate editor={editor} value={value} onChange={() => {}}>
|
||||
<DefaultEditable
|
||||
<Editable
|
||||
renderElement={({ element, children }) => {
|
||||
React.useEffect(() => mounts(element), [])
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('slate-react', () => {
|
||||
act(() => {
|
||||
el = create(
|
||||
<Slate editor={editor} value={value} onChange={() => {}}>
|
||||
<DefaultEditable
|
||||
<Editable
|
||||
renderElement={({ element, children }) => {
|
||||
React.useEffect(() => mounts(element), [])
|
||||
|
||||
|
@@ -601,11 +601,15 @@ export const Editor: EditorInterface = {
|
||||
*/
|
||||
|
||||
isEditor(value: any): value is Editor {
|
||||
if (!isPlainObject(value)) return false
|
||||
const cachedIsEditor = IS_EDITOR_CACHE.get(value)
|
||||
if (cachedIsEditor !== undefined) {
|
||||
return cachedIsEditor
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isEditor =
|
||||
typeof value.addMark === 'function' &&
|
||||
typeof value.apply === 'function' &&
|
||||
|
@@ -1,15 +1,16 @@
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { Slate, Editable, withReact } from 'slate-react'
|
||||
import {
|
||||
Editor,
|
||||
Transforms,
|
||||
Range,
|
||||
Point,
|
||||
createEditor,
|
||||
Element as SlateElement,
|
||||
Descendant,
|
||||
Editor,
|
||||
Element as SlateElement,
|
||||
Node as SlateNode,
|
||||
Point,
|
||||
Range,
|
||||
Transforms,
|
||||
} from 'slate'
|
||||
import { withHistory } from 'slate-history'
|
||||
import { Editable, ReactEditor, Slate, withReact } from 'slate-react'
|
||||
import { BulletedListElement } from './custom-types'
|
||||
|
||||
const SHORTCUTS = {
|
||||
@@ -31,9 +32,44 @@ const MarkdownShortcutsExample = () => {
|
||||
() => withShortcuts(withReact(withHistory(createEditor()))),
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDOMBeforeInput = useCallback((e: InputEvent) => {
|
||||
queueMicrotask(() => {
|
||||
const pendingDiffs = ReactEditor.androidPendingDiffs(editor)
|
||||
|
||||
const scheduleFlush = pendingDiffs?.some(({ diff, path }) => {
|
||||
if (!diff.text.endsWith(' ')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { text } = SlateNode.leaf(editor, path)
|
||||
const beforeText = text.slice(0, diff.start) + diff.text.slice(0, -1)
|
||||
if (!(beforeText in SHORTCUTS)) {
|
||||
return
|
||||
}
|
||||
|
||||
const blockEntry = Editor.above(editor, {
|
||||
at: path,
|
||||
match: n => Editor.isBlock(editor, n),
|
||||
})
|
||||
if (!blockEntry) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [, blockPath] = blockEntry
|
||||
return Editor.isStart(editor, Editor.start(editor, path), blockPath)
|
||||
})
|
||||
|
||||
if (scheduleFlush) {
|
||||
ReactEditor.androidScheduleFlush(editor)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Slate editor={editor} value={initialValue}>
|
||||
<Editable
|
||||
onDOMBeforeInput={handleDOMBeforeInput}
|
||||
renderElement={renderElement}
|
||||
placeholder="Write some markdown..."
|
||||
spellCheck
|
||||
@@ -49,7 +85,7 @@ const withShortcuts = editor => {
|
||||
editor.insertText = text => {
|
||||
const { selection } = editor
|
||||
|
||||
if (text === ' ' && selection && Range.isCollapsed(selection)) {
|
||||
if (text.endsWith(' ') && selection && Range.isCollapsed(selection)) {
|
||||
const { anchor } = selection
|
||||
const block = Editor.above(editor, {
|
||||
match: n => Editor.isBlock(editor, n),
|
||||
@@ -57,12 +93,16 @@ const withShortcuts = editor => {
|
||||
const path = block ? block[1] : []
|
||||
const start = Editor.start(editor, path)
|
||||
const range = { anchor, focus: start }
|
||||
const beforeText = Editor.string(editor, range)
|
||||
const beforeText = Editor.string(editor, range) + text.slice(0, -1)
|
||||
const type = SHORTCUTS[beforeText]
|
||||
|
||||
if (type) {
|
||||
Transforms.select(editor, range)
|
||||
Transforms.delete(editor)
|
||||
|
||||
if (!Range.isCollapsed(range)) {
|
||||
Transforms.delete(editor)
|
||||
}
|
||||
|
||||
const newProperties: Partial<SlateElement> = {
|
||||
type,
|
||||
}
|
||||
|
Reference in New Issue
Block a user