mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-08-06 15:26:34 +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:
5
.changeset/small-laws-remember.md
Normal file
5
.changeset/small-laws-remember.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'slate-react': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added android keyboard support for slate editor
|
@@ -151,15 +151,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"use-isnan": "error",
|
"use-isnan": "error",
|
||||||
"valid-jsdoc": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"prefer": {
|
|
||||||
"return": "returns"
|
|
||||||
},
|
|
||||||
"requireReturn": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"valid-typeof": "error",
|
"valid-typeof": "error",
|
||||||
"yield-star-spacing": [
|
"yield-star-spacing": [
|
||||||
"error",
|
"error",
|
||||||
|
@@ -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
|
||||||
|
}
|
||||||
|
}
|
452
packages/slate-react/src/components/android/android-editable.tsx
Normal file
452
packages/slate-react/src/components/android/android-editable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@@ -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
|
111
packages/slate-react/src/components/android/diff-text.ts
Normal file
111
packages/slate-react/src/components/android/diff-text.ts
Normal 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
|
||||||
|
}
|
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, AndroidEditableNoError } from './android-editable'
|
@@ -1137,13 +1137,13 @@ export const DefaultPlaceholder = ({
|
|||||||
* A default memoized decorate function.
|
* A default memoized decorate function.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const defaultDecorate: (entry: NodeEntry) => Range[] = () => []
|
export const defaultDecorate: (entry: NodeEntry) => Range[] = () => []
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if two DOM range objects are equal.
|
* Check if two DOM range objects are equal.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const isRangeEqual = (a: DOMRange, b: DOMRange) => {
|
export const isRangeEqual = (a: DOMRange, b: DOMRange) => {
|
||||||
return (
|
return (
|
||||||
(a.startContainer === b.startContainer &&
|
(a.startContainer === b.startContainer &&
|
||||||
a.startOffset === b.startOffset &&
|
a.startOffset === b.startOffset &&
|
||||||
@@ -1160,7 +1160,7 @@ const isRangeEqual = (a: DOMRange, b: DOMRange) => {
|
|||||||
* Check if the target is in the editor.
|
* Check if the target is in the editor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const hasTarget = (
|
export const hasTarget = (
|
||||||
editor: ReactEditor,
|
editor: ReactEditor,
|
||||||
target: EventTarget | null
|
target: EventTarget | null
|
||||||
): target is DOMNode => {
|
): target is DOMNode => {
|
||||||
@@ -1171,7 +1171,7 @@ const hasTarget = (
|
|||||||
* Check if the target is editable and in the editor.
|
* Check if the target is editable and in the editor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const hasEditableTarget = (
|
export const hasEditableTarget = (
|
||||||
editor: ReactEditor,
|
editor: ReactEditor,
|
||||||
target: EventTarget | null
|
target: EventTarget | null
|
||||||
): target is DOMNode => {
|
): target is DOMNode => {
|
||||||
@@ -1185,7 +1185,7 @@ const hasEditableTarget = (
|
|||||||
* Check if the target is inside void and in the editor.
|
* Check if the target is inside void and in the editor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const isTargetInsideVoid = (
|
export const isTargetInsideVoid = (
|
||||||
editor: ReactEditor,
|
editor: ReactEditor,
|
||||||
target: EventTarget | null
|
target: EventTarget | null
|
||||||
): boolean => {
|
): boolean => {
|
||||||
@@ -1198,7 +1198,7 @@ const isTargetInsideVoid = (
|
|||||||
* Check if an event is overrided by a handler.
|
* Check if an event is overrided by a handler.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const isEventHandled = <
|
export const isEventHandled = <
|
||||||
EventType extends React.SyntheticEvent<unknown, unknown>
|
EventType extends React.SyntheticEvent<unknown, unknown>
|
||||||
>(
|
>(
|
||||||
event: EventType,
|
event: EventType,
|
||||||
@@ -1216,7 +1216,7 @@ const isEventHandled = <
|
|||||||
* Check if a DOM event is overrided by a handler.
|
* Check if a DOM event is overrided by a handler.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const isDOMEventHandled = <E extends Event>(
|
export const isDOMEventHandled = <E extends Event>(
|
||||||
event: E,
|
event: E,
|
||||||
handler?: (event: E) => void
|
handler?: (event: E) => void
|
||||||
) => {
|
) => {
|
||||||
|
@@ -1,8 +1,12 @@
|
|||||||
// Components
|
// 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 {
|
export {
|
||||||
RenderElementProps,
|
RenderElementProps,
|
||||||
RenderLeafProps,
|
RenderLeafProps,
|
||||||
Editable,
|
|
||||||
RenderPlaceholderProps,
|
RenderPlaceholderProps,
|
||||||
DefaultPlaceholder,
|
DefaultPlaceholder,
|
||||||
} from './components/editable'
|
} from './components/editable'
|
||||||
@@ -21,3 +25,4 @@ export { useSlate } from './hooks/use-slate'
|
|||||||
// Plugin
|
// Plugin
|
||||||
export { ReactEditor } from './plugin/react-editor'
|
export { ReactEditor } from './plugin/react-editor'
|
||||||
export { withReact } from './plugin/with-react'
|
export { withReact } from './plugin/with-react'
|
||||||
|
export const Editable = !IS_ANDROID ? DefaultEditable : AndroidEditable
|
||||||
|
@@ -7,6 +7,9 @@ export const IS_IOS =
|
|||||||
export const IS_APPLE =
|
export const IS_APPLE =
|
||||||
typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent)
|
typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent)
|
||||||
|
|
||||||
|
export const IS_ANDROID =
|
||||||
|
typeof navigator !== 'undefined' && /Android/.test(navigator.userAgent)
|
||||||
|
|
||||||
export const IS_FIREFOX =
|
export const IS_FIREFOX =
|
||||||
typeof navigator !== 'undefined' &&
|
typeof navigator !== 'undefined' &&
|
||||||
/^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent)
|
/^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent)
|
||||||
|
@@ -26,9 +26,9 @@ const areRangesSameLine = (
|
|||||||
* A helper utility that returns the end portion of a `Range`
|
* A helper utility that returns the end portion of a `Range`
|
||||||
* which is located on a single line.
|
* which is located on a single line.
|
||||||
*
|
*
|
||||||
* @param {Editor} editor The editor object to compare against
|
* @param editor The editor object to compare against
|
||||||
* @param {Range} parentRange The parent range to compare against
|
* @param parentRange The parent range to compare against
|
||||||
* @returns {Range} A valid portion of the parentRange which is one a single line
|
* @returns A valid portion of the parentRange which is one a single line
|
||||||
*/
|
*/
|
||||||
export const findCurrentLineRange = (
|
export const findCurrentLineRange = (
|
||||||
editor: ReactEditor,
|
editor: ReactEditor,
|
||||||
|
@@ -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_ON_CHANGE = new WeakMap<Editor, () => void>()
|
||||||
|
|
||||||
|
export const EDITOR_TO_RESTORE_DOM = new WeakMap<Editor, () => void>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Symbols.
|
* Symbols.
|
||||||
*/
|
*/
|
||||||
|
@@ -1,13 +1,13 @@
|
|||||||
import React, { useState, useMemo } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import imageExtensions from 'image-extensions'
|
import imageExtensions from 'image-extensions'
|
||||||
import isUrl from 'is-url'
|
import isUrl from 'is-url'
|
||||||
import { Transforms, createEditor, Descendant } from 'slate'
|
import { Transforms, createEditor, Descendant } from 'slate'
|
||||||
import {
|
import {
|
||||||
Slate,
|
|
||||||
Editable,
|
Editable,
|
||||||
useSlateStatic,
|
Slate,
|
||||||
useSelected,
|
|
||||||
useFocused,
|
useFocused,
|
||||||
|
useSelected,
|
||||||
|
useSlateStatic,
|
||||||
withReact,
|
withReact,
|
||||||
} from 'slate-react'
|
} from 'slate-react'
|
||||||
import { withHistory } from 'slate-history'
|
import { withHistory } from 'slate-history'
|
||||||
|
@@ -1,12 +1,12 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react'
|
import React, { useCallback, useMemo, useState } from 'react'
|
||||||
import isHotkey from 'is-hotkey'
|
import isHotkey from 'is-hotkey'
|
||||||
import { Editable, withReact, useSlate, Slate } from 'slate-react'
|
import { Editable, Slate, useSlate, withReact } from 'slate-react'
|
||||||
import {
|
import {
|
||||||
Editor,
|
|
||||||
Transforms,
|
|
||||||
createEditor,
|
createEditor,
|
||||||
Descendant,
|
Descendant,
|
||||||
|
Editor,
|
||||||
Element as SlateElement,
|
Element as SlateElement,
|
||||||
|
Transforms,
|
||||||
} from 'slate'
|
} from 'slate'
|
||||||
import { withHistory } from 'slate-history'
|
import { withHistory } from 'slate-history'
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ const RichTextExample = () => {
|
|||||||
const editor = useMemo(() => withHistory(withReact(createEditor())), [])
|
const editor = useMemo(() => withHistory(withReact(createEditor())), [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Slate editor={editor} value={value} onChange={value => setValue(value)}>
|
<Slate editor={editor} value={value} onChange={setValue}>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<MarkButton format="bold" icon="format_bold" />
|
<MarkButton format="bold" icon="format_bold" />
|
||||||
<MarkButton format="italic" icon="format_italic" />
|
<MarkButton format="italic" icon="format_italic" />
|
||||||
|
34
yarn.lock
34
yarn.lock
@@ -2592,18 +2592,19 @@
|
|||||||
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
|
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
|
||||||
|
|
||||||
"@types/react-dom@^16.9.4":
|
"@types/react-dom@^16.9.4":
|
||||||
version "16.9.8"
|
version "16.9.11"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.8.tgz#fe4c1e11dfc67155733dfa6aa65108b4971cb423"
|
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.11.tgz#752e223a1592a2c10f2668b215a0e0667f4faab1"
|
||||||
integrity sha512-ykkPQ+5nFknnlU6lDd947WbQ6TE3NNzbQAkInC2EKY1qeYdTKp7onFusmYZb+ityzx2YviqT6BXSu+LyWWJwcA==
|
integrity sha512-3UuR4MoWf5spNgrG6cwsmT9DdRghcR4IDFOzNZ6+wcmacxkFykcb5ji0nNVm9ckBT4BCxvCrJJbM4+EYsEEVIg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@types/react" "^16"
|
||||||
|
|
||||||
"@types/react@*", "@types/react@^16.9.13":
|
"@types/react@^16", "@types/react@^16.9.13":
|
||||||
version "16.9.46"
|
version "16.14.5"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.46.tgz#f0326cd7adceda74148baa9bff6e918632f5069e"
|
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.14.5.tgz#2c39b5cadefaf4829818f9219e5e093325979f4d"
|
||||||
integrity sha512-dbHzO3aAq1lB3jRQuNpuZ/mnu+CdD3H0WVaaBQA8LTT3S33xhVBUj232T8M3tAhSWJs/D/UqORYUlJNl/8VQZg==
|
integrity sha512-YRRv9DNZhaVTVRh9Wmmit7Y0UFhEVqXqCSw3uazRWMxa2x85hWQZ5BN24i7GXZbaclaLXEcodEeIHsjBA8eAMw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/prop-types" "*"
|
"@types/prop-types" "*"
|
||||||
|
"@types/scheduler" "*"
|
||||||
csstype "^3.0.2"
|
csstype "^3.0.2"
|
||||||
|
|
||||||
"@types/resolve@0.0.8":
|
"@types/resolve@0.0.8":
|
||||||
@@ -2613,6 +2614,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@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":
|
"@types/semver@^6.0.0":
|
||||||
version "6.2.2"
|
version "6.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-6.2.2.tgz#5c27df09ca39e3c9beb4fae6b95f4d71426df0a9"
|
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"
|
safe-buffer "^5.1.0"
|
||||||
|
|
||||||
react-dom@^16.12.0:
|
react-dom@^16.12.0:
|
||||||
version "16.13.1"
|
version "16.14.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f"
|
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89"
|
||||||
integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==
|
integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
object-assign "^4.1.1"
|
object-assign "^4.1.1"
|
||||||
@@ -9603,9 +9609,9 @@ react-values@^0.3.0:
|
|||||||
integrity sha512-K0SWzJBIuEDwWtDDZqbEm8XWaSy3LUJB7hZm1iHUo6wTwWQWD28TEn/T9YkbrJHrw2PNwZFL3nMKjkk09BmbqA==
|
integrity sha512-K0SWzJBIuEDwWtDDZqbEm8XWaSy3LUJB7hZm1iHUo6wTwWQWD28TEn/T9YkbrJHrw2PNwZFL3nMKjkk09BmbqA==
|
||||||
|
|
||||||
react@^16.12.0:
|
react@^16.12.0:
|
||||||
version "16.13.1"
|
version "16.14.0"
|
||||||
resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
|
resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
|
||||||
integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==
|
integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
object-assign "^4.1.1"
|
object-assign "^4.1.1"
|
||||||
|
Reference in New Issue
Block a user