1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-16 12:14:14 +02:00

fix: [AndroidEditor] Solve input association problems and add click events. (#4734)

Co-authored-by: 陈耀耀 <chenyaoyao@taptap.com>
This commit is contained in:
陈耀耀
2021-12-17 05:33:50 +08:00
committed by GitHub
parent 7bfb61c769
commit 3c07a8706e
4 changed files with 223 additions and 38 deletions

View File

@@ -1,6 +1,7 @@
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 debounce from 'lodash/debounce'
import scrollIntoView from 'scroll-into-view-if-needed'
import { DefaultPlaceholder, ReactEditor } from '../..'
@@ -23,8 +24,13 @@ import {
IS_READ_ONLY,
NODE_TO_ELEMENT,
PLACEHOLDER_SYMBOL,
IS_COMPOSING,
IS_ON_COMPOSITION_END,
EDITOR_ON_COMPOSITION_TEXT,
} 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 {
defaultDecorate,
@@ -41,6 +47,10 @@ import { useContentKey } from '../../hooks/use-content-key'
* 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 => {
const {
autoFocus,
@@ -56,6 +66,8 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
...attributes
} = props
const editor = useSlate()
// Rerender editor when composition status changed
const [isComposing, setIsComposing] = useState(false)
const ref = useRef<HTMLDivElement>(null)
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.
const state = useMemo(
() => ({
isComposing: false,
isUpdatingSelection: false,
latestElement: null as DOMElement | null,
}),
@@ -93,7 +106,11 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
const root = ReactEditor.findDocumentOrShadowRoot(editor)
const domSelection = root.getSelection()
if (!domSelection || !ReactEditor.isFocused(editor)) {
if (
state.isComposing ||
!domSelection ||
!ReactEditor.isFocused(editor)
) {
return
}
@@ -190,36 +207,6 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
}
}, [autoFocus])
// Listen on the native `beforeinput` event to get real "Level 2" events. This
// is required because React's `beforeinput` is fake and never really attaches
// to the real event sadly. (2019/11/01)
// https://github.com/facebook/react/issues/11211
const onDOMBeforeInput = useCallback(
(event: InputEvent) => {
if (
!readOnly &&
hasEditableTarget(editor, event.target) &&
!isDOMEventHandled(event, propsOnDOMBeforeInput)
) {
inputManager.onUserInput()
}
},
[readOnly, propsOnDOMBeforeInput]
)
// Attach a native DOM event handler for `beforeinput` events, because React's
// built-in `onBeforeInput` is actually a leaky polyfill that doesn't expose
// real `beforeinput` events sadly... (2019/11/04)
useIsomorphicLayoutEffect(() => {
const node = ref.current
// @ts-ignore The `beforeinput` event isn't recognized.
node?.addEventListener('beforeinput', onDOMBeforeInput)
// @ts-ignore The `beforeinput` event isn't recognized.
return () => node?.removeEventListener('beforeinput', onDOMBeforeInput)
}, [contentKey, propsOnDOMBeforeInput])
// Listen on the native `selectionchange` event to be able to update any time
// the selection changes. This is required because React's `onSelect` is leaky
// and non-standard so it doesn't fire until after a selection has been
@@ -228,7 +215,11 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
const onDOMSelectionChange = useCallback(
throttle(() => {
try {
if (!state.isUpdatingSelection && !inputManager.isReconciling.current) {
if (
!state.isComposing &&
!state.isUpdatingSelection &&
!inputManager.isReconciling.current
) {
const root = ReactEditor.findDocumentOrShadowRoot(editor)
const { activeElement } = root
const el = ReactEditor.toDOMNode(editor, editor)
@@ -272,6 +263,46 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
[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
// 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
@@ -279,15 +310,18 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
// https://github.com/facebook/react/issues/5785
useIsomorphicLayoutEffect(() => {
const window = ReactEditor.getWindow(editor)
window.document.addEventListener('selectionchange', onDOMSelectionChange)
window.document.addEventListener(
'selectionchange',
scheduleOnDOMSelectionChange
)
return () => {
window.document.removeEventListener(
'selectionchange',
onDOMSelectionChange
scheduleOnDOMSelectionChange
)
}
})
}, [scheduleOnDOMSelectionChange])
const decorations = decorate([editor, []])
@@ -295,7 +329,8 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
placeholder &&
editor.children.length === 1 &&
Array.from(Node.texts(editor)).length === 1 &&
Node.string(editor) === ''
Node.string(editor) === '' &&
!isComposing
) {
const start = Editor.start(editor, [])
decorations.push({
@@ -444,6 +479,123 @@ export const AndroidEditable = (props: EditableProps): JSX.Element => {
},
[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(
(event: React.ClipboardEvent<HTMLDivElement>) => {
// this will make application/x-slate-fragment exist when onPaste attributes is passed

View File

@@ -1,5 +1,10 @@
import { ReactEditor } from '../../plugin/react-editor'
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'
@@ -105,6 +110,17 @@ export class AndroidInputManager {
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
insertedText.forEach(insertion => {
const text = insertion.text.insertText

View File

@@ -1,5 +1,6 @@
import { Ancestor, Editor, Node } from 'slate'
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
@@ -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_DRAGGING: 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.