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:
creatixchu
2026-07-31 15:25:29 +08:00
parent ef01e2e9af
commit 49f8cdd401
11 changed files with 412 additions and 430 deletions

View File

@@ -77,12 +77,14 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
// The composer's own text cap, measured on the live textarea before the
// takeover replaces it. The panel's scroll region must stop at the same
// height (the designer's requirement: one cap for the composer seat), and
// measuring it here keeps the assertion free of the px value itself.
// The composer's own text cap, measured on the live draft scrollport before
// the takeover replaces it — the box that carries the cap, while the
// textarea inside it is as tall as the whole draft. The panel's scroll
// region must stop at the same height (the designer's requirement: one cap
// for the composer seat), and measuring it here keeps the assertion free of
// the px value itself.
await input.fill(CAP_PROBE)
const composerCap = await input.evaluate(el => el.clientHeight)
const composerCap = await input.evaluate(el => el.closest('[data-input-scroll]')?.clientHeight ?? 0)
expect(composerCap).toBeGreaterThan(0)
await input.fill('')