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