fix(web): scroll the composer's glyph layer with its textarea

A composer draft past the 14-line cap could not be scrolled: the caret and
the selection moved, but the words stayed frozen at line 1, so the tail of
anything longer than the cap was unreachable while writing it.

The composer paints its text in two stacked layers. The textarea owns the
value, the selection and the caret but renders its own glyphs transparent;
every visible character is painted by the decoration backdrop beneath it,
which also carries the claim-token highlight, the chips and the ghost hint.
The backdrop is `inset: 0; overflow: hidden` — clipped, not scrolled — and
nothing linked its offset to the textarea's. Below the cap both layers rest
at 0, which is why the defect hid behind every short-draft screenshot and
fixture.

InputBar now mirrors the textarea's scrollTop onto the backdrop, from a
`scroll` listener (every gesture and every caret-driven scroll) and from a
layout effect keyed on the committed draft (an edit reflows both layers
without necessarily firing a scroll event).

Scrolling is layout, so jsdom cannot show this: the unit spec stubs both
offsets and proves the mirroring paths run, while a new browser scenario
measures the user-visible fact against the built client with a DOM Range
over the backdrop's own text — after a wheel gesture over a 40-line draft
the last line is on screen and the first has scrolled out. Confirmed both
directions: with the mirroring reverted and the packages rebuilt, the
golden reads `last draft line is on screen: false` while `textarea moved:
true`.
This commit is contained in:
creatixchu
2026-07-31 11:53:04 +08:00
parent f6443601c4
commit a7b7066267
9 changed files with 461 additions and 9 deletions

View File

@@ -6,7 +6,7 @@
* region-slot content) ride the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession. */
import { useEffect, useRef } from 'react'
import { useEffect, useLayoutEffect, useRef } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -62,6 +62,7 @@ export function InputBar({
const draft = input?.draft ?? ''
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const backdropRef = 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)
@@ -91,11 +92,19 @@ export function InputBar({
if (!locked) inputRef.current?.focus()
}, [locked, sessionId])
// Active conversation scrollport: chain the wheel. While the textarea (capped
// at 14 lines with overflow-y:auto) can still move in this direction, keep
// the native scroll; only at its own edge forward delta to the host so a
// short draft never traps the gesture and a long draft stays scrollable.
// Hero mounts have no host and keep native wheel scrolling.
// Two DOM listeners on the textarea, one lifetime (it is never unmounted —
// the inert state renders the same element disabled).
//
// wheel — active conversation scrollport: chain the gesture. While the
// textarea (capped at 14 lines with overflow-y:auto) can still move in this
// direction, keep the native scroll; only at its own edge forward delta to
// the host so a short draft never traps the gesture and a long draft stays
// scrollable. Hero mounts have no host and keep native wheel scrolling.
//
// scroll — the backdrop paints every visible glyph (the textarea's own text
// is transparent) but is clipped, not scrolled, so it does not follow the
// textarea on its own: without this mirror a draft past the cap moves the
// caret while the words stay frozen in place.
useEffect(() => {
const el = inputRef.current
if (el === null) return
@@ -108,10 +117,28 @@ export function InputBar({
e.preventDefault()
host.scrollTop += e.deltaY
}
const onScroll = (): void => {
const backdropEl = backdropRef.current
if (backdropEl !== null) backdropEl.scrollTop = el.scrollTop
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => { el.removeEventListener('wheel', onWheel) }
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('scroll', onScroll)
}
}, [])
// Draft edits reflow both layers without necessarily moving the textarea
// (no scroll event fires when the caret stays in view), and a shrinking
// draft clamps each layer independently. Re-mirror after every committed
// draft so the glyphs never lag the caret by an edit.
useLayoutEffect(() => {
const el = inputRef.current
const backdropEl = backdropRef.current
if (el !== null && backdropEl !== null) backdropEl.scrollTop = el.scrollTop
}, [draft])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
// Absent machine (no session): the textarea is disabled so events cannot
// fire; the guard narrows the faces for the paths below.
@@ -376,7 +403,7 @@ export function InputBar({
(min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting
rows by '\n' cannot see soft wraps. */}
<div className={css.grow}>
<div aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<div ref={backdropRef} aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<textarea
ref={inputRef}
className={css.input}

View File

@@ -288,6 +288,22 @@ describe('running and lock semantics (queue cut 1)', () => {
}
})
it('the decoration backdrop tracks the textarea offset (it paints every visible glyph)', () => {
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
const backdrop = view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
Object.defineProperty(backdrop, 'scrollTop', { value: 0, writable: true, configurable: true })
Object.defineProperty(textarea, 'scrollTop', { value: 0, writable: true, configurable: true })
// A scrolled draft: the textarea moves, the clipped backdrop must follow.
textarea.scrollTop = 120
fireEvent.scroll(textarea)
expect(backdrop.scrollTop).toBe(120)
// Editing re-mirrors without a scroll event (the caret can stay in view).
backdrop.scrollTop = 0
textarea.scrollTop = 96
fireEvent.change(textarea, { target: { value: 'line\n'.repeat(39) } })
expect(backdrop.scrollTop).toBe(96)
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
const { textarea } = bench({ disabled: true })
expect(textarea.placeholder).toBe('会话不可用')