1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-07-30 20:10:24 +02:00

Feature/android keyboard support (#4200)

* Added Android Keyboard Support

* Added changeset for android keyboard support

* Removed dead code in android editable that supported non-android environments

* Removed unnecessary attributes observation for android-editable

* Removed dead code

* Added no-error boundary

* Fixed issues with linters
This commit is contained in:
Wayne Leroux
2021-05-12 19:30:39 -04:00
committed by GitHub
parent 351d0a1c36
commit e03ce7c561
15 changed files with 1197 additions and 43 deletions

View File

@@ -0,0 +1,5 @@
---
'slate-react': minor
---
Added android keyboard support for slate editor

View File

@@ -151,15 +151,6 @@
}
],
"use-isnan": "error",
"valid-jsdoc": [
"error",
{
"prefer": {
"return": "returns"
},
"requireReturn": false
}
],
"valid-typeof": "error",
"yield-star-spacing": [
"error",
@@ -179,4 +170,4 @@
}
}
]
}
}

View File

@@ -0,0 +1,20 @@
import React, { PropsWithChildren } from 'react'
export class ErrorBoundary extends React.Component<
PropsWithChildren<{}>,
never
> {
static getDerivedStateFromError(error: Error) {
// Update state so the next render will show the fallback UI.
return { hasError: true }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// eslint-disable-next-line no-console
console.error(error)
}
render() {
return this.props.children
}
}

View File

