From c2c56cfca5ebb3e8820cf0159f812772a5bd3f1d Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 25 Apr 2026 23:46:31 +0200 Subject: [PATCH] fix: harden inline wikilink ime handling --- src/components/InlineWikilinkInput.tsx | 34 +++++++++- src/components/WikilinkChatInput.test.tsx | 66 ++++++++++++++++++++ src/components/useInlineWikilinkSelection.ts | 30 +++++++-- 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/components/InlineWikilinkInput.tsx b/src/components/InlineWikilinkInput.tsx index 25e3712e..04b5ff07 100644 --- a/src/components/InlineWikilinkInput.tsx +++ b/src/components/InlineWikilinkInput.tsx @@ -1,4 +1,5 @@ import { + useLayoutEffect, useMemo, useRef, useState, @@ -20,6 +21,7 @@ import { extractDroppedPathText } from './inlineWikilinkDropText' import { readSelectionRange, serializeInlineNode, + type InlineSelectionRange, } from './inlineWikilinkDom' import { buildPendingPasteState, @@ -145,6 +147,7 @@ export function InlineWikilinkInput({ paletteFooter, }: InlineWikilinkInputProps) { const [renderVersion, forceRender] = useState(0) + const isComposingRef = useRef(false) const segments = useMemo( () => buildInlineWikilinkSegments(value, entries), [entries, value], @@ -163,11 +166,18 @@ export function InlineWikilinkInput({ value, onChange, inputRef, + isComposingRef, }) const pendingPasteRef = useRef(null) - const isComposingRef = useRef(false) const pendingCompositionInputRef = useRef(false) const handledFileDropRef = useRef(false) + const pendingFocusAfterRemountRef = useRef(null) + useLayoutEffect(() => { + const target = pendingFocusAfterRemountRef.current + if (!target) return + pendingFocusAfterRemountRef.current = null + focusSelectionRange(target) + }, [focusSelectionRange, renderVersion]) const activeQuery = useMemo( () => selectionRange.start === selectionRange.end ? findActiveWikilinkQuery(value, selectionIndex) @@ -304,7 +314,27 @@ export function InlineWikilinkInput({ const flushPendingCompositionInput = () => { if (isComposingRef.current || !pendingCompositionInputRef.current) return pendingCompositionInputRef.current = false - syncValueFromEditor() + + const editor = editorRef.current + if (!editor) return + + if (containsUnsupportedInlineContent(editor)) { + recoverUnsupportedMutation() + return + } + + const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editor)) + const nextSelection = readSelectionRange(editor) + const clampedSelection: InlineSelectionRange = { + start: Math.min(nextSelection.start, nextValue.length), + end: Math.min(nextSelection.end, nextValue.length), + } + + const shouldRestoreFocus = document.activeElement === editor + pendingFocusAfterRemountRef.current = shouldRestoreFocus ? clampedSelection : null + onChange(nextValue) + setSelectionRange(clampedSelection) + forceRender((current) => current + 1) } const handleCompositionStart = () => { isComposingRef.current = true diff --git a/src/components/WikilinkChatInput.test.tsx b/src/components/WikilinkChatInput.test.tsx index 74726f23..f6d28009 100644 --- a/src/components/WikilinkChatInput.test.tsx +++ b/src/components/WikilinkChatInput.test.tsx @@ -233,6 +233,72 @@ describe('WikilinkChatInput', () => { expect(screen.getByTestId('wikilink-menu').textContent).toContain('Alpha') }) + it('clears IME-injected stray text nodes after composition ends', async () => { + const onDraftChange = vi.fn() + render() + const initialEditor = screen.getByTestId('agent-input') as HTMLDivElement + initialEditor.focus() + + fireEvent.compositionStart(initialEditor) + initialEditor.appendChild(document.createTextNode('你')) + fireEvent.input(initialEditor) + fireEvent.compositionEnd(initialEditor) + + await waitFor(() => { + expect(onDraftChange).toHaveBeenCalledWith('你') + }) + await waitFor(() => { + const editor = screen.getByTestId('agent-input') as HTMLDivElement + expect(editor.textContent).toBe('你') + }) + }) + + it('does not steal focus back if it was moved elsewhere during composition end', async () => { + const onDraftChange = vi.fn() + render( + <> + + + , + ) + const initialEditor = screen.getByTestId('agent-input') as HTMLDivElement + const otherTarget = screen.getByTestId('other-target') as HTMLButtonElement + initialEditor.focus() + + fireEvent.compositionStart(initialEditor) + initialEditor.appendChild(document.createTextNode('你')) + fireEvent.input(initialEditor) + + otherTarget.focus() + fireEvent.compositionEnd(initialEditor) + + await waitFor(() => { + expect(onDraftChange).toHaveBeenCalledWith('你') + }) + expect(document.activeElement).toBe(otherTarget) + }) + + it('does not reset the DOM selection while IME composition is active', () => { + const removeAllRanges = vi.spyOn(Selection.prototype, 'removeAllRanges') + render() + const editor = screen.getByTestId('agent-input') as HTMLDivElement + editor.focus() + + fireEvent.compositionStart(editor) + editor.appendChild(document.createTextNode('ni')) + setSelection(editor, 2) + + removeAllRanges.mockClear() + fireEvent.keyUp(editor) + fireEvent.click(editor) + fireEvent.mouseUp(editor) + + expect(removeAllRanges).not.toHaveBeenCalled() + expect(editor.textContent).toContain('ni') + + removeAllRanges.mockRestore() + }) + it('deletes an inline chip with a single Backspace', () => { render() updateEditorText('edit my [[alp') diff --git a/src/components/useInlineWikilinkSelection.ts b/src/components/useInlineWikilinkSelection.ts index d93ee0c5..107bdb9d 100644 --- a/src/components/useInlineWikilinkSelection.ts +++ b/src/components/useInlineWikilinkSelection.ts @@ -16,12 +16,30 @@ interface UseInlineWikilinkSelectionArgs { value: string onChange: (value: string) => void inputRef?: React.RefObject + isComposingRef?: React.RefObject +} + +function shouldSkipSelectionSync( + editor: HTMLDivElement | null, + isComposingRef?: React.RefObject, +) { + return !editor || isComposingRef?.current === true +} + +function canApplySelectionRange( + editor: HTMLDivElement | null, + isComposingRef?: React.RefObject, +) { + return editor !== null + && document.activeElement === editor + && isComposingRef?.current !== true } export function useInlineWikilinkSelection({ value, onChange, inputRef, + isComposingRef, }: UseInlineWikilinkSelectionArgs) { const editorRef = useRef(null) const [selectionRange, setSelectionRange] = useState({ @@ -37,9 +55,10 @@ export function useInlineWikilinkSelection({ }, [inputRef]) const syncSelectionRange = useCallback(() => { - if (!editorRef.current) return - setSelectionRange(readSelectionRange(editorRef.current)) - }, []) + const editor = editorRef.current + if (shouldSkipSelectionSync(editor, isComposingRef)) return + setSelectionRange(readSelectionRange(editor)) + }, [isComposingRef]) const focusSelectionRange = useCallback((nextSelectionRange: InlineSelectionRange) => { const editor = editorRef.current @@ -63,10 +82,9 @@ export function useInlineWikilinkSelection({ useLayoutEffect(() => { const editor = editorRef.current - if (!editor) return - if (document.activeElement !== editor) return + if (!canApplySelectionRange(editor, isComposingRef)) return applySelectionRange(editor, selectionRange) - }, [selectionRange, value]) + }, [isComposingRef, selectionRange, value]) return { editorRef,