mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-08-16 20:24:01 +02:00
fix: [AndroidEditor] Solve input association problems and add click events. (#4734)
Co-authored-by: 陈耀耀 <chenyaoyao@taptap.com>
This commit is contained in:
5
.changeset/quick-feet-melt.md
Normal file
5
.changeset/quick-feet-melt.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'slate-react': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
[AndroidEditor] Solve input association problems and add click events.
|
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Descendant, Editor, Element, Node, Range, Transforms } from 'slate'
|
import { Editor, Element, Node, Range, Transforms, Path, Text } from 'slate'
|
||||||
import throttle from 'lodash/throttle'
|
import throttle from 'lodash/throttle'
|
||||||
|
import debounce from 'lodash/debounce'
|
||||||
import scrollIntoView from 'scroll-into-view-if-needed'
|
import scrollIntoView from 'scroll-into-view-if-needed'
|
||||||
|
|
||||||
import { DefaultPlaceholder, ReactEditor } from '../..'
|
import { DefaultPlaceholder, ReactEditor } from '../..'
|
||||||
@@ -23,8 +24,13 @@ import {
|
|||||||
IS_READ_ONLY,
|
IS_READ_ONLY,
|
||||||
NODE_TO_ELEMENT,
|
NODE_TO_ELEMENT,
|
||||||
PLACEHOLDER_SYMBOL,
|
PLACEHOLDER_SYMBOL,
|
||||||
|
IS_COMPOSING,
|
||||||
|
IS_ON_COMPOSITION_END,
|
||||||
|
EDITOR_ON_COMPOSITION_TEXT,
|
||||||
} from '../../utils/weak-maps'
|
} from '../../utils/weak-maps'
|
||||||
import { EditableProps } from '../editable'
|
import { normalizeTextInsertionRange } from './diff-text'
|
||||||
|
|
||||||
|
import { EditableProps, hasTarget } from '../editable'
|
||||||
import useChildren from '../../hooks/use-children'
|
import useChildren from '../../hooks/use-children'
|
||||||
import {
|
import {
|
||||||
defaultDecorate,
|
defaultDecorate,
|
||||||
@@ -41,6 +47,10 @@ import { useContentKey } from '../../hooks/use-content-key'
|
|||||||
* Editable.
|
* Editable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// https://github.com/facebook/draft-js/blob/main/src/component/handlers/composition/DraftEditorCompositionHandler.js#L41
|
||||||
|
// When using keyboard English association function, conpositionEnd triggered too fast, resulting in after `insertText` still maintain association state.
|
||||||
|
const RESOLVE_DELAY = 20
|
||||||
|
|
||||||
export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
||||||
const {
|
const {
|
||||||
autoFocus,
|
autoFocus,
|
||||||
@@ -56,6 +66,8 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
|||||||
...attributes
|
...attributes
|
||||||
} = props
|
} = props
|
||||||
const editor = useSlate()
|
const editor = useSlate()
|
||||||
|
// Rerender editor when composition status changed
|
||||||
|
const [isComposing, setIsComposing] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const inputManager = useAndroidInputManager(ref)
|
const inputManager = useAndroidInputManager(ref)
|
||||||
|
|
||||||
@@ -65,6 +77,7 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
|||||||
// Keep track of some state for the event handler logic.
|
// Keep track of some state for the event handler logic.
|
||||||
const state = useMemo(
|
const state = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
isComposing: false,
|
||||||
isUpdatingSelection: false,
|
isUpdatingSelection: false,
|
||||||
latestElement: null as DOMElement | null,
|
latestElement: null as DOMElement | null,
|
||||||
}),
|
}),
|
||||||
@@ -93,7 +106,11 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
|||||||
const root = ReactEditor.findDocumentOrShadowRoot(editor)
|
const root = ReactEditor.findDocumentOrShadowRoot(editor)
|
||||||
const domSelection = root.getSelection()
|
const domSelection = root.getSelection()
|
||||||
|
|
||||||
if (!domSelection || !ReactEditor.isFocused(editor)) {
|
if (
|
||||||
|
state.isComposing ||
|
||||||
|
!domSelection ||
|
||||||
|
!ReactEditor.isFocused(editor)
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,36 +207,6 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}, [autoFocus])
|
}, [autoFocus])
|
||||||
|
|
||||||
// Listen on the native `beforeinput` event to get real "Level 2" events. This
|
|
||||||
// is required because React's `beforeinput` is fake and never really attaches
|
|
||||||
// to the real event sadly. (2019/11/01)
|
|
||||||
// https://github.com/facebook/react/issues/11211
|
|
||||||
const onDOMBeforeInput = useCallback(
|
|
||||||
(event: InputEvent) => {
|
|
||||||
if (
|
|
||||||
!readOnly &&
|
|
||||||
hasEditableTarget(editor, event.target) &&
|
|
||||||
!isDOMEventHandled(event, propsOnDOMBeforeInput)
|
|
||||||
) {
|
|
||||||
inputManager.onUserInput()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[readOnly, propsOnDOMBeforeInput]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Attach a native DOM event handler for `beforeinput` events, because React's
|
|
||||||
// built-in `onBeforeInput` is actually a leaky polyfill that doesn't expose
|
|
||||||
// real `beforeinput` events sadly... (2019/11/04)
|
|
||||||
useIsomorphicLayoutEffect(() => {
|
|
||||||
const node = ref.current
|
|
||||||
|
|
||||||
// @ts-ignore The `beforeinput` event isn't recognized.
|
|
||||||
node?.addEventListener('beforeinput', onDOMBeforeInput)
|
|
||||||
|
|
||||||
// @ts-ignore The `beforeinput` event isn't recognized.
|
|
||||||
return () => node?.removeEventListener('beforeinput', onDOMBeforeInput)
|
|
||||||
}, [contentKey, propsOnDOMBeforeInput])
|
|
||||||
|
|
||||||
// Listen on the native `selectionchange` event to be able to update any time
|
// 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
|
// 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
|
// and non-standard so it doesn't fire until after a selection has been
|
||||||
@@ -228,7 +215,11 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
|||||||
const onDOMSelectionChange = useCallback(
|
const onDOMSelectionChange = useCallback(
|
||||||
throttle(() => {
|
throttle(() => {
|
||||||
try {
|
try {
|
||||||
if (!state.isUpdatingSelection && !inputManager.isReconciling.current) {
|
if (
|
||||||
|
!state.isComposing &&
|
||||||
|
!state.isUpdatingSelection &&
|
||||||
|
!inputManager.isReconciling.current
|
||||||
|
) {
|
||||||
const root = ReactEditor.findDocumentOrShadowRoot(editor)
|
const root = ReactEditor.findDocumentOrShadowRoot(editor)
|
||||||
const { activeElement } = root
|
const { activeElement } = root
|
||||||
const el = ReactEditor.toDOMNode(editor, editor)
|
const el = ReactEditor.toDOMNode(editor, editor)
|
||||||
@@ -272,6 +263,46 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
|||||||
[readOnly]
|
[readOnly]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const scheduleOnDOMSelectionChange = useMemo(
|
||||||
|
() => debounce(onDOMSelectionChange, 0),
|
||||||
|
[onDOMSelectionChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Listen on the native `beforeinput` event to get real "Level 2" events. This
|
||||||
|
// is required because React's `beforeinput` is fake and never really attaches
|
||||||
|
// to the real event sadly. (2019/11/01)
|
||||||
|
// https://github.com/facebook/react/issues/11211
|
||||||
|
const onDOMBeforeInput = useCallback(
|
||||||
|
(event: InputEvent) => {
|
||||||
|
if (
|
||||||
|
!readOnly &&
|
||||||
|
hasEditableTarget(editor, event.target) &&
|
||||||
|
!isDOMEventHandled(event, propsOnDOMBeforeInput)
|
||||||
|
) {
|
||||||
|
// Some IMEs/Chrome extensions like e.g. Grammarly set the selection immediately before
|
||||||
|
// triggering a `beforeinput` expecting the change to be applied to the immediately before
|
||||||
|
// set selection.
|
||||||
|
scheduleOnDOMSelectionChange.flush()
|
||||||
|
|
||||||
|
inputManager.onUserInput()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[readOnly, propsOnDOMBeforeInput]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attach a native DOM event handler for `beforeinput` events, because React's
|
||||||
|
// built-in `onBeforeInput` is actually a leaky polyfill that doesn't expose
|
||||||
|
// real `beforeinput` events sadly... (2019/11/04)
|
||||||
|
useIsomorphicLayoutEffect(() => {
|
||||||
|
const node = ref.current
|
||||||
|
|
||||||
|
// @ts-ignore The `beforeinput` event isn't recognized.
|
||||||
|
node?.addEventListener('beforeinput', onDOMBeforeInput)
|
||||||
|
|
||||||
|
// @ts-ignore The `beforeinput` event isn't recognized.
|
||||||
|
return () => node?.removeEventListener('beforeinput', onDOMBeforeInput)
|
||||||
|
}, [contentKey, propsOnDOMBeforeInput])
|
||||||
|
|
||||||
// Attach a native DOM event handler for `selectionchange`, because React's
|
// 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
|
// 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
|
// leaky polyfill that only fires on keypresses or clicks. Instead, we want to
|
||||||
@@ -279,15 +310,18 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
|||||||
// https://github.com/facebook/react/issues/5785
|
// https://github.com/facebook/react/issues/5785
|
||||||
useIsomorphicLayoutEffect(() => {
|
useIsomorphicLayoutEffect(() => {
|
||||||
const window = ReactEditor.getWindow(editor)
|
const window = ReactEditor.getWindow(editor)
|
||||||
window.document.addEventListener('selectionchange', onDOMSelectionChange)
|
window.document.addEventListener(
|
||||||
|
'selectionchange',
|
||||||
|
scheduleOnDOMSelectionChange
|
||||||
|
)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.document.removeEventListener(
|
window.document.removeEventListener(
|
||||||
'selectionchange',
|
'selectionchange',
|
||||||
onDOMSelectionChange
|
scheduleOnDOMSelectionChange
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
}, [scheduleOnDOMSelectionChange])
|
||||||
|
|
||||||
const decorations = decorate([editor, []])
|
const decorations = decorate([editor, []])
|
||||||
|
|
||||||
@@ -295,7 +329,8 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
|||||||
placeholder &&
|
placeholder &&
|
||||||
editor.children.length === 1 &&
|
editor.children.length === 1 &&
|
||||||
Array.from(Node.texts(editor)).length === 1 &&
|
Array.from(Node.texts(editor)).length === 1 &&
|
||||||
Node.string(editor) === ''
|
Node.string(editor) === '' &&
|
||||||
|
!isComposing
|
||||||
) {
|
) {
|
||||||
const start = Editor.start(editor, [])
|
const start = Editor.start(editor, [])
|
||||||
decorations.push({
|
decorations.push({
|
||||||
@@ -444,6 +479,123 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
|
|||||||
},
|
},
|
||||||
[readOnly, attributes.onBlur]
|
[readOnly, attributes.onBlur]
|
||||||
)}
|
)}
|
||||||
|
onClick={useCallback(
|
||||||
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (
|
||||||
|
!readOnly &&
|
||||||
|
hasTarget(editor, event.target) &&
|
||||||
|
!isEventHandled(event, attributes.onClick) &&
|
||||||
|
isDOMNode(event.target)
|
||||||
|
) {
|
||||||
|
const node = ReactEditor.toSlateNode(editor, event.target)
|
||||||
|
const path = ReactEditor.findPath(editor, node)
|
||||||
|
|
||||||
|
// At this time, the Slate document may be arbitrarily different,
|
||||||
|
// because onClick handlers can change the document before we get here.
|
||||||
|
// Therefore we must check that this path actually exists,
|
||||||
|
// and that it still refers to the same node.
|
||||||
|
if (Editor.hasPath(editor, path)) {
|
||||||
|
const lookupNode = Node.get(editor, path)
|
||||||
|
if (lookupNode === node) {
|
||||||
|
const start = Editor.start(editor, path)
|
||||||
|
const end = Editor.end(editor, path)
|
||||||
|
|
||||||
|
const startVoid = Editor.void(editor, { at: start })
|
||||||
|
const endVoid = Editor.void(editor, { at: end })
|
||||||
|
|
||||||
|
if (
|
||||||
|
startVoid &&
|
||||||
|
endVoid &&
|
||||||
|
Path.equals(startVoid[1], endVoid[1])
|
||||||
|
) {
|
||||||
|
const range = Editor.range(editor, start)
|
||||||
|
Transforms.select(editor, range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[readOnly, attributes.onClick]
|
||||||
|
)}
|
||||||
|
onCompositionEnd={useCallback(
|
||||||
|
(event: React.CompositionEvent<HTMLDivElement>) => {
|
||||||
|
if (
|
||||||
|
hasEditableTarget(editor, event.target) &&
|
||||||
|
!isEventHandled(event, attributes.onCompositionEnd)
|
||||||
|
) {
|
||||||
|
scheduleOnDOMSelectionChange.flush()
|
||||||
|
setTimeout(() => {
|
||||||
|
state.isComposing && setIsComposing(false)
|
||||||
|
state.isComposing = false
|
||||||
|
|
||||||
|
IS_COMPOSING.set(editor, false)
|
||||||
|
IS_ON_COMPOSITION_END.set(editor, true)
|
||||||
|
|
||||||
|
const insertedText =
|
||||||
|
EDITOR_ON_COMPOSITION_TEXT.get(editor) || []
|
||||||
|
|
||||||
|
// `insertedText` is set in `MutationObserver` constructor.
|
||||||
|
// If open phone keyboard association function, `CompositionEvent` will be triggered.
|
||||||
|
if (!insertedText.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
EDITOR_ON_COMPOSITION_TEXT.set(editor, [])
|
||||||
|
|
||||||
|
const { selection, marks } = editor
|
||||||
|
|
||||||
|
insertedText.forEach(insertion => {
|
||||||
|
const text = insertion.text.insertText
|
||||||
|
const at = normalizeTextInsertionRange(
|
||||||
|
editor,
|
||||||
|
selection,
|
||||||
|
insertion
|
||||||
|
)
|
||||||
|
if (marks) {
|
||||||
|
const node = { text, ...marks }
|
||||||
|
Transforms.insertNodes(editor, node, {
|
||||||
|
match: Text.isText,
|
||||||
|
at,
|
||||||
|
select: true,
|
||||||
|
})
|
||||||
|
editor.marks = null
|
||||||
|
} else {
|
||||||
|
Transforms.insertText(editor, text, {
|
||||||
|
at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, RESOLVE_DELAY)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[attributes.onCompositionEnd]
|
||||||
|
)}
|
||||||
|
onCompositionUpdate={useCallback(
|
||||||
|
(event: React.CompositionEvent<HTMLDivElement>) => {
|
||||||
|
if (
|
||||||
|
hasEditableTarget(editor, event.target) &&
|
||||||
|
!isEventHandled(event, attributes.onCompositionUpdate)
|
||||||
|
) {
|
||||||
|
!state.isComposing && setIsComposing(true)
|
||||||
|
state.isComposing = true
|
||||||
|
IS_COMPOSING.set(editor, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[attributes.onCompositionUpdate]
|
||||||
|
)}
|
||||||
|
onCompositionStart={useCallback(
|
||||||
|
(event: React.CompositionEvent<HTMLDivElement>) => {
|
||||||
|
if (
|
||||||
|
hasEditableTarget(editor, event.target) &&
|
||||||
|
!isEventHandled(event, attributes.onCompositionStart)
|
||||||
|
) {
|
||||||
|
!state.isComposing && setIsComposing(true)
|
||||||
|
state.isComposing = true
|
||||||
|
IS_COMPOSING.set(editor, true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[attributes.onCompositionStart]
|
||||||
|
)}
|
||||||
onPaste={useCallback(
|
onPaste={useCallback(
|
||||||
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
// this will make application/x-slate-fragment exist when onPaste attributes is passed
|
// this will make application/x-slate-fragment exist when onPaste attributes is passed
|
||||||
|
@@ -1,5 +1,10 @@
|
|||||||
import { ReactEditor } from '../../plugin/react-editor'
|
import { ReactEditor } from '../../plugin/react-editor'
|
||||||
import { Editor, Range, Transforms, Text } from 'slate'
|
import { Editor, Range, Transforms, Text } from 'slate'
|
||||||
|
import {
|
||||||
|
IS_COMPOSING,
|
||||||
|
IS_ON_COMPOSITION_END,
|
||||||
|
EDITOR_ON_COMPOSITION_TEXT,
|
||||||
|
} from '../../utils/weak-maps'
|
||||||
|
|
||||||
import { DOMNode } from '../../utils/dom'
|
import { DOMNode } from '../../utils/dom'
|
||||||
|
|
||||||
@@ -105,6 +110,17 @@ export class AndroidInputManager {
|
|||||||
|
|
||||||
const { selection, marks } = this.editor
|
const { selection, marks } = this.editor
|
||||||
|
|
||||||
|
// If it is in composing or after `onCompositionend`, set `EDITOR_ON_COMPOSITION_TEXT` and return.
|
||||||
|
// Text will be inserted on compositionend event.
|
||||||
|
if (
|
||||||
|
IS_COMPOSING.get(this.editor) ||
|
||||||
|
IS_ON_COMPOSITION_END.get(this.editor)
|
||||||
|
) {
|
||||||
|
EDITOR_ON_COMPOSITION_TEXT.set(this.editor, insertedText)
|
||||||
|
IS_ON_COMPOSITION_END.set(this.editor, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Insert the batched text diffs
|
// Insert the batched text diffs
|
||||||
insertedText.forEach(insertion => {
|
insertedText.forEach(insertion => {
|
||||||
const text = insertion.text.insertText
|
const text = insertion.text.insertText
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import { Ancestor, Editor, Node } from 'slate'
|
import { Ancestor, Editor, Node } from 'slate'
|
||||||
import { Key } from './key'
|
import { Key } from './key'
|
||||||
|
import { TextInsertion } from '../components/android/diff-text'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two weak maps that allow us rebuild a path given a node. They are populated
|
* Two weak maps that allow us rebuild a path given a node. They are populated
|
||||||
@@ -32,6 +33,17 @@ export const IS_READ_ONLY: WeakMap<Editor, boolean> = new WeakMap()
|
|||||||
export const IS_FOCUSED: WeakMap<Editor, boolean> = new WeakMap()
|
export const IS_FOCUSED: WeakMap<Editor, boolean> = new WeakMap()
|
||||||
export const IS_DRAGGING: WeakMap<Editor, boolean> = new WeakMap()
|
export const IS_DRAGGING: WeakMap<Editor, boolean> = new WeakMap()
|
||||||
export const IS_CLICKING: WeakMap<Editor, boolean> = new WeakMap()
|
export const IS_CLICKING: WeakMap<Editor, boolean> = new WeakMap()
|
||||||
|
export const IS_COMPOSING: WeakMap<Editor, boolean> = new WeakMap()
|
||||||
|
export const IS_ON_COMPOSITION_END: WeakMap<Editor, boolean> = new WeakMap()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weak maps for saving text on composition stage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const EDITOR_ON_COMPOSITION_TEXT: WeakMap<
|
||||||
|
Editor,
|
||||||
|
TextInsertion[]
|
||||||
|
> = new WeakMap()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Weak map for associating the context `onChange` context with the plugin.
|
* Weak map for associating the context `onChange` context with the plugin.
|
||||||
|
Reference in New Issue
Block a user