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:
@@ -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}>
|
||||
|
||||
@@ -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('会话不可用')
|
||||
|
||||
Reference in New Issue
Block a user