fix(web): improve rich composer IME and line-break handling (#1328)

This commit is contained in:
TEEK
2026-08-03 18:04:37 +08:00
committed by GitHub
parent ecd73b8c10
commit 0f78bfdc9a
3 changed files with 307 additions and 32 deletions
@@ -118,6 +118,8 @@ describe('insertLineBreakAtCaret', () => {
document.body.appendChild(root)
const hello = document.createTextNode('hello')
root.appendChild(hello)
Object.defineProperty(root, 'scrollHeight', { value: 120 })
root.scrollTop = 10
placeCaretAtEnd(root, hello)
insertLineBreakAtCaret(root)
@@ -125,6 +127,7 @@ describe('insertLineBreakAtCaret', () => {
const texts = Array.from(root.childNodes).map((n) => n.textContent ?? '')
expect(texts).toContain(CARET_PAD)
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\n')
expect(root.scrollTop).toBe(120)
})
it('does not pad when there is meaningful content after the caret', () => {
@@ -132,12 +135,41 @@ describe('insertLineBreakAtCaret', () => {
document.body.appendChild(root)
const text = document.createTextNode('helloworld')
root.appendChild(text)
Object.defineProperty(root, 'scrollHeight', { value: 120 })
root.scrollTop = 10
placeCaretInText(text, 5) // between hello|world
insertLineBreakAtCaret(root)
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\nworld')
expect(Array.from(root.childNodes).some((n) => n.textContent === CARET_PAD)).toBe(false)
expect(root.scrollTop).toBe(10)
})
it('does not scroll for a nested middle-line break', () => {
const root = document.createElement('div')
document.body.appendChild(root)
root.innerHTML = '<div>first</div><div>second</div>'
Object.defineProperty(root, 'scrollHeight', { value: 120 })
root.scrollTop = 10
placeCaretAtEnd(root, root.firstElementChild!.firstChild as Text)
insertLineBreakAtCaret(root)
expect(root.scrollTop).toBe(10)
})
it('scrolls for a line break at the end of nested blocks', () => {
const root = document.createElement('div')
document.body.appendChild(root)
root.innerHTML = '<div>first</div><div>second</div>'
Object.defineProperty(root, 'scrollHeight', { value: 120 })
root.scrollTop = 10
placeCaretAtEnd(root, root.lastElementChild!.firstChild as Text)
insertLineBreakAtCaret(root)
expect(root.scrollTop).toBe(120)
})
})
@@ -1,10 +1,11 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { flushSync } from 'react-dom'
import { useState } from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import { useRef, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
mirrorOffsetFromPoint,
RichComposerInput,
type RichComposerInputHandle,
segmentsFromEditor,
} from './RichComposerInput'
import { serializeComposerSegments } from '@/lib/composerSegments'
@@ -50,6 +51,43 @@ function SynchronousControlledHarness() {
)
}
function ProgrammaticEditHarness() {
const [value, setValue] = useState('')
const ref = useRef<RichComposerInputHandle>(null)
return (
<>
<button type="button" onClick={() => ref.current?.applyPlainSuggestion('hello')}>
Insert suggestion
</button>
<RichComposerInput
ref={ref}
value={value}
placeholder="Type a message"
onValueChange={setValue}
onMirrorChange={() => {}}
/>
</>
)
}
function LineBreakDeletionHarness() {
const [value, setValue] = useState('hello')
return (
<>
<output data-testid="controlled-value">{value}</output>
<RichComposerInput
value={value}
onValueChange={(next) => {
flushSync(() => setValue(next))
}}
onMirrorChange={() => {}}
/>
</>
)
}
describe('RichComposerInput controlled synchronization', () => {
afterEach(() => {
window.getSelection()?.removeAllRanges()
@@ -82,4 +120,128 @@ describe('RichComposerInput controlled synchronization', () => {
expect(serializeComposerSegments(segmentsFromEditor(editor))).toBe('external draft')
expect(selectionOffset(editor)).toBe('external draft'.length)
})
it('tracks placeholder visibility from the DOM during composition', () => {
render(
<RichComposerInput
value=""
placeholder="Type a message"
onValueChange={() => {}}
onMirrorChange={() => {}}
/>
)
const editor = screen.getByTestId('rich-composer-input')
expect(screen.getByText('Type a message')).toBeInTheDocument()
fireEvent.compositionStart(editor)
editor.textContent = 'dictated text'
fireEvent.input(editor)
expect(screen.queryByText('Type a message')).not.toBeInTheDocument()
editor.replaceChildren(document.createElement('br'))
fireEvent.input(editor)
expect(screen.getByText('Type a message')).toBeInTheDocument()
})
it('tracks placeholder visibility for programmatic insertion and deletion', () => {
render(<ProgrammaticEditHarness />)
const editor = screen.getByTestId('rich-composer-input')
expect(screen.getByText('Type a message')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Insert suggestion' }))
expect(screen.queryByText('Type a message')).not.toBeInTheDocument()
const text = editor.firstChild
expect(text).toBeInstanceOf(Text)
const range = document.createRange()
range.selectNodeContents(editor)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
fireEvent.keyDown(editor, { key: 'Backspace' })
expect(screen.getByText('Type a message')).toBeInTheDocument()
})
it('deletes exactly one trailing line break per Backspace', () => {
render(<LineBreakDeletionHarness />)
const editor = screen.getByTestId('rich-composer-input')
const text = editor.firstChild
expect(text).toBeInstanceOf(Text)
editor.focus()
placeCaret(text as Text, text?.textContent?.length ?? 0)
fireEvent.keyDown(editor, { key: 'Enter' })
expect(screen.getByTestId('controlled-value').textContent).toBe('hello\n')
fireEvent.keyDown(editor, { key: 'Enter' })
expect(screen.getByTestId('controlled-value').textContent).toBe('hello\n\n')
fireEvent.keyDown(editor, { key: 'Backspace' })
expect(screen.getByTestId('controlled-value').textContent).toBe('hello\n')
fireEvent.keyDown(editor, { key: 'Backspace' })
expect(screen.getByTestId('controlled-value').textContent).toBe('hello')
expect(selectionOffset(editor)).toBe('hello'.length)
})
it('handles soft-keyboard backward deletion through beforeinput', () => {
render(<LineBreakDeletionHarness />)
const editor = screen.getByTestId('rich-composer-input')
const text = editor.firstChild
expect(text).toBeInstanceOf(Text)
editor.focus()
placeCaret(text as Text, text?.textContent?.length ?? 0)
fireEvent.keyDown(editor, { key: 'Enter' })
const event = new InputEvent('beforeinput', {
bubbles: true,
cancelable: true,
inputType: 'deleteContentBackward',
})
expect(editor.dispatchEvent(event)).toBe(false)
expect(screen.getByTestId('controlled-value').textContent).toBe('hello')
expect(selectionOffset(editor)).toBe('hello'.length)
})
it('leaves ordinary beforeinput deletion to the browser', () => {
render(<LineBreakDeletionHarness />)
const editor = screen.getByTestId('rich-composer-input')
const text = editor.firstChild
expect(text).toBeInstanceOf(Text)
editor.focus()
placeCaret(text as Text, text?.textContent?.length ?? 0)
const event = new InputEvent('beforeinput', {
bubbles: true,
cancelable: true,
inputType: 'deleteContentBackward',
})
expect(editor.dispatchEvent(event)).toBe(true)
expect(event.defaultPrevented).toBe(false)
})
it('does not forward Enter while composition is active', () => {
const onKeyDown = vi.fn()
render(
<RichComposerInput
value="hello"
onValueChange={() => {}}
onMirrorChange={() => {}}
onKeyDown={onKeyDown}
/>
)
const editor = screen.getByTestId('rich-composer-input')
fireEvent.compositionStart(editor)
fireEvent.keyDown(editor, { key: 'Enter', isComposing: false })
expect(onKeyDown).not.toHaveBeenCalled()
expect(serializeComposerSegments(segmentsFromEditor(editor))).toBe('hello')
})
})
@@ -111,6 +111,35 @@ function stripCaretPad(text: string): string {
return text.replaceAll(CARET_PAD, '')
}
function caretIsAfterCaretPad(root: HTMLElement): boolean {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return false
const range = selection.getRangeAt(0)
if (!range.collapsed || !root.contains(range.startContainer)) return false
const { startContainer, startOffset } = range
if (startContainer.nodeType === Node.TEXT_NODE) {
const text = startContainer.textContent ?? ''
return startOffset > 0 && text[startOffset - 1] === CARET_PAD
}
if (startContainer.nodeType !== Node.ELEMENT_NODE || startOffset === 0) return false
const previous = startContainer.childNodes[startOffset - 1]
return previous?.nodeType === Node.TEXT_NODE
&& (previous.textContent ?? '').endsWith(CARET_PAD)
}
function selectionIsAfterCaretPad(
root: HTMLElement,
mirror: string,
selection: ComposerSelection
): boolean {
return selection.start === selection.end
&& selection.start > 0
&& mirror[selection.start - 1] === '\n'
&& caretIsAfterCaretPad(root)
}
type ComposerDomSpan = {
/** Mirror offset at the point inside this node where its visible content begins. */
start: number
@@ -277,6 +306,13 @@ export function insertLineBreakAtCaret(root: HTMLElement): void {
root.focus()
const range = sel.getRangeAt(0)
const trailingRange = range.cloneRange()
trailingRange.collapse(false)
trailingRange.setEnd(root, root.childNodes.length)
const trailingRoot = document.createElement('div')
trailingRoot.append(trailingRange.cloneContents())
const selectionEndsAtEditorEnd =
mirrorComposerSegments(segmentsFromEditor(trailingRoot)).length === 0
range.deleteContents()
const nl = document.createTextNode('\n')
range.insertNode(nl)
@@ -290,6 +326,8 @@ export function insertLineBreakAtCaret(root: HTMLElement): void {
range.collapse(true)
sel.removeAllRanges()
sel.addRange(range)
// Restore native textarea scrolling for trailing line breaks.
if (selectionEndsAtEditorEnd) root.scrollTop = root.scrollHeight
}
function renderSegmentsToEditor(
@@ -419,6 +457,10 @@ function mirrorOffsetFromMappedPoint(
return mapping.mirrorLength
}
function editorDomIsEmpty(root: HTMLElement): boolean {
return (root.textContent ?? '').length === 0
}
/** Exported for unit tests — maps a DOM caret point into mirror-string offset. */
export function mirrorOffsetFromPoint(root: HTMLElement, endContainer: Node, endOffset: number): number {
const mapping = mapComposerEditorDom(root)
@@ -589,6 +631,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
const tooltipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hoveredChipRef = useRef<HTMLElement | null>(null)
const [mentionTooltip, setMentionTooltip] = useState<MentionTooltipState | null>(null)
const [domIsEmpty, setDomIsEmpty] = useState(value.length === 0)
const clearMentionTooltip = useCallback(() => {
if (tooltipTimerRef.current) {
@@ -599,9 +642,15 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
setMentionTooltip(null)
}, [])
const renderEditorSegments = useCallback((root: HTMLElement, segments: readonly ComposerSegment[]) => {
renderSegmentsToEditor(root, segments, resolveSessionMentionTooltip)
setDomIsEmpty(editorDomIsEmpty(root))
}, [resolveSessionMentionTooltip])
const emitFromDom = useCallback(() => {
const root = rootRef.current
if (!root) return
setDomIsEmpty(editorDomIsEmpty(root))
const segments = segmentsFromEditor(root)
const serialized = serializeComposerSegments(segments)
const selection = getMirrorSelection(root)
@@ -615,7 +664,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
const root = rootRef.current
if (!root) return
const segments = parseComposerSegments(next)
renderSegmentsToEditor(root, segments, resolveSessionMentionTooltip)
renderEditorSegments(root, segments)
lastEmittedRef.current = next
clearMentionTooltip()
const mirror = mirrorComposerSegments(segments)
@@ -627,7 +676,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
setMirrorSelection(root, sel)
}
onMirrorChange({ text: mirror, selection: sel })
}, [clearMentionTooltip, onMirrorChange, resolveSessionMentionTooltip])
}, [clearMentionTooltip, onMirrorChange, renderEditorSegments])
useLayoutEffect(() => {
if (value === lastEmittedRef.current) return
@@ -671,7 +720,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
const selection = getMirrorSelection(root)
const result = insertSessionMentionInComposerSegments(segments, selection, mention, prefixes)
const serialized = serializeComposerSegments(result.segments)
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
renderEditorSegments(root, result.segments)
lastEmittedRef.current = serialized
setMirrorSelection(root, result.selection)
onValueChange(serialized)
@@ -690,7 +739,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
const selection = getMirrorSelection(root)
const result = insertPlainTextInComposerSegments(segments, selection, suggestionText, prefixes)
const serialized = serializeComposerSegments(result.segments)
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
renderEditorSegments(root, result.segments)
lastEmittedRef.current = serialized
setMirrorSelection(root, result.selection)
onValueChange(serialized)
@@ -700,7 +749,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
})
return { text: serialized, selection: result.selection }
},
}), [onMirrorChange, onValueChange, resolveSessionMentionTooltip, value])
}), [onMirrorChange, onValueChange, renderEditorSegments, value])
useEffect(() => () => {
if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current)
@@ -776,7 +825,11 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
const handleInput = useCallback((_e: ReactFormEvent<HTMLDivElement>) => {
clearMentionTooltip()
if (composingRef.current) return
if (composingRef.current) {
const root = rootRef.current
if (root) setDomIsEmpty(editorDomIsEmpty(root))
return
}
onEdit?.()
emitFromDom()
}, [clearMentionTooltip, emitFromDom, onEdit])
@@ -794,7 +847,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
parseComposerSegments(text),
)
const serialized = serializeComposerSegments(result.segments)
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
renderEditorSegments(root, result.segments)
lastEmittedRef.current = serialized
setMirrorSelection(root, result.selection)
onValueChange(serialized)
@@ -803,7 +856,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
selection: result.selection,
})
onEdit?.()
}, [onEdit, onMirrorChange, onValueChange, resolveSessionMentionTooltip])
}, [onEdit, onMirrorChange, onValueChange, renderEditorSegments])
const handleCopyOrCut = useCallback((e: ReactClipboardEvent<HTMLDivElement>, cut: boolean) => {
const root = rootRef.current
@@ -818,7 +871,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
clearMentionTooltip()
const result = deleteBackwardInComposerSegments(segments, selection)
const serialized = serializeComposerSegments(result.segments)
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
renderEditorSegments(root, result.segments)
lastEmittedRef.current = serialized
setMirrorSelection(root, result.selection)
onValueChange(serialized)
@@ -832,7 +885,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
onEdit,
onMirrorChange,
onValueChange,
resolveSessionMentionTooltip,
renderEditorSegments,
])
const handlePaste = useCallback((e: ReactClipboardEvent<HTMLDivElement>) => {
@@ -848,12 +901,53 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
insertPlainClipboardText(e.clipboardData?.getData('text/plain') ?? '')
}, [insertPlainClipboardText, onPaste])
const applyBackwardDelete = useCallback((
root: HTMLElement,
segments: readonly ComposerSegment[],
selection: ComposerSelection
) => {
clearMentionTooltip()
const result = deleteBackwardInComposerSegments(segments, selection)
const serialized = serializeComposerSegments(result.segments)
renderEditorSegments(root, result.segments)
lastEmittedRef.current = serialized
setMirrorSelection(root, result.selection)
onValueChange(serialized)
onMirrorChange({
text: mirrorComposerSegments(result.segments),
selection: result.selection,
})
onEdit?.()
}, [clearMentionTooltip, onEdit, onMirrorChange, onValueChange, renderEditorSegments])
useEffect(() => {
const root = rootRef.current
if (!root) return
const handleBeforeInput = (event: InputEvent) => {
if (
event.inputType !== 'deleteContentBackward'
|| event.isComposing
|| composingRef.current
|| !event.cancelable
) return
const segments = segmentsFromEditor(root)
const selection = getMirrorSelection(root)
const mirror = mirrorComposerSegments(segments)
if (!selectionIsAfterCaretPad(root, mirror, selection)) return
event.preventDefault()
applyBackwardDelete(root, segments, selection)
}
root.addEventListener('beforeinput', handleBeforeInput)
return () => root.removeEventListener('beforeinput', handleBeforeInput)
}, [applyBackwardDelete])
// No onDrop: intercepting without caretRangeFromPoint appends at EOF / no-ops
// in-editor moves. Native CE drop + plaintext-only / paste path is enough for #1215.
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.nativeEvent.isComposing) {
onKeyDown?.(e)
if (e.nativeEvent.isComposing || composingRef.current) {
return
}
if (e.key === 'Backspace' && !e.metaKey && !e.ctrlKey && !e.altKey) {
@@ -862,24 +956,14 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
const segments = segmentsFromEditor(root)
const selection = getMirrorSelection(root)
const mirror = mirrorComposerSegments(segments)
const afterCaretPad = selectionIsAfterCaretPad(root, mirror, selection)
const againstAtom =
selection.start === selection.end
&& selection.start > 0
&& mirror[selection.start - 1] === COMPOSER_MENTION_MIRROR_CHAR
if (againstAtom || selection.start !== selection.end) {
if (afterCaretPad || againstAtom || selection.start !== selection.end) {
e.preventDefault()
clearMentionTooltip()
const result = deleteBackwardInComposerSegments(segments, selection)
const serialized = serializeComposerSegments(result.segments)
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
lastEmittedRef.current = serialized
setMirrorSelection(root, result.selection)
onValueChange(serialized)
onMirrorChange({
text: mirrorComposerSegments(result.segments),
selection: result.selection,
})
onEdit?.()
applyBackwardDelete(root, segments, selection)
return
}
}
@@ -900,18 +984,15 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
emitFromDom()
}
}, [
applyBackwardDelete,
emitFromDom,
onEdit,
onKeyDown,
onMirrorChange,
onValueChange,
resolveSessionMentionTooltip,
clearMentionTooltip,
])
return (
<div className="relative min-w-0 flex-1">
{(!value || value.length === 0) && placeholder ? (
{domIsEmpty && placeholder ? (
<div
aria-hidden
className="pointer-events-none absolute inset-0 text-base leading-snug text-[var(--app-hint)]"