1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-17 20:51:20 +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

@@ -0,0 +1,5 @@
---
'slate-react': patch
---
[AndroidEditor] Solve input association problems and add click events.

View File

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

View File

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

View File

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