@@ -0,0 +1,452 @@
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { Descendant, Editor, 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 {
DOMElement,
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 { AndroidInputManager } from './android-input-manager'
import { EditableProps } from '../editable'
import { ErrorBoundary } from './ErrorBoundary'
import useChildren from '../../hooks/use-children'
import {
defaultDecorate,
hasEditableTarget,
isEventHandled,
isTargetInsideVoid,
} from '../editable'
import { IS_FIREFOX } from '../../utils/environment'
export const AndroidEditableNoError = (props: EditableProps): JSX.Element => {
return (
<ErrorBoundary>
<AndroidEditable {...props} />
</ErrorBoundary>
)
}
/**
* 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 = useMemo(() => new AndroidInputManager(editor), [editor])
// 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,
}),
[]
)
// Update element-related weak maps with the DOM element ref.
useIsomorphicLayoutEffect(() => {
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)
}
})
// Update element-related weak maps with the DOM element ref.
useIsomorphicLayoutEffect(() => {
if (ref.current) {
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)
}
})
// Whenever the editor updates, make sure the DOM selection state is in sync.
useIsomorphicLayoutEffect(() => {
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,
})
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(() => {
// COMPAT: In Firefox, it's not enough to create a range, you also need
// to focus the contenteditable element too. (2016/11/16)
if (newDomRange && IS_FIREFOX) {
el.focus()
}
state.isUpdatingSelection = false
})
})
useLayoutEffect(() => {
inputManager.onDidMount()
return () => {
inputManager.onWillUnmount()
}
}, [])
const prevValue = useRef<Descendant[]>([])
if (prevValue.current !== editor.children) {
inputManager.onRender()
prevValue.current = editor.children
}
useLayoutEffect(() => {
inputManager.onDidUpdate()
})
// 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(() => {
if (!readOnly && !state.isComposing && !state.isUpdatingSelection) {
inputManager.onSelect()
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)
}
}
}, 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(() => {
window.document.addEventListener('selectionchange', onDOMSelectionChange)
return () => {
window.document.removeEventListener(
'selectionchange',
onDOMSelectionChange
)
}
}, [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,
})
}
const [contentKey, setContentKey] = useState(0)
const onRestoreDOM = useCallback(() => {
setContentKey(prev => prev + 1)
}, [contentKey])
EDITOR_TO_RESTORE_DOM.set(editor, onRestoreDOM)
useEffect(() => {
return () => {
EDITOR_TO_RESTORE_DOM.delete(editor)
}
}, [])
return (
<ReadOnlyContext.Provider value={readOnly}>
<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={{
// 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,
}}
onCompositionEnd={useCallback(
(event: React.CompositionEvent<HTMLDivElement>) => {
if (
hasEditableTarget(editor, event.target) &&
!isEventHandled(event, attributes.onCompositionEnd)
) {
state.isComposing = false
inputManager.onCompositionEnd()
}
},
[attributes.onCompositionEnd]
)}
onCompositionStart={useCallback(
(event: React.CompositionEvent<HTMLDivElement>) => {
if (
hasEditableTarget(editor, event.target) &&
!isEventHandled(event, attributes.onCompositionStart)
) {
state.isComposing = true
inputManager.onCompositionStart()
}
},
[attributes.onCompositionStart]
)}
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 && Range.isExpanded(selection)) {
Editor.deleteFragment(editor)
}
}
},
[readOnly, attributes.onCut]
)}
onFocus={useCallback(
(event: React.FocusEvent<HTMLDivElement>) => {
if (
!readOnly &&
!state.isUpdatingSelection &&
hasEditableTarget(editor, event.target) &&
!isEventHandled(event, attributes.onFocus)
) {
const el = ReactEditor.toDOMNode(editor, editor)
state.latestElement = window.document.activeElement
IS_FOCUSED.set(editor, true)
}
},
[readOnly, attributes.onFocus]
)}
onKeyDown={useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {},
[readOnly, attributes.onKeyDown]
)}
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) &&
isPlainTextOnlyPaste(event.nativeEvent) &&
!readOnly
) {
event.preventDefault()
ReactEditor.insertData(editor, event.clipboardData)
}
},
[readOnly, attributes.onPaste]
)}
>
{useChildren({
decorations,
node: editor,
renderElement,
renderPlaceholder,
renderLeaf,
selection: editor.selection,
})}
</Component>
</ReadOnlyContext.Provider>
)
}

View File

@@ -0,0 +1,558 @@
import { ReactEditor } from '../../plugin/react-editor'
import { Editor, Node as SlateNode, Path, Range, Transforms } from 'slate'
import { Diff, diffText } from './diff-text'
import { DOMNode } from '../../utils/dom'
import {
EDITOR_TO_ON_CHANGE,
EDITOR_TO_RESTORE_DOM,
} from '../../utils/weak-maps'
const debug = (...message: any[]) => {}
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)
}
}
function flushController(editor: ReactEditor): void {
try {
const onChange = EDITOR_TO_ON_CHANGE.get(editor)
if (onChange) {
onChange()
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(err)
}
}
function renderSync(editor: ReactEditor, fn: () => void) {
try {
fn()
flushController(editor)
} catch (err) {
// eslint-disable-next-line no-console
console.error(err)
}
}
/**
* Takes text from a dom node and an offset within that text and returns an
* object with fixed text and fixed offset which removes zero width spaces
* and adjusts the offset.
*
* Optionally, if an `isLastNode` argument is passed in, it will also remove
* a trailing newline.
*/
function fixTextAndOffset(
prevText: string,
prevOffset = 0,
isLastNode = false
) {
let nextOffset = prevOffset
let nextText = prevText
// remove the last newline if we are in the last node of a block
const lastChar = nextText.charAt(nextText.length - 1)
if (isLastNode && lastChar === '\n') {
nextText = nextText.slice(0, -1)
}
const maxOffset = nextText.length
if (nextOffset > maxOffset) nextOffset = maxOffset
return { text: nextText, offset: nextOffset }
}
/**
* Based loosely on:
*
* https://github.com/facebook/draft-js/blob/master/src/component/handlers/composition/DOMObserver.js
* https://github.com/ProseMirror/prosemirror-view/blob/master/src/domobserver.js
*
* But is an analysis mainly for `backspace` and `enter` as we handle
* compositions as a single operation.
*
* @param editor
*/
export class AndroidInputManager {
/**
* A MutationObserver that flushes to the method `flush`
*/
private readonly observer: MutationObserver
private rootEl?: HTMLElement = undefined
/**
* Object that keeps track of the most recent state
*/
private lastPath?: Path = undefined
private lastDiff?: Diff = undefined
private lastRange?: Range = undefined
private lastDomNode?: Node = undefined
constructor(private editor: ReactEditor) {
this.observer = new MutationObserver(this.flush)
}
onDidMount = () => {
this.connect()
}
onDidUpdate = () => {
this.connect()
}
/**
* Connect the MutationObserver to a specific editor root element
*/
connect = () => {
debug('connect')
const rootEl = ReactEditor.toDOMNode(this.editor, this.editor)
if (this.rootEl === rootEl) return
this.rootEl = rootEl
debug('connect:run')
this.observer.disconnect()
this.observer.observe(rootEl, {
childList: true,
characterData: true,
subtree: true,
characterDataOldValue: true,
})
}
onWillUnmount = () => {
this.disconnect()
}
disconnect = () => {
debug('disconnect')
this.observer.disconnect()
this.rootEl = undefined
}
onRender = () => {
this.disconnect()
this.clearDiff()
}
private clearDiff = () => {
debug('clearDiff')
this.bufferedMutations.length = 0
this.lastPath = undefined
this.lastDiff = undefined
}
/**
* Clear the `last` properties related to an action only
*/
private clearAction = () => {
debug('clearAction')
this.bufferedMutations.length = 0
this.lastDiff = undefined
this.lastDomNode = undefined
}
/**
* Apply the last `diff`
*
* We don't want to apply the `diff` at the time it is created because we
* may be in a composition. There are a few things that trigger the applying
* of the saved diff. Sometimes on its own and sometimes immediately before
* doing something else with the Editor.
*
* - `onCompositionEnd` event
* - `onSelect` event only when the user has moved into a different node
* - The user hits `enter`
* - The user hits `backspace` and removes an inline node
* - The user hits `backspace` and merges two blocks
*/
private applyDiff = () => {
debug('applyDiff')
if (this.lastPath === undefined || this.lastDiff === undefined) return
debug('applyDiff:run')
const range: Range = {
anchor: { path: this.lastPath, offset: this.lastDiff.start },
focus: { path: this.lastPath, offset: this.lastDiff.end },
}
Transforms.insertText(this.editor, this.lastDiff.insertText, { at: range })
}
/**
* Handle `enter` that splits block
*/
private splitBlock = () => {
debug('splitBlock')
renderSync(this.editor, () => {
this.applyDiff()
Transforms.splitNodes(this.editor, { always: true })
ReactEditor.focus(this.editor)
this.clearAction()
restoreDOM(this.editor)
flushController(this.editor)
})
}
/**
* Handle `backspace` that merges blocks
*/
private mergeBlock = () => {
debug('mergeBlock')
/**
* The delay is required because hitting `enter`, `enter` then `backspace`
* in a word results in the cursor being one position to the right in
* Android 9.
*
* Slate sets the position to `0` and we even check it immediately after
* setting it and it is correct, but somewhere Android moves it to the right.
*
* This happens only when using the virtual keyboard. Hitting enter on a
* hardware keyboard does not trigger this bug.
*
* The call to `focus` is required because when we switch examples then
* merge a block, we lose focus in Android 9 (possibly others).
*/
window.requestAnimationFrame(() => {
renderSync(this.editor, () => {
this.applyDiff()
Transforms.select(this.editor, this.lastRange!)
Editor.deleteBackward(this.editor)
ReactEditor.focus(this.editor)
this.clearAction()
restoreDOM(this.editor)
flushController(this.editor)
})
})
}
/**
* The requestId used to the save selection
*/
private onSelectTimeoutId: number | null = null
private bufferedMutations: MutationRecord[] = []
private startActionFrameId: number | null = null
private isFlushing = false
/**
* Mark the beginning of an action. The action happens when the
* `requestAnimationFrame` expires.
*
* If `onKeyDown` is called again, it pushes the `action` to a new
* `requestAnimationFrame` and cancels the old one.
*/
private startAction = () => {
debug('startAction')
if (this.onSelectTimeoutId) {
window.cancelAnimationFrame(this.onSelectTimeoutId)
this.onSelectTimeoutId = null
}
this.isFlushing = true
if (this.startActionFrameId) {
window.cancelAnimationFrame(this.startActionFrameId)
}
this.startActionFrameId = window.requestAnimationFrame((): void => {
if (this.bufferedMutations.length > 0) {
this.flushAction(this.bufferedMutations)
}
this.startActionFrameId = null
this.bufferedMutations.length = 0
this.isFlushing = false
})
}
/**
* Handle MutationObserver flush
*
* @param mutations
*/
flush = (mutations: MutationRecord[]) => {
debug('flush')
this.bufferedMutations.push(...mutations)
this.startAction()
}
/**
* Handle a `requestAnimationFrame` long batch of mutations.
*
* @param mutations
*/
private flushAction = (mutations: MutationRecord[]) => {
try {
debug('flushAction', mutations.length, mutations)
const removedNodes = mutations.filter(
mutation => mutation.removedNodes.length > 0
).length
const addedNodes = mutations.filter(
mutation => mutation.addedNodes.length > 0
).length
if (removedNodes > addedNodes) {
this.mergeBlock()
} else if (addedNodes > removedNodes) {
this.splitBlock()
} else {
this.resolveDOMNode(mutations[0].target.parentNode!)
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(err)
}
}
/**
* Takes a DOM Node and resolves it against Slate's Document.
*
* Saves the changes to `last.diff` which can be applied later using
* `applyDiff()`
*
* @param domNode
*/
private resolveDOMNode = (domNode: DOMNode) => {
debug('resolveDOMNode')
let node
try {
node = ReactEditor.toSlateNode(this.editor, domNode)
} catch (e) {
// not in react model yet.
return
}
const path = ReactEditor.findPath(this.editor, node)
const prevText = SlateNode.string(node)
// COMPAT: If this is the last leaf, and the DOM text ends in a new line,
// we will have added another new line in <Leaf>'s render method to account
// for browsers collapsing a single trailing new lines, so remove it.
const [block] = Editor.parent(
this.editor,
ReactEditor.findPath(this.editor, node)
)
const isLastNode = block.children[block.children.length - 1] === node
const fix = fixTextAndOffset(domNode.textContent!, 0, isLastNode)
const nextText = fix.text
debug('resolveDOMNode:pre:post', prevText, nextText)
// If the text is no different, there is no diff.
if (nextText === prevText) {
this.lastDiff = undefined
return
}
const diff = diffText(prevText, nextText)
if (diff === null) {
this.lastDiff = undefined
return
}
this.lastPath = path
this.lastDiff = diff
debug('resolveDOMNode:diff', this.lastDiff)
}
/**
* handle `onCompositionStart`
*/
onCompositionStart = () => {
debug('onCompositionStart')
}
/**
* handle `onCompositionEnd`
*/
onCompositionEnd = () => {
debug('onCompositionEnd')
/**
* The timing on the `setTimeout` with `20` ms is sensitive.
*
* It cannot use `requestAnimationFrame` because it is too short.
*
* Android 9, for example, when you type `it ` the space will first trigger
* a `compositionEnd` for the `it` part before the mutation for the ` `.
* This means that we end up with `it` if we trigger too soon because it
* is on the wrong value.
*/
window.setTimeout(() => {
if (this.lastDiff !== undefined) {
debug('onCompositionEnd:applyDiff')
renderSync(this.editor, () => {
this.applyDiff()
const domRange = window.getSelection()!.getRangeAt(0)
const domText = domRange.startContainer.textContent!
const offset = domRange.startOffset
const fix = fixTextAndOffset(domText, offset)
let range = ReactEditor.toSlateRange(this.editor, domRange, {
exactMatch: true,
})
if (range !== null) {
range = {
...range,
anchor: {
...range.anchor,
offset: fix.offset,
},
focus: {
...range.focus,
offset: fix.offset,
},
}
/**
* We must call `restoreDOM` even though this is applying a `diff` which
* should not require it. But if you type `it me. no.` on a blank line
* with a block following it, the next line will merge with the this
* line. A mysterious `keydown` with `input` of backspace appears in the
* event stream which the user not React caused.
*
* `focus` is required as well because otherwise we lose focus on hitting
* `enter` in such a scenario.
*/
Transforms.select(this.editor, range)
ReactEditor.focus(this.editor)
}
this.clearAction()
restoreDOM(this.editor)
})
}
}, 20)
}
/**
* Handle `onSelect` event
*
* Save the selection after a `requestAnimationFrame`
*
* - If we're not in the middle of flushing mutations
* - and cancel save if a mutation runs before the `requestAnimationFrame`
*/
onSelect = () => {
debug('onSelect:try')
if (this.onSelectTimeoutId !== null) {
window.cancelAnimationFrame(this.onSelectTimeoutId)
this.onSelectTimeoutId = null
}
// Don't capture the last selection if the selection was made during the
// flushing of DOM mutations. This means it is all part of one user action.
if (this.isFlushing) return
this.onSelectTimeoutId = window.requestAnimationFrame(() => {
debug('onSelect:save-selection')
const domSelection = window.getSelection()
if (
domSelection === null ||
domSelection.anchorNode === null ||
domSelection.anchorNode.textContent === null ||
domSelection.focusNode === null ||
domSelection.focusNode.textContent === null
)
return
const { offset: anchorOffset } = fixTextAndOffset(
domSelection.anchorNode.textContent,
domSelection.anchorOffset
)
const { offset: focusOffset } = fixTextAndOffset(
domSelection.focusNode!.textContent!,
domSelection.focusOffset
)
let range = ReactEditor.toSlateRange(this.editor, domSelection, {
exactMatch: true,
})
if (range !== null) {
range = {
focus: {
path: range.focus.path,
offset: focusOffset,
},
anchor: {
path: range.anchor.path,
offset: anchorOffset,
},
}
debug('onSelect:save-data', {
anchorNode: domSelection.anchorNode,
anchorOffset: domSelection.anchorOffset,
focusNode: domSelection.focusNode,
focusOffset: domSelection.focusOffset,
range,
})
// If the `domSelection` has moved into a new node, then reconcile with
// `applyDiff`
if (
domSelection.isCollapsed &&
this.lastDomNode !== domSelection.anchorNode &&
this.lastDiff !== undefined
) {
debug('onSelect:applyDiff', this.lastDiff)
this.applyDiff()
Transforms.select(this.editor, range)
this.clearAction()
flushController(this.editor)
restoreDOM(this.editor)
}
this.lastRange = range
this.lastDomNode = domSelection.anchorNode
}
})
}
}
export default AndroidInputManager

View File

@@ -0,0 +1,111 @@
/**
* 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
}
type TextRange = {
start: number
end: number
}
/**
* 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 type Diff = {
start: number
end: number
insertText: string
removeText: string
}

View File

@@ -0,0 +1 @@
export { AndroidEditable, AndroidEditableNoError } from './android-editable'

View File

@@ -1137,13 +1137,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 +1160,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 +1171,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 +1185,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 +1198,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 +1216,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
) => {

View File

@@ -1,8 +1,12 @@
// Components
// Environment-dependent Editable
import { Editable as DefaultEditable } from './components/editable'
import { AndroidEditableNoError as AndroidEditable } from './components/android/android-editable'
import { IS_ANDROID } from './utils/environment'
export {
RenderElementProps,
RenderLeafProps,
Editable,
RenderPlaceholderProps,
DefaultPlaceholder,
} from './components/editable'
@@ -21,3 +25,4 @@ export { useSlate } from './hooks/use-slate'
// Plugin
export { ReactEditor } from './plugin/react-editor'
export { withReact } from './plugin/with-react'
export const Editable = !IS_ANDROID ? DefaultEditable : AndroidEditable

View File

@@ -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)

View File

@@ -26,9 +26,9 @@ const areRangesSameLine = (
* A helper utility that returns the end portion of a `Range`
* which is located on a single line.
*
* @param {Editor} editor The editor object to compare against
* @param {Range} parentRange The parent range to compare against
* @returns {Range} A valid portion of the parentRange which is one a single line
* @param editor The editor object to compare against
* @param parentRange The parent range to compare against
* @returns A valid portion of the parentRange which is one a single line
*/
export const findCurrentLineRange = (
editor: ReactEditor,

View File

@@ -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.
*/

View File

@@ -1,13 +1,13 @@
import React, { useState, useMemo } from 'react'
import React, { useMemo, useState } from 'react'
import imageExtensions from 'image-extensions'
import isUrl from 'is-url'
import { Transforms, createEditor, Descendant } from 'slate'
import {
Slate,
Editable,
useSlateStatic,
useSelected,
Slate,
useFocused,
useSelected,
useSlateStatic,
withReact,
} from 'slate-react'
import { withHistory } from 'slate-history'

View File

@@ -1,12 +1,12 @@
import React, { useCallback, useMemo, useState } from 'react'
import isHotkey from 'is-hotkey'
import { Editable, withReact, useSlate, Slate } from 'slate-react'
import { Editable, Slate, useSlate, withReact } from 'slate-react'
import {
Editor,
Transforms,
createEditor,
Descendant,
Editor,
Element as SlateElement,
Transforms,
} from 'slate'
import { withHistory } from 'slate-history'
@@ -28,7 +28,7 @@ const RichTextExample = () => {
const editor = useMemo(() => withHistory(withReact(createEditor())), [])
return (
<Slate editor={editor} value={value} onChange={value => setValue(value)}>
<Slate editor={editor} value={value} onChange={setValue}>
<Toolbar>
<MarkButton format="bold" icon="format_bold" />
<MarkButton format="italic" icon="format_italic" />

View File

@@ -2592,18 +2592,19 @@
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
"@types/react-dom@^16.9.4":
version "16.9.8"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423"
integrity sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA==
version "16.9.11"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.11.tgz#752e223a1592a2c10f2668b215a0e0667f4faab1"
integrity sha512-3UuR4MoWf5spNgrG6cwsmT9DdRghcR4IDFOzNZ6+wcmacxkFykcb5ji0nNVm9ckBT4BCxvCrJJbM4+EYsEEVIg==
dependencies:
"@types/react" "*"
"@types/react" "^16"
"@types/react@*", "@types/react@^16.9.13":
version "16.9.46"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.46.tgz#f0326cd7adceda74148baa9bff6e918632f5069e"
integrity sha512-dbHzO3aAq1lB3jRQuNpuZ/mnu+CdD3H0WVaaBQA8LTT3S33xhVBUj232T8M3tAhSWJs/D/UqORYUlJNl/8VQZg==
"@types/react@^16", "@types/react@^16.9.13":
version "16.14.5"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.5.tgz#2c39b5cadefaf4829818f9219e5e093325979f4d"
integrity sha512-YRRv9DNZhaVTVRh9Wmmit7Y0UFhEVqXqCSw3uazRWMxa2x85hWQZ5BN24i7GXZbaclaLXEcodEeIHsjBA8eAMw==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/resolve@0.0.8":
@@ -2613,6 +2614,11 @@
dependencies:
"@types/node" "*"
"@types/scheduler@*":
version "0.16.1"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275"
integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA==
"@types/semver@^6.0.0":
version "6.2.2"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-6.2.2.tgz#5c27df09ca39e3c9beb4fae6b95f4d71426df0a9"
@@ -9539,9 +9545,9 @@ randomfill@^1.0.3:
safe-buffer "^5.1.0"
react-dom@^16.12.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f"
integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==
version "16.14.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89"
integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
@@ -9603,9 +9609,9 @@ react-values@^0.3.0:
integrity sha512-K0SWzJBIuEDwWtDDZqbEm8XWaSy3LUJB7hZm1iHUo6wTwWQWD28TEn/T9YkbrJHrw2PNwZFL3nMKjkk09BmbqA==
react@^16.12.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==
version "16.14.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"