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:
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
|
11
.eslintrc
11
.eslintrc
@@ -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 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@@ -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.
|
||||
*/
|
||||
|
||||
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
|
||||
) => {
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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,
|
||||
|
@@ -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.
|
||||
*/
|
||||
|
@@ -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'
|
||||
|
@@ -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" />
|
||||
|
34
yarn.lock
34
yarn.lock
@@ -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"
|
||||
|
Reference in New Issue
Block a user