fix: harden inline wikilink ime handling

This commit is contained in:
lucaronin
2026-04-25 23:46:31 +02:00
parent 449a62e31f
commit c2c56cfca5
3 changed files with 122 additions and 8 deletions

View File

@@ -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<PendingPasteState | null>(null)
const isComposingRef = useRef(false)
const pendingCompositionInputRef = useRef(false)
const handledFileDropRef = useRef(false)
const pendingFocusAfterRemountRef = useRef<InlineSelectionRange | null>(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

View File

@@ -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(<Controlled onDraftChange={onDraftChange} />)
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(
<>
<Controlled onDraftChange={onDraftChange} />
<button data-testid="other-target">Other</button>
</>,
)
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(<Controlled />)
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(<Controlled />)
updateEditorText('edit my [[alp')

View File

@@ -16,12 +16,30 @@ interface UseInlineWikilinkSelectionArgs {
value: string
onChange: (value: string) => void
inputRef?: React.RefObject<HTMLDivElement | null>
isComposingRef?: React.RefObject<boolean>
}
function shouldSkipSelectionSync(
editor: HTMLDivElement | null,
isComposingRef?: React.RefObject<boolean>,
) {
return !editor || isComposingRef?.current === true
}
function canApplySelectionRange(
editor: HTMLDivElement | null,
isComposingRef?: React.RefObject<boolean>,
) {
return editor !== null
&& document.activeElement === editor
&& isComposingRef?.current !== true
}
export function useInlineWikilinkSelection({
value,
onChange,
inputRef,
isComposingRef,
}: UseInlineWikilinkSelectionArgs) {
const editorRef = useRef<HTMLDivElement | null>(null)
const [selectionRange, setSelectionRange] = useState<InlineSelectionRange>({
@@ -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,