fix(web): bind the composer's caret to its glyphs with one scrollport
The composer paints its draft in two layers — the textarea owns the value, the selection and the caret, the backdrop paints every visible glyph — and they had one scroll offset each, kept equal by a `scroll` listener. That holds at rest and not in motion: a wheel gesture scrolls the textarea on the compositor, the listener runs afterwards, and for those frames the caret sits at the new offset with the words at the old one. Measured on a harness of the same geometry, a 200px offset change separates caret from glyphs by ~200px (chromium 203, firefox 202, WebKit 203) until a later frame — the caret flying out of its own text when a user swipes a long draft quickly. Both layers now ride one scrollport: `[data-input-scroll]` carries the 14-line cap, the auto-grow stack inside it is as tall as the whole draft, and the textarea holds no scrollable overflow of its own. The browser applies one offset to both layers in the same frame, so the coupling is structural rather than maintained. The backdrop's trailing-line sentinel and the cross-engine wrap-width premise go with the mirror: the layers now share a containing block, which closes the WebKit 768-against-776 gap by construction.
This commit is contained in:
@@ -88,11 +88,11 @@
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
/* Elevated surface in dark, same as the menus: the textarea inside scrolls
|
||||
once the composer hits its height cap, so the thumb takes the l2 pair.
|
||||
Declared on the card because the elevation belongs to the surface, and the
|
||||
custom properties inherit down to the textarea that actually scrolls (see
|
||||
ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
/* Elevated surface in dark, same as the menus: the draft scrollport inside
|
||||
scrolls once the composer hits its height cap, so the thumb takes the l2
|
||||
pair. Declared on the card because the elevation belongs to the surface,
|
||||
and the custom properties inherit down to the box that actually scrolls
|
||||
(see ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
@@ -112,8 +112,21 @@
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Mirror-div auto-grow wrapper: the hidden mirror is in normal flow and sets the height
|
||||
(min 2 lines / max 14 lines); the textarea rides it absolutely. Mirror and textarea
|
||||
/* The draft's scrollport, and the ONLY scrolling box in the composer: the
|
||||
caret is the textarea's and every visible glyph is the backdrop's, so the two
|
||||
layers stay together only by riding one offset the browser applies to both at
|
||||
once. Scrolling one box and assigning the offset to the other cannot hold —
|
||||
a wheel gesture is composited off the main thread, so the assignment lands
|
||||
frames late and the words visibly trail the caret. The 14-line cap lives here
|
||||
because this is the box the cap describes. */
|
||||
.scroll {
|
||||
max-height: var(--dsh-composer-text-max-height);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Mirror-div auto-grow stack: the hidden mirror is in normal flow and sets the FULL draft
|
||||
height (min 2 lines in hero); backdrop and textarea ride it absolutely, so both layers are
|
||||
as tall as the draft and the scrollport above shows a window onto them. Mirror and textarea
|
||||
MUST share font, line-height, padding and wrapping rules or heights diverge. */
|
||||
.grow {
|
||||
position: relative;
|
||||
@@ -165,7 +178,11 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
/* Never a scroller of its own: it is as tall as the draft, so it has no
|
||||
scrollable overflow to hold an offset that could differ from the glyphs'.
|
||||
The browser still reveals the caret — the scroll-into-view walks up to
|
||||
.scroll and moves both layers together. */
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
@@ -193,18 +210,16 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
/* These three MUST wrap at one width, because InputBar mirrors a single
|
||||
scroll offset between .input and .backdrop and a layer that wraps onto
|
||||
more lines is taller, has a larger scroll maximum, and clamps the mirrored
|
||||
offset below the caret. Only .input scrolls, so only .input can lose
|
||||
content width to a scrollbar that consumes layout space.
|
||||
`scrollbar-gutter: stable` here does NOT buy that guarantee and was
|
||||
removed after measuring: WebKit applies it to overflow-y:auto but not to
|
||||
the overflow:hidden layers, so it left .input at 768 against 776 — the
|
||||
same gap it was meant to close — while costing chromium 8px of text width
|
||||
unconditionally. The gap it would have closed is measured and recorded in
|
||||
the Agent Note (2026-07-31-composer-glyph-layer-tracks-the-textarea);
|
||||
closing it needs one geometry every engine agrees on, not this property. */
|
||||
/* These three MUST wrap at one width: the mirror decides the box height
|
||||
the other two are laid out in, and a glyph layer that breaks lines
|
||||
elsewhere than the textarea puts the words under the wrong caret. They do
|
||||
so by construction now that all three sit INSIDE .scroll — a scrollbar
|
||||
that consumes layout space narrows the scrollport, which is their shared
|
||||
containing block, so it costs all three the same width on every engine.
|
||||
Scrolling the textarea itself is what used to break this, and no property
|
||||
fixed it: WebKit reserved gutter space for the overflow-y:auto textarea
|
||||
and not for the overflow:hidden layers beside it, leaving them 8px apart
|
||||
(768 against 776) — worth 2 to 5 wrapped lines on a long draft. */
|
||||
}
|
||||
|
||||
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */
|
||||
@@ -222,10 +237,6 @@
|
||||
.mirror {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
/* 14-line cap, shared with the composer takeovers (declared on
|
||||
ConversationRoot .composerSeat). */
|
||||
max-height: var(--dsh-composer-text-max-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
|
||||
|
||||
@@ -62,7 +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)
|
||||
const scrollRef = 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)
|
||||
@@ -92,24 +92,14 @@ export function InputBar({
|
||||
if (!locked) inputRef.current?.focus()
|
||||
}, [locked, sessionId])
|
||||
|
||||
// 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. Every way the box moves ends
|
||||
// in a `scroll` event, edits included (the caret is scrolled into view), and
|
||||
// the layers share an extent, so a draft that shrinks past the offset clamps
|
||||
// both to the same maximum — one listener covers the coupling.
|
||||
// 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
|
||||
// at its own edge forward the delta to the active conversation scrollport, so
|
||||
// a short draft never traps the gesture and a long draft stays scrollable.
|
||||
// Hero mounts have no host and keep native wheel scrolling.
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
const el = scrollRef.current
|
||||
if (el === null) return
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
const host = el.closest('[data-conversation-scroll]')
|
||||
@@ -120,16 +110,8 @@ 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 })
|
||||
el.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
return () => { el.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
@@ -375,22 +357,6 @@ export function InputBar({
|
||||
const displayHint = translated !== hintKey ? translated : deco.hint
|
||||
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{displayHint}</span>)
|
||||
}
|
||||
// Trailing-line sentinel, the same one the mirror div carries and for the
|
||||
// same reason: a textarea reserves a line box for the caret after a final
|
||||
// newline, while `white-space: pre-wrap` collapses a text node's trailing
|
||||
// newline and generates none. Without it a draft ending in a newline makes
|
||||
// the backdrop exactly one line SHORTER than the textarea, so mirroring the
|
||||
// offset at the very bottom clamps and the glyphs sit a line behind the
|
||||
// caret. The extra newline is absorbed by that same collapse when the draft
|
||||
// does not end in one, so it costs no height in the ordinary case.
|
||||
//
|
||||
// The mirror only fails one way — a backdrop SHORTER than the textarea
|
||||
// clamps the assignment, while a taller one takes every offset exactly and
|
||||
// hides the surplus below the clip. That is why the ghost hint needs no
|
||||
// handling of its own: it can only add content after the draft and before
|
||||
// this sentinel, never remove a line box, so it moves the pair to equal or
|
||||
// to the safe side.
|
||||
backdrop.push('\n')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -408,32 +374,38 @@ export function InputBar({
|
||||
<div className={css.card} data-composer-card>
|
||||
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
|
||||
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
|
||||
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
|
||||
(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 ref={backdropRef} aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={css.input}
|
||||
value={draft}
|
||||
disabled={locked}
|
||||
readOnly={machineBusy}
|
||||
data-phase={input?.phase ?? 'inert'}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? t('placeholder.unavailable')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
rows={2}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelect={onSelect}
|
||||
onCopy={(e) => { onCopyOrCut(e, false) }}
|
||||
onCut={(e) => { onCopyOrCut(e, true) }}
|
||||
onPaste={onPaste}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
/>
|
||||
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
|
||||
{/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the
|
||||
stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the
|
||||
absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14
|
||||
lines in CSS — is the only thing that scrolls. The caret belongs to the textarea and the
|
||||
glyphs to the backdrop, so they can only stay together by moving together: one scroll
|
||||
offset the browser applies to both layers at once, never a JS mirror between two boxes,
|
||||
which a compositor-driven gesture outruns and leaves the words trailing the caret. */}
|
||||
<div ref={scrollRef} className={css.scroll} data-input-scroll>
|
||||
<div className={css.grow}>
|
||||
<div aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={css.input}
|
||||
value={draft}
|
||||
disabled={locked}
|
||||
readOnly={machineBusy}
|
||||
data-phase={input?.phase ?? 'inert'}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? t('placeholder.unavailable')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
rows={2}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelect={onSelect}
|
||||
onCopy={(e) => { onCopyOrCut(e, false) }}
|
||||
onCut={(e) => { onCopyOrCut(e, true) }}
|
||||
onPaste={onPaste}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
/>
|
||||
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<div className={css.tools}>
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
expect((textarea).value).toBe('typed')
|
||||
})
|
||||
|
||||
it('wheel over a non-overflowing textarea forwards to the conversation host', () => {
|
||||
it('wheel over a non-overflowing draft forwards to the conversation host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
@@ -255,17 +255,18 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('wheel chains: long drafts scroll inside the textarea until each edge, then the host', () => {
|
||||
it('wheel chains: long drafts scroll inside the draft scrollport until each edge, then the host', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollTop', { value: 40, writable: true, configurable: true })
|
||||
const { view, textarea } = bench()
|
||||
host.appendChild(view.container)
|
||||
document.body.appendChild(host)
|
||||
Object.defineProperty(textarea, 'clientHeight', { value: 100, configurable: true })
|
||||
Object.defineProperty(textarea, 'scrollHeight', { value: 400, configurable: true })
|
||||
const scrollport = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
Object.defineProperty(scrollport, 'clientHeight', { value: 100, configurable: true })
|
||||
Object.defineProperty(scrollport, 'scrollHeight', { value: 400, configurable: true })
|
||||
let scrollTop = 150
|
||||
Object.defineProperty(textarea, 'scrollTop', {
|
||||
Object.defineProperty(scrollport, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => { scrollTop = value },
|
||||
@@ -289,35 +290,20 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('the decoration backdrop tracks the textarea offset (it paints every visible glyph)', () => {
|
||||
it('the caret layer and the glyph layer ride one scrollport', () => {
|
||||
const { view, textarea } = bench({ draft: 'line\n'.repeat(40) })
|
||||
const scroll = view.container.querySelector<HTMLElement>('[data-input-scroll]')!
|
||||
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)
|
||||
// Every later move tracks too, including back to the top — a one-shot
|
||||
// mirror would leave the glyphs parked at the first offset it saw.
|
||||
textarea.scrollTop = 0
|
||||
fireEvent.scroll(textarea)
|
||||
expect(backdrop.scrollTop).toBe(0)
|
||||
})
|
||||
|
||||
it('the backdrop carries the trailing-line sentinel that keeps its extent equal to the textarea', () => {
|
||||
// jsdom has no layout, so the HEIGHTS this protects cannot be asserted here
|
||||
// (the browser scenario owns that); what is checkable is that the backdrop's
|
||||
// text is the draft plus exactly one newline. A textarea reserves a line box
|
||||
// after a final newline and `pre-wrap` collapses one, so without the
|
||||
// sentinel a draft ending in a newline leaves the backdrop a line short and
|
||||
// the mirrored offset clamps.
|
||||
const withNewline = bench({ draft: 'alpha\nbeta\n' })
|
||||
const backdrop = withNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
|
||||
expect(backdrop.textContent).toBe('alpha\nbeta\n\n')
|
||||
const withoutNewline = bench({ draft: 'alpha\nbeta' })
|
||||
const plain = withoutNewline.view.container.querySelector<HTMLElement>('[data-input-backdrop]')!
|
||||
expect(plain.textContent).toBe('alpha\nbeta\n')
|
||||
// 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.
|
||||
expect(scroll.contains(textarea)).toBe(true)
|
||||
expect(scroll.contains(backdrop)).toBe(true)
|
||||
// The glyph layer carries the draft and nothing else: with one scrollport
|
||||
// it no longer pads its own height to match a second box's scroll extent.
|
||||
expect(backdrop.textContent).toBe('line\n'.repeat(40))
|
||||
})
|
||||
|
||||
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
|
||||
|
||||
Reference in New Issue
Block a user