fix(web): scroll to the caret after an edit the composer performs itself

Pasting a long block left the view where it was while the caret sat at the
end of what was pasted. Paste, ctrl/meta-Enter newline and cut all suppress
the native edit — the machine owns the draft and the undo log — and restore
the caret with `setSelectionRange`, which reveals nothing: measured in
chromium and WebKit, before this branch as well as on it. Firefox happened to
reveal it, in the old geometry only.

The three restores now share one helper that measures the caret against the
hidden mirror — same draft, same metrics, same wrap width, so a Range
collapsed at the caret's index reports where the caret is without a caret API
— and scrolls the scrollport the minimum that brings the line inside, which
is what the browser does for typing. One scrollport is what makes this
possible at all: the reveal is finally a single offset to move.

Also from review: the composer's own focus() on unlock and session switch
passes preventScroll, so a session switch cannot move the transcript through
the taller textarea's reveal chain.
This commit is contained in:
creatixchu
2026-07-31 16:07:55 +08:00
parent 49f8cdd401
commit e465806120
7 changed files with 163 additions and 17 deletions

View File

@@ -63,6 +63,7 @@ export function InputBar({
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null)
const mirrorRef = useRef<HTMLDivElement | null>(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
// clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
const composingRef = useRef(false)
@@ -87,11 +88,43 @@ export function InputBar({
const locked = disabled
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
// Unlock (mount / session switch) returns focus to the box.
// Unlock (mount / session switch) returns focus to the box. `preventScroll`
// because this focus is ours, not a gesture: the textarea is as tall as the
// draft, so an unsuppressed reveal would walk up to the conversation
// scrollport and move the transcript under a user who only switched session.
useEffect(() => {
if (!locked) inputRef.current?.focus()
if (!locked) inputRef.current?.focus({ preventScroll: true })
}, [locked, sessionId])
// Caret restore after an edit the composer performs itself. The machine owns
// the draft and the undo log, so paste, ctrl/meta-Enter newline and cut all
// suppress the native edit and write the value through the machine — and a
// programmatic selection change reveals nothing: measured in chromium and
// WebKit, pasting a long block leaves the view where it was while the caret
// sits at the end of the draft. Native typing gets its reveal from the
// browser; these three have to ask for it, so they share one restore.
//
// The mirror is the caret's ruler: it renders the same draft at the same
// metrics and the same wrap width in the same stack (that is what makes it
// the height authority), so a Range collapsed at the caret's index reports
// where the caret is without a caret API. Minimal scroll, matching what the
// browser does for typing: move only far enough to bring the line inside.
const restoreCaret = (el: HTMLTextAreaElement, caret: number): void => {
requestAnimationFrame(() => {
el.setSelectionRange(caret, caret)
const scrollEl = scrollRef.current
const text = mirrorRef.current?.firstChild
if (scrollEl === null || !(text instanceof Text)) return
const range = document.createRange()
range.setStart(text, Math.min(caret, text.data.length))
range.collapse(true)
const at = range.getBoundingClientRect()
const box = scrollEl.getBoundingClientRect()
if (at.bottom > box.bottom) scrollEl.scrollTop += at.bottom - box.bottom
else if (at.top < box.top) scrollEl.scrollTop -= box.top - at.top
})
}
// Wheel chaining on the draft scrollport, one lifetime (it is never
// unmounted — the inert state renders the same element disabled). While the
// capped box can still move in this direction, keep the native scroll; only
@@ -167,8 +200,7 @@ export function InputBar({
const el = e.currentTarget
const sel = selectionOf(el)
keyboard.newline(sel)
const caret = sel.start + 1
requestAnimationFrame(() => { el.setSelectionRange(caret, caret) })
restoreCaret(el, sel.start + 1)
}
return
}
@@ -224,7 +256,7 @@ export function InputBar({
e.clipboardData.setData('text/plain', text)
if (cut && !machineBusy && !locked) {
keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 })
requestAnimationFrame(() => { el.setSelectionRange(start, start) })
restoreCaret(el, start)
}
void slice
}
@@ -243,7 +275,7 @@ export function InputBar({
// land (paste-upgrade). The DOM layer only starts the transaction.
keyboard.pasteBegin(text, sel)
const caret = sel.start + text.length
requestAnimationFrame(() => { el.setSelectionRange(caret, caret) })
restoreCaret(el, caret)
keyboard.track(keyboard.snapshot.draft, caret)
}
@@ -404,7 +436,7 @@ export function InputBar({
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
<div ref={mirrorRef} aria-hidden className={css.mirror} data-input-mirror>{`${draft}\n`}</div>
</div>
</div>
<div className={css.row}>

View File

@@ -4,7 +4,7 @@
// semantics (input stays free; primary turns stop), the machine pending lock,
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
@@ -18,6 +18,13 @@ import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
// jsdom implements no Range geometry at all — `Range.prototype.getBoundingClientRect`
// is absent — and the composer measures the caret with one when it restores the
// selection after an edit it performed itself. Every case here runs against a
// zero rect; the reveal case below substitutes its own and restores this one.
const ZERO_RECT = (): DOMRect => ({ top: 0, bottom: 0 }) as DOMRect
Range.prototype.getBoundingClientRect = ZERO_RECT
const SCTX = {} as ClientContext
const SID = 's1' as SessionId
@@ -296,9 +303,9 @@ describe('running and lock semantics (queue cut 1)', () => {
const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
// The caret is the textarea's and every visible glyph is the backdrop's, so
// one box has to carry both or an offset can exist in one and not the other.
// jsdom has no layout — the browser scenario owns the geometry; what is
// checkable here is that there is exactly one scrolling box and it holds
// both layers.
// jsdom has no layout and loads no stylesheet — which box scrolls is the
// browser scenario's to assert; what is checkable here is that the
// scrollport element holds both layers.
expect(scroll.contains(textarea)).toBe(true)
expect(scroll.contains(backdrop)).toBe(true)
// The glyph layer carries the draft and nothing else: with one scrollport
@@ -306,6 +313,41 @@ describe('running and lock semantics (queue cut 1)', () => {
expect(backdrop.textContent).toBe('line\n'.repeat(40))
})
it('an edit the composer performs itself scrolls the caret back into view', async () => {
// Paste, ctrl-Enter newline and cut suppress the native edit, so no engine
// reveals the caret for them. jsdom has no layout: the rects are stubbed,
// and what is asserted is the arithmetic — minimal scroll, in both
// directions, and nothing at all for a caret already inside the box.
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
const mirror = view.container.querySelector<HTMLElement>('[data-input-mirror]')!
expect(mirror.firstChild).toBeInstanceOf(Text)
scroll.getBoundingClientRect = () => ({ top: 100, bottom: 436 }) as DOMRect
Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true })
onTestFinished(() => { Range.prototype.getBoundingClientRect = ZERO_RECT })
const caretAt = (top: number): void => {
Range.prototype.getBoundingClientRect = () => ({ top, bottom: top + 24 }) as DOMRect
}
const settle = async (): Promise<void> => {
await act(async () => { await new Promise((resolve) => { requestAnimationFrame(() => { resolve(null) }) }) })
}
// Pasted text lands below the fold: scroll down by exactly the overshoot.
caretAt(500)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } })
await settle()
expect(scroll.scrollTop).toBe(88) // 524 - 436
// A caret already inside the box does not move it.
caretAt(200)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } })
await settle()
expect(scroll.scrollTop).toBe(88)
// Above the fold (a cut can leave it there): scroll back up.
caretAt(60)
fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } })
await settle()
expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60)
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
const { textarea } = bench({ disabled: true })
expect(textarea.placeholder).toBe('会话不可用')