fix: harden inline wikilink ime handling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user