diff --git a/.changeset/lucky-pandas-shave.md b/.changeset/lucky-pandas-shave.md new file mode 100644 index 0000000000..4b64ac972d --- /dev/null +++ b/.changeset/lucky-pandas-shave.md @@ -0,0 +1,5 @@ +--- +'slate-react': patch +--- + +Fix IME composition being cancelled on the first character typed into an empty leaf on Android, which left the first character behind as a separate, uncomposed character (typing 안녕 in Korean produced ㅇ안녕). diff --git a/packages/slate-react/src/components/string.tsx b/packages/slate-react/src/components/string.tsx index 5f1dce96b4..01e96857a4 100644 --- a/packages/slate-react/src/components/string.tsx +++ b/packages/slate-react/src/components/string.tsx @@ -3,7 +3,7 @@ import { Editor, Text, Path, Element, Node } from 'slate' import { ReactEditor, useSlateStatic } from '..' import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect' -import { IS_ANDROID } from 'slate-dom' +import { IS_ANDROID, IS_COMPOSING } from 'slate-dom' import { MARK_PLACEHOLDER_SYMBOL } from 'slate-dom' /** @@ -61,6 +61,7 @@ const String = (props: { */ const TextString = (props: { text: string; isTrailing?: boolean }) => { const { text, isTrailing = false } = props + const editor = useSlateStatic() const ref = useRef(null) const getTextContent = () => { return `${text ?? ''}${isTrailing ? '\n' : ''}` @@ -81,6 +82,20 @@ const TextString = (props: { text: string; isTrailing?: boolean }) => { const textWithTrailing = getTextContent() if (ref.current && ref.current.textContent !== textWithTrailing) { + // On Android the DOM intentionally runs ahead of the value while the IME + // composes, since input is buffered as pending diffs. Writing + // `textContent` here would replace the text node the composition lives in + // and cancel it; the pending diffs reconcile this span when the + // composition ends. + if (IS_ANDROID && IS_COMPOSING.get(editor)) { + const composingNode = + ReactEditor.getWindow(editor).getSelection()?.anchorNode + + if (composingNode && ref.current.contains(composingNode)) { + return + } + } + ref.current.textContent = textWithTrailing } @@ -135,9 +150,15 @@ export const ZeroWidthString = (props: { // be because accepting an IME suggestion when at the start of a block (no // preceding \uFEFF) removes one or more DOM elements that `toSlateRange` // depends on. (https://github.com/ianstormtaylor/slate/issues/5703) + // A leaf with no text node at all forces the IME to create one when the + // first character is composed there, and no re-render can then touch the + // leaf without cancelling the composition. Rendering the zero-width space on + // Android too gives the composition a React-owned text node to live in; the + // wrapper keeps its data-slate-zero-width attributes, so selection mapping + // is unchanged. return ( - {!IS_ANDROID || !isLineBreak ? '\uFEFF' : null} + {'\uFEFF'} {isLineBreak ?
: null}
) diff --git a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts index 1bc8041c16..62130f1cfd 100644 --- a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts +++ b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts @@ -1,4 +1,7 @@ import { DebouncedFunc } from 'lodash' +// Default import: the UMD build resolves react-dom through its CommonJS entry, +// whose named exports rollup cannot discover statically. +import ReactDOM from 'react-dom' import { Editor, Location, Node, Path, Point, Range, Transforms } from 'slate' import { ReactEditor } from '../../plugin/react-editor' import { @@ -34,6 +37,16 @@ const RESOLVE_DELAY = 25 // Time with no user interaction before the current user action is considered as done. const FLUSH_DELAY = 200 +// Time with no composition input after which a composition that never ended is +// treated as stale. Long enough that pausing mid-word does not cut a live +// composition short. +const COMPOSITION_IDLE_TIMEOUT = 5000 + +// How often a deferred flush re-checks whether the composition is still live. +// Only bounds how quickly the value catches up with a composition that ended +// without firing `compositionend`; one that ends normally flushes immediately. +const COMPOSITION_RECHECK_DELAY = 50 + // Replace with `const debug = console.log` to debug const debug = (..._: unknown[]) => {} @@ -78,6 +91,8 @@ export function createAndroidInputManager({ let compositionEndTimeoutId: ReturnType | null = null let flushTimeoutId: ReturnType | null = null let actionTimeoutId: ReturnType | null = null + let lastCompositionActivity = 0 + let compositionCleared = false let idCounter = 0 let insertPositionHint: StringDiff | null | false = false @@ -123,6 +138,45 @@ export function createAndroidInputManager({ action.run() } + // `compositionend` is not fired reliably in every browser, which Slate + // already works around on keydown. Deferring flushes on a composition that + // never ends would strand the typed text outside the value, so a composition + // that has gone quiet, or that has lost focus, no longer holds flushes back. + const isCompositionLive = () => { + if (!IS_COMPOSING.get(editor) || compositionCleared) { + return false + } + + try { + const editable = ReactEditor.toDOMNode(editor, editor) + const { activeElement } = ReactEditor.getWindow(editor).document + + if (activeElement !== editable && !editable.contains(activeElement)) { + return false + } + } catch { + // Fall back to the idle check below if the editable can't be resolved. + } + + return Date.now() - lastCompositionActivity < COMPOSITION_IDLE_TIMEOUT + } + + // A leaf that Slate models as empty renders as a zero-width string, which on + // Android is a `
` rather than a text node. When the IME composes the + // first character into such a leaf, the browser creates a text node for the + // composition. Applying the pending diffs re-renders that leaf as a text + // leaf, which unmounts the zero-width span and takes the text node the IME is + // composing in with it, silently cancelling the composition. + const hasPendingDiffsInEmptyLeaf = () => + !!EDITOR_TO_PENDING_DIFFS.get(editor)?.some(({ path }) => { + try { + return Node.leaf(editor, path).text.length === 0 + } catch { + // The path may no longer resolve if the editor changed underneath us. + return false + } + }) + const flush = () => { if (flushTimeoutId) { clearTimeout(flushTimeoutId) @@ -134,6 +188,17 @@ export function createAndroidInputManager({ actionTimeoutId = null } + // Defer flushing until the composition ends, so that the re-render that + // would replace the composing text node cannot happen mid-composition. + // Composing into a leaf that already has text is unaffected: applying the + // diff there only updates `textContent`, so the value still updates on + // every `compositionupdate`. + if (isCompositionLive() && hasPendingDiffsInEmptyLeaf()) { + debug('deferring flush during composition in empty leaf') + flushTimeoutId = setTimeout(flush, COMPOSITION_RECHECK_DELAY) + return + } + if (!hasPendingDiffs() && !hasPendingAction()) { applyPendingSelection() return @@ -253,9 +318,31 @@ export function createAndroidInputManager({ clearTimeout(compositionEndTimeoutId) } + // Diffs deferred by `flush` have to be applied synchronously here. IMEs + // that compose one syllable at a time (Hangul, kana) start the next + // composition in the same tick as this event, so an asynchronous render + // would replace the text node that composition has already started in. + if (hasPendingDiffsInEmptyLeaf() && !flushing) { + ReactDOM.flushSync(() => { + IS_COMPOSING.set(editor, false) + flush() + }) + updatePlaceholderVisibility() + return + } + compositionEndTimeoutId = setTimeout(() => { IS_COMPOSING.set(editor, false) flush() + + if (compositionCleared) { + // A cancelled composition can take the empty leaf's text node with + // it. A forced render lets RestoreDOM put the leaf's DOM back, so the + // next composition starts from a clean state instead of a bare
. + EDITOR_TO_FORCE_RENDER.get(editor)?.() + } + + updatePlaceholderVisibility() }, RESOLVE_DELAY) } @@ -265,6 +352,8 @@ export function createAndroidInputManager({ debug('composition start') IS_COMPOSING.set(editor, true) + lastCompositionActivity = Date.now() + compositionCleared = false if (compositionEndTimeoutId) { clearTimeout(compositionEndTimeoutId) @@ -351,6 +440,38 @@ export function createAndroidInputManager({ } const { inputType: type } = event + + if (type === 'insertCompositionText' || type === 'deleteCompositionText') { + lastCompositionActivity = Date.now() + + if (event.data) { + compositionCleared = false + } else { + // The IME threw away what it was composing. The deferred text never + // reached the value, so the value is already in the desired state - + // drop the deferral instead of applying and re-deleting it, which + // would churn the DOM mid-composition and can make Android close the + // keyboard. + compositionCleared = true + const deferredDiffs = EDITOR_TO_PENDING_DIFFS.get(editor) + + if (deferredDiffs?.length) { + const allInEmptyLeaves = deferredDiffs.every(({ path }) => { + try { + return Node.leaf(editor, path).text.length === 0 + } catch { + return false + } + }) + + if (allInEmptyLeaves) { + EDITOR_TO_PENDING_DIFFS.set(editor, []) + EDITOR_TO_PENDING_SELECTION.delete(editor) + } + } + } + } + let targetRange: Range | null = null const data: DataTransfer | string | undefined = (event as any).dataTransfer || event.data || undefined diff --git a/site/examples/js/android-tests.jsx b/site/examples/js/android-tests.jsx index bdb956cb5b..c653e8ce1a 100644 --- a/site/examples/js/android-tests.jsx +++ b/site/examples/js/android-tests.jsx @@ -185,6 +185,26 @@ const TEST_CASES = [ }, ], }, + { + id: 'ime-first-character', + name: 'IME first character', + instructions: + 'Using a keyboard that composes (Korean, Japanese or pinyin), type a word into the empty first paragraph, then into the empty paragraph after "Second block". Every character must compose into the word you typed. If composition breaks, the first character is left behind on its own: typing 안녕 in Korean gives ㅇ안녕 or ㅇㅏㄴ녕.', + value: [ + { + type: 'paragraph', + children: [{ text: '' }], + }, + { + type: 'paragraph', + children: [{ text: 'Second block', bold: true }], + }, + { + type: 'paragraph', + children: [{ text: '' }], + }, + ], + }, ] const AndroidTestsExample = () => { const [testId, setTestId] = useState( diff --git a/site/examples/ts/android-tests.tsx b/site/examples/ts/android-tests.tsx index a74ae1c3ae..962a7fed04 100644 --- a/site/examples/ts/android-tests.tsx +++ b/site/examples/ts/android-tests.tsx @@ -192,6 +192,26 @@ const TEST_CASES: AndroidTestCase[] = [ }, ], }, + { + id: 'ime-first-character', + name: 'IME first character', + instructions: + 'Using a keyboard that composes (Korean, Japanese or pinyin), type a word into the empty first paragraph, then into the empty paragraph after "Second block". Every character must compose into the word you typed. If composition breaks, the first character is left behind on its own: typing 안녕 in Korean gives ㅇ안녕 or ㅇㅏㄴ녕.', + value: [ + { + type: 'paragraph', + children: [{ text: '' }], + }, + { + type: 'paragraph', + children: [{ text: 'Second block', bold: true }], + }, + { + type: 'paragraph', + children: [{ text: '' }], + }, + ], + }, ] const AndroidTestsExample = () => {