fix(web): reserve one scrollbar gutter across the composer's text layers
Second review round escalated the wrap-width divergence from a separate concern to a defect in this fix's own premise, and it is right. Only .input scrolls, so only .input loses content width to a scrollbar that consumes layout space — what Windows and Firefox draw, and what the theme's global `::-webkit-scrollbar` width makes chromium treat as occupying space. A narrower .input wraps a long soft-wrapped draft onto more lines, so it grows taller, its scroll maximum exceeds the backdrop's, and the mirrored offset clamps below the caret. That is the same failure the trailing-line sentinel fixes, in the same direction, so deferring it would have shipped a fix that does not hold where users run a classic scrollbar. My first attempt to reproduce it found nothing and was wrong: the probe content was not wrap-sensitive. With varied-length words the effect is plain — the same draft laid out at 8px-apart widths differs by 2 to 5 lines, while at equal widths a textarea and a div agree exactly. The three layers now reserve the gutter together, in the shared metrics block that already exists to keep them symmetric. `overflow: hidden` is still a scroll container, so the non-scrolling layers honour it: 8px is reserved on each, measured. The cost is a text column 8px narrower on every platform, which is the price of one geometry rather than a per-platform one. The browser scenario asserts the premise directly — equal wrap widths, and a reserved band greater than zero on each layer. The band is what stops the assertion being vacuous: the widths would also match with no reservation at all on this engine's overlay scrollbar, and it is the reservation, not the match, that carries the guarantee to a platform whose scrollbar takes real width. Removing the declaration fails it with `expected 0 to be greater than 0`, and fails the golden with it. Also from the same round, three comment corrections: the e2e file header no longer describes the deleted layout effect, the measurement guard no longer claims the backdrop holds exactly one text node (the sentinel makes a second), and the sentinel comment now carries the one-sidedness argument that also settles the ghost hint — the mirror only fails when the backdrop is SHORTER, and the hint can only add content, never remove a line box.
This commit is contained in:
@@ -13,7 +13,16 @@
|
||||
// So past the cap the textarea scrolled and the words did not: the caret walked
|
||||
// off the bottom of a block of text frozen at line 1, and no gesture — wheel,
|
||||
// drag, arrow key — moved it. `InputBar` now mirrors the offset onto the
|
||||
// backdrop on every textarea `scroll` and after every committed draft.
|
||||
// backdrop on every textarea `scroll`, which is the one event every way of
|
||||
// moving the box ends in.
|
||||
//
|
||||
// Mirroring an offset is only correct while both layers can reach it, so two
|
||||
// pieces of geometry are asserted here alongside the visible outcome: the
|
||||
// backdrop's trailing-line sentinel (a textarea reserves a line box for the
|
||||
// caret after a final newline; `pre-wrap` collapses one) and the scrollbar
|
||||
// gutter reserved on all three layers (only the textarea scrolls, so only it
|
||||
// would lose wrap width to a space-consuming scrollbar). Either one breaks the
|
||||
// extent equality, and an unreachable offset clamps the glyphs below the caret.
|
||||
//
|
||||
// Only a real engine can show this. Scrolling is layout: jsdom reports
|
||||
// `scrollHeight === clientHeight` for every element and never scrolls one, so
|
||||
@@ -95,6 +104,19 @@ interface ComposerMetrics {
|
||||
inputMax: number
|
||||
/** Furthest the backdrop can scroll — equal to `inputMax`, or the mirror clamps below the caret. */
|
||||
backdropMax: number
|
||||
/** Content width the textarea wraps at. */
|
||||
inputWrapWidth: number
|
||||
/** Content width the backdrop wraps at — equal, or the layers break lines in different places. */
|
||||
backdropWrapWidth: number
|
||||
/**
|
||||
* Width each layer holds back for a scrollbar. Non-zero is the proof the
|
||||
* reservation is in force: were it absent, both would read 0 here and stay
|
||||
* equal on this engine's overlay scrollbar while diverging on a platform
|
||||
* whose scrollbar consumes layout width.
|
||||
*/
|
||||
inputGutter: number
|
||||
/** The same reservation on the clipped layer, which never draws a scrollbar. */
|
||||
backdropGutter: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,10 +131,12 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
|
||||
const backdrop = input.parentElement?.querySelector<HTMLElement>('[data-input-backdrop]')
|
||||
if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea')
|
||||
const box = input.getBoundingClientRect()
|
||||
// The draft carries no chips or claim token, so the backdrop holds exactly
|
||||
// one text node — the plain-text arm of the decoration walk.
|
||||
// The draft carries no chips or claim token, so the decoration walk emits it
|
||||
// as one text node — the backdrop's first, ahead of the trailing-line
|
||||
// sentinel React renders as a second one. Both markers live in that first
|
||||
// node, which is what the Range below needs.
|
||||
const text = backdrop.firstChild
|
||||
if (!(text instanceof Text)) throw new Error('backdrop is not a single plain text node')
|
||||
if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node')
|
||||
const offsetOf = (marker: string): number => {
|
||||
const at = text.data.indexOf(marker)
|
||||
if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`)
|
||||
@@ -136,6 +160,10 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
|
||||
return {
|
||||
inputMax,
|
||||
backdropMax,
|
||||
inputWrapWidth: input.clientWidth,
|
||||
backdropWrapWidth: backdrop.clientWidth,
|
||||
inputGutter: Math.round(input.getBoundingClientRect().width - input.clientWidth),
|
||||
backdropGutter: Math.round(backdrop.getBoundingClientRect().width - backdrop.clientWidth),
|
||||
overflows: input.scrollHeight > input.clientHeight,
|
||||
clientHeight: input.clientHeight,
|
||||
visibleLines: Math.floor(input.clientHeight / lineHeight),
|
||||
@@ -170,6 +198,8 @@ function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingN
|
||||
`- draft overflows the capped box: ${String(top.overflows)}`,
|
||||
`- visible lines: ${String(top.visibleLines)}`,
|
||||
`- both layers share one scroll extent: ${String(top.inputMax === top.backdropMax)}`,
|
||||
`- both layers wrap at one width: ${String(top.inputWrapWidth === top.backdropWrapWidth)}`,
|
||||
`- scrollbar gutter reserved on each layer: ${String(top.inputGutter)}px / ${String(top.backdropGutter)}px`,
|
||||
`- textarea scroll offset: ${String(top.inputScrollTop)}px`,
|
||||
`- glyph layer tracks it: ${String(top.layersAgree)}`,
|
||||
`- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
|
||||
@@ -239,6 +269,25 @@ describe('web e2e: composer draft scrolling', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('reserves one scrollbar gutter on every text layer, so they wrap at one width', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-gutter'))
|
||||
// The premise under the mirror. Only the textarea scrolls, so only it would
|
||||
// lose width to a space-consuming scrollbar — and a narrower textarea wraps
|
||||
// a long draft onto more lines than the backdrop, ending up taller, with a
|
||||
// larger maximum, which makes the mirrored offset clamp below the caret.
|
||||
// Measured on a standalone harness: an 8px width difference moves a
|
||||
// wrap-sensitive draft by 2 to 5 lines.
|
||||
const metrics = await measureComposer(page)
|
||||
expect(metrics.inputWrapWidth).toBe(metrics.backdropWrapWidth)
|
||||
// Not a tautology on this engine: the widths would also match with no
|
||||
// reservation at all, because headless chromium draws an overlay scrollbar.
|
||||
// The reserved band is what distinguishes the two states, and it is what
|
||||
// carries the guarantee to a platform whose scrollbar takes real width.
|
||||
expect(metrics.inputGutter).toBeGreaterThan(0)
|
||||
expect(metrics.backdropGutter).toBe(metrics.inputGutter)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('a wheel gesture over a long draft moves the words, not only the caret', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
- draft overflows the capped box: true
|
||||
- visible lines: 14
|
||||
- both layers share one scroll extent: true
|
||||
- both layers wrap at one width: true
|
||||
- scrollbar gutter reserved on each layer: 8px / 8px
|
||||
- textarea scroll offset: 0px
|
||||
- glyph layer tracks it: true
|
||||
- first draft line is on screen: true
|
||||
|
||||
Reference in New Issue
Block a user