mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-08-14 03:03:58 +02:00
(Re)-Introduce Android support via a MutationObserver based reconciliation layer
* Revert "Temporarily revert Android commits in order to create a release of Slate focused solely on Android (#4256)"
This reverts commit 4f50211984
.
* Updates to android composition manager
* Add user input tracking
It's necessary to only track mutations that result from user input. Some mutations that happen inside block or leaf nodes can happen outside of the render loop of the Editable component.
This commit is contained in:
480
packages/slate-react/src/components/android/android-editable.tsx
Normal file
480
packages/slate-react/src/components/android/android-editable.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Descendant, Editor, Element, Node, Range, Transforms } from 'slate'
|
||||
import throttle from 'lodash/throttle'
|
||||
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,
|
||||
isPlainTextOnlyPaste,
|
||||
} from '../../utils/dom'
|
||||
import {
|
||||
EDITOR_TO_ELEMENT,
|
||||
EDITOR_TO_RESTORE_DOM,
|
||||
EDITOR_TO_WINDOW,
|
||||
ELEMENT_TO_NODE,
|
||||
IS_FOCUSED,
|
||||
IS_READ_ONLY,
|
||||
NODE_TO_ELEMENT,
|
||||
PLACEHOLDER_SYMBOL,
|
||||
} from '../../utils/weak-maps'
|
||||
import { EditableProps } from '../editable'
|
||||
import useChildren from '../../hooks/use-children'
|
||||
import {
|
||||
defaultDecorate,
|
||||
hasEditableTarget,
|
||||
isEventHandled,
|
||||
isDOMEventHandled,
|
||||
isTargetInsideVoid,
|
||||
} from '../editable'
|
||||
|
||||
import { useAndroidInputManager } from './use-android-input-manager'
|
||||
|
||||
/**
|
||||
* Editable.
|
||||
*/
|
||||
|
||||
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()
|
||||
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(
|
||||
() => ({
|
||||
isUpdatingSelection: false,
|
||||
latestElement: null as DOMElement | null,
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const [contentKey, setContentKey] = useState(0)
|
||||
const onRestoreDOM = useCallback(() => {
|
||||
setContentKey(prev => prev + 1)
|
||||
}, [contentKey])
|
||||
|
||||
// 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)
|
||||
EDITOR_TO_RESTORE_DOM.set(editor, onRestoreDOM)
|
||||
} else {
|
||||
NODE_TO_ELEMENT.delete(editor)
|
||||
EDITOR_TO_RESTORE_DOM.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 (!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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
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 `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)
|
||||
) {
|
||||
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])
|
||||
|
||||
// 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 (
|
||||
!readOnly &&
|
||||
!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) ||
|
||||
isTargetInsideVoid(editor, anchorNode)
|
||||
|
||||
const focusNodeSelectable =
|
||||
hasEditableTarget(editor, focusNode) ||
|
||||
isTargetInsideVoid(editor, focusNode)
|
||||
|
||||
if (anchorNodeSelectable && focusNodeSelectable) {
|
||||
const range = ReactEditor.toSlateRange(editor, domSelection, {
|
||||
exactMatch: false,
|
||||
})
|
||||
Transforms.select(editor, range)
|
||||
} else {
|
||||
Transforms.deselect(editor)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Failed to update selection, likely due to reconciliation error
|
||||
}
|
||||
}, 100),
|
||||
[readOnly]
|
||||
)
|
||||
|
||||
// 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', onDOMSelectionChange)
|
||||
|
||||
return () => {
|
||||
window.document.removeEventListener(
|
||||
'selectionchange',
|
||||
onDOMSelectionChange
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const decorations = decorate([editor, []])
|
||||
|
||||
if (
|
||||
placeholder &&
|
||||
editor.children.length === 1 &&
|
||||
Array.from(Node.texts(editor)).length === 1 &&
|
||||
Node.string(editor) === ''
|
||||
) {
|
||||
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)
|
||||
}
|
||||
},
|
||||
[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)
|
||||
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]
|
||||
)}
|
||||
onPaste={useCallback(
|
||||
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
// 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>
|
||||
)
|
||||
}
|
@@ -0,0 +1,191 @@
|
||||
import { ReactEditor } from '../../plugin/react-editor'
|
||||
import { Editor, Range, Transforms } from 'slate'
|
||||
|
||||
import { DOMNode } from '../../utils/dom'
|
||||
|
||||
import {
|
||||
normalizeTextInsertionRange,
|
||||
combineInsertedText,
|
||||
TextInsertion,
|
||||
} from './diff-text'
|
||||
import {
|
||||
gatherMutationData,
|
||||
isDeletion,
|
||||
isLineBreak,
|
||||
isRemoveLeafNodes,
|
||||
isReplaceExpandedSelection,
|
||||
isTextInsertion,
|
||||
} from './mutation-detection'
|
||||
import { restoreDOM } from './restore-dom'
|
||||
|
||||
// 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
|
||||
*/
|
||||
|
||||
export class AndroidInputManager {
|
||||
constructor(private editor: ReactEditor) {
|
||||
this.editor = editor
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
restoreDOM(this.editor)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
// Insert the batched text diffs
|
||||
insertedText.forEach(insertion => {
|
||||
Transforms.insertText(this.editor, insertion.text.insertText, {
|
||||
at: normalizeTextInsertionRange(this.editor, selection, insertion),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle line breaks
|
||||
*/
|
||||
|
||||
private insertBreak = () => {
|
||||
debug('insertBreak')
|
||||
|
||||
const { selection } = this.editor
|
||||
|
||||
Editor.insertBreak(this.editor)
|
||||
|
||||
// To-do: Need a more granular solution to restoring only a specific portion
|
||||
// of the document. Restoring the entire document is expensive.
|
||||
restoreDOM(this.editor)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
restoreDOM(this.editor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `backspace` that merges blocks
|
||||
*/
|
||||
|
||||
private deleteBackward = () => {
|
||||
debug('deleteBackward')
|
||||
|
||||
Editor.deleteBackward(this.editor)
|
||||
ReactEditor.focus(this.editor)
|
||||
|
||||
restoreDOM(this.editor)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
restoreDOM(this.editor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AndroidInputManager
|
225
packages/slate-react/src/components/android/diff-text.ts
Normal file
225
packages/slate-react/src/components/android/diff-text.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
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
packages/slate-react/src/components/android/index.ts
Normal file
1
packages/slate-react/src/components/android/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AndroidEditable } from './android-editable'
|
@@ -0,0 +1,142 @@
|
||||
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
|
||||
)
|
||||
}
|
14
packages/slate-react/src/components/android/restore-dom.ts
Normal file
14
packages/slate-react/src/components/android/restore-dom.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ReactEditor } from '../..'
|
||||
import { EDITOR_TO_RESTORE_DOM } from '../../utils/weak-maps'
|
||||
|
||||
export function restoreDOM(editor: ReactEditor) {
|
||||
try {
|
||||
const onRestoreDOM = EDITOR_TO_RESTORE_DOM.get(editor)
|
||||
if (onRestoreDOM) {
|
||||
onRestoreDOM()
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err)
|
||||
}
|
||||
}
|
@@ -0,0 +1,46 @@
|
||||
import { RefObject, useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { useSlateStatic } from '../../hooks/use-slate-static'
|
||||
|
||||
import { AndroidInputManager } from './android-input-manager'
|
||||
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 [inputManager] = useState(() => new AndroidInputManager(editor))
|
||||
const { receivedUserInput, onUserInput } = useTrackUserInput()
|
||||
const timeoutId = useRef<NodeJS.Timeout | 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,
|
||||
}
|
||||
}
|
@@ -0,0 +1,27 @@
|
||||
import { RefObject, useEffect, useState } from 'react'
|
||||
import { useIsomorphicLayoutEffect } from '../../hooks/use-isomorphic-layout-effect'
|
||||
|
||||
export function useMutationObserver(
|
||||
node: RefObject<HTMLElement>,
|
||||
callback: MutationCallback,
|
||||
options: MutationObserverInit
|
||||
) {
|
||||
const [mutationObserver] = useState(() => new MutationObserver(callback))
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
// Disconnect mutation observer during render phase
|
||||
mutationObserver.disconnect()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!node.current) {
|
||||
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)
|
||||
})
|
||||
}
|
@@ -0,0 +1,38 @@
|
||||
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,
|
||||
}
|
||||
}
|
@@ -138,8 +138,9 @@ export const Editable = (props: EditableProps) => {
|
||||
[]
|
||||
)
|
||||
|
||||
// Update element-related weak maps with the DOM element ref.
|
||||
// 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)
|
||||
@@ -149,10 +150,8 @@ export const Editable = (props: EditableProps) => {
|
||||
} else {
|
||||
NODE_TO_ELEMENT.delete(editor)
|
||||
}
|
||||
})
|
||||
|
||||
// Whenever the editor updates, make sure the DOM selection state is in sync.
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
// Make sure the DOM selection state is in sync.
|
||||
const { selection } = editor
|
||||
const root = ReactEditor.findDocumentOrShadowRoot(editor)
|
||||
const domSelection = root.getSelection()
|
||||
@@ -1137,13 +1136,13 @@ export const DefaultPlaceholder = ({
|
||||
* A default memoized decorate function.
|
||||
*/
|
||||
|
||||
const defaultDecorate: (entry: NodeEntry) => Range[] = () => []
|
||||
export const defaultDecorate: (entry: NodeEntry) => Range[] = () => []
|
||||
|
||||
/**
|
||||
* Check if two DOM range objects are equal.
|
||||
*/
|
||||
|
||||
const isRangeEqual = (a: DOMRange, b: DOMRange) => {
|
||||
export const isRangeEqual = (a: DOMRange, b: DOMRange) => {
|
||||
return (
|
||||
(a.startContainer === b.startContainer &&
|
||||
a.startOffset === b.startOffset &&
|
||||
@@ -1160,7 +1159,7 @@ const isRangeEqual = (a: DOMRange, b: DOMRange) => {
|
||||
* Check if the target is in the editor.
|
||||
*/
|
||||
|
||||
const hasTarget = (
|
||||
export const hasTarget = (
|
||||
editor: ReactEditor,
|
||||
target: EventTarget | null
|
||||
): target is DOMNode => {
|
||||
@@ -1171,7 +1170,7 @@ const hasTarget = (
|
||||
* Check if the target is editable and in the editor.
|
||||
*/
|
||||
|
||||
const hasEditableTarget = (
|
||||
export const hasEditableTarget = (
|
||||
editor: ReactEditor,
|
||||
target: EventTarget | null
|
||||
): target is DOMNode => {
|
||||
@@ -1185,7 +1184,7 @@ const hasEditableTarget = (
|
||||
* Check if the target is inside void and in the editor.
|
||||
*/
|
||||
|
||||
const isTargetInsideVoid = (
|
||||
export const isTargetInsideVoid = (
|
||||
editor: ReactEditor,
|
||||
target: EventTarget | null
|
||||
): boolean => {
|
||||
@@ -1198,7 +1197,7 @@ const isTargetInsideVoid = (
|
||||
* Check if an event is overrided by a handler.
|
||||
*/
|
||||
|
||||
const isEventHandled = <
|
||||
export const isEventHandled = <
|
||||
EventType extends React.SyntheticEvent<unknown, unknown>
|
||||
>(
|
||||
event: EventType,
|
||||
@@ -1216,7 +1215,7 @@ const isEventHandled = <
|
||||
* Check if a DOM event is overrided by a handler.
|
||||
*/
|
||||
|
||||
const isDOMEventHandled = <E extends Event>(
|
||||
export const isDOMEventHandled = <E extends Event>(
|
||||
event: E,
|
||||
handler?: (event: E) => void
|
||||
) => {
|
||||
|
@@ -1,11 +1,18 @@
|
||||
// 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,
|
||||
RenderElementProps,
|
||||
RenderLeafProps,
|
||||
Editable,
|
||||
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'
|
||||
|
@@ -7,6 +7,9 @@ export const IS_IOS =
|
||||
export const IS_APPLE =
|
||||
typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent)
|
||||
|
||||
export const IS_ANDROID =
|
||||
typeof navigator !== 'undefined' && /Android/.test(navigator.userAgent)
|
||||
|
||||
export const IS_FIREFOX =
|
||||
typeof navigator !== 'undefined' &&
|
||||
/^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent)
|
||||
|
@@ -37,6 +37,8 @@ export const IS_CLICKING: WeakMap<Editor, boolean> = new WeakMap()
|
||||
|
||||
export const EDITOR_TO_ON_CHANGE = new WeakMap<Editor, () => void>()
|
||||
|
||||
export const EDITOR_TO_RESTORE_DOM = new WeakMap<Editor, () => void>()
|
||||
|
||||
/**
|
||||
* Symbols.
|
||||
*/
|
||||
|
Reference in New Issue
Block a user