From 1008ca865bf61b396505a0c6c88ff57ce1c3755d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 17:03:55 +0800 Subject: [PATCH] fix(web): reveal a caret that sits after a newline, where the engines disagree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A caret straight after a newline is on a line with nothing to measure — the shape a trailing-newline draft ends in. chromium returns no client rects at all for the collapsed position (an all-zero box, which sent the reveal upward instead), firefox reports the line above, WebKit the right one. Measure the newline the caret just left and step one line down: all three then land on 649 of 652 with the caret's line at 315 inside the 336px box. The browser case now pastes a newline-terminated block, and fails 'expected 0 to be greater than 0' without the rule. --- ...text-layers-share-one-scrollport.i18n.yaml | 4 +-- ...mposer-text-layers-share-one-scrollport.md | 2 ++ ...ser-text-layers-share-one-scrollport.zh.md | 2 ++ apps/web/tests/composer-draft-scroll.e2e.ts | 7 +++-- .../src/client/skeleton/InputBar.tsx | 25 +++++++++++----- .../ui-conversation/tests/input-bar.spec.tsx | 29 +++++++++++++++++-- 6 files changed, 56 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml index d4758aa0d2..38cf7342d2 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md -2026-07-31-composer-text-layers-share-one-scrollport.md: 78a02560c13af1746e3cd2a4d595fd510e8b9b1b -2026-07-31-composer-text-layers-share-one-scrollport.zh.md: e3e397a4b4c4893089407891f061e3e01155fcfd +2026-07-31-composer-text-layers-share-one-scrollport.md: 8c3a8002098177b729da7953210aed0b421729f7 +2026-07-31-composer-text-layers-share-one-scrollport.zh.md: b6f9d59516e864053ad7c64cf2c7f1a806044053 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md index 78a02560c1..8c3a800209 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md @@ -32,6 +32,8 @@ Two things the previous mechanism needed are gone with it: **Edits the composer performs itself now ask for the reveal.** Paste, ctrl/meta-Enter newline and cut suppress the native edit — the machine owns the draft and the undo log — and restore the caret with `setSelectionRange`, which reveals nothing: measured in chromium and WebKit, pasting a long block leaves the view where it was while the caret sits at the end of what was pasted. That defect predates this change (Firefox happened to reveal it, in the old geometry only) and is fixed here because one scrollport is what finally makes the reveal ours to perform. The three restores share one helper that measures the caret against the hidden mirror — same draft, same metrics, same wrap width, so a Range collapsed at the caret's index reports where the caret is without a caret API — and scrolls the minimum that brings it inside, which is what the browser does for typing. +One shape needs a rule of its own, because the engines disagree about it: a caret straight after a newline sits on a line with nothing on it to measure, which is where a trailing-newline draft ends. chromium returns **no client rects at all** for the collapsed position — an all-zero box, which would send the reveal the wrong way — firefox reports the line above, and WebKit the right one. The helper therefore measures the newline the caret just left, whose box is the line it came from, and steps one line down; all three then land on the same offset (649 of 652, with the caret's line at 315 inside the 336px box). + Revealing the caret is the one thing that now depends on the browser rather than on us: with no offset of its own, the textarea's scroll-into-view has to walk up to the scrollport. It does, on every engine measured — typing at the draft's end brings the scrollport to the caret (625, 626 and 628 of a 628px maximum in chromium, firefox and WebKit), walking the caret back up with `ArrowUp` scrolls back to it, and typing after scrolling away returns to it. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md index e3e397a4b4..b6f9d59516 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md @@ -32,6 +32,8 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl **由 composer 自己完成的编辑,现在会主动请求回视。** 粘贴、ctrl/meta-Enter 换行与剪切都会抑制原生编辑——草稿与撤销日志归状态机所有——再用 `setSelectionRange` 恢复光标,而这不会带来任何回视:在 chromium 与 WebKit 上实测,粘贴一大段之后视图停在原处,光标却落在所粘内容的末尾。该缺陷早于本次改动(Firefox 只在旧几何下恰好会回视),在此修复,是因为单一滚动容器才终于让「回视」成为我们能自己做的事。三处恢复共用一个 helper:它以隐藏的镜像层为标尺——同一份草稿、同一套度量、同一折行宽度,因此在光标索引处折叠一个 Range 就能报出光标位置,无需任何 caret API——并且只滚动到刚好把该行带进可见范围为止,与浏览器为输入所做的一致。 +有一种形状需要单独的规则,因为引擎之间在这里并不一致:紧跟在换行之后的光标,落在一条没有任何内容可供度量的行上——以换行结尾的草稿正是终止于此。chromium 对这个折叠位置**根本不返回任何 client rect**(一个全零盒子,会把回视带向反方向),firefox 报的是上一行,WebKit 报的才是对的那一行。因此该 helper 改为度量光标刚离开的那个换行——它的盒子就是光标来的那一行——再往下走一行;三者随即落在同一个偏移上(649/652,光标所在行位于 336px 盒内的 315)。 + 现在唯一依赖浏览器而非依赖我们自己的,是把光标滚入可见范围:textarea 没有了自己的偏移,它的 scroll-into-view 必须向上走到滚动容器。实测的每个引擎都会这么做——在草稿末尾输入会把滚动容器带到光标处(chromium、firefox、WebKit 分别为 625、626、628,最大值 628),用 `ArrowUp` 把光标一路走回去会滚回去,滚离光标后再输入也会回到光标。 ## 备选方案 diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index 3dd341e47e..0a8b20e214 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/apps/web/tests/composer-draft-scroll.e2e.ts @@ -380,7 +380,10 @@ describe('web e2e: composer draft scrolling', () => { const data = new DataTransfer() data.setData('text/plain', text) el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) - }, `\n${DRAFT}`) + // Ending in a newline is the shape the engines disagree on: the caret + // lands on a line with nothing on it, where chromium reports no client + // rects at all for the collapsed position. + }, `\n${DRAFT}\n`) await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true) // The restore lands one frame after the machine commits the draft, so the // box overflows before it moves; waiting on the offset is waiting for the @@ -449,7 +452,7 @@ describe('web e2e: composer draft scrolling', () => { const data = new DataTransfer() data.setData('text/plain', text) el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true })) - }, `\n${DRAFT}`) + }, `\n${DRAFT}\n`) await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true) await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0) const pasted = await measureComposer(page) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 03d329a1db..bd1dedc480 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -98,18 +98,29 @@ export function InputBar({ // where the caret is without a caret API. const revealCaret = (caret: number): void => { const scrollEl = scrollRef.current - const text = mirrorRef.current?.firstChild - if (scrollEl === null || !(text instanceof Text)) return + const mirrorEl = mirrorRef.current + const text = mirrorEl?.firstChild + if (scrollEl === null || mirrorEl === null || !(text instanceof Text)) return // A box that cannot scroll has nothing to reveal: the draft fits, so every // caret is already in view and the assignment below would clamp to itself. if (scrollEl.scrollHeight <= scrollEl.clientHeight) return + const at = Math.min(caret, text.data.length) + // A caret straight after a newline sits on a line with nothing on it to + // measure — the shape a trailing-newline draft ends in — and the engines + // disagree there: chromium returns NO client rects at all (an all-zero box, + // which would scroll the wrong way), firefox reports the line above, WebKit + // the right one. Measure the newline itself instead, which is the line the + // caret just left, and step one line down; that they all agree on. + const afterNewline = at > 0 && text.data[at - 1] === '\n' const range = document.createRange() - range.setStart(text, Math.min(caret, text.data.length)) - range.collapse(true) - const at = range.getBoundingClientRect() + range.setStart(text, afterNewline ? at - 1 : at) + if (afterNewline) range.setEnd(text, at) + else range.collapse(true) + const line = afterNewline ? Number.parseFloat(getComputedStyle(mirrorEl).lineHeight) : 0 + const rect = range.getBoundingClientRect() const box = scrollEl.getBoundingClientRect() - if (at.bottom > box.bottom) scrollEl.scrollTop += at.bottom - box.bottom - else if (at.top < box.top) scrollEl.scrollTop -= box.top - at.top + if (rect.bottom + line > box.bottom) scrollEl.scrollTop += rect.bottom + line - box.bottom + else if (rect.top + line < box.top) scrollEl.scrollTop -= box.top - rect.top - line } // Unlock (mount / session switch) returns focus to the box, and owns the diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 551c50c394..296f10fbfc 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -371,6 +371,17 @@ describe('running and lock semantics (queue cut 1)', () => { fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } }) await settle() expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60) + // A caret straight after a newline has nothing on its line to measure, so + // the newline it just left is measured instead and one line is added. + // chromium reports no client rects at all for the collapsed position. + mirror.style.lineHeight = '24px' + caretAt(500) + fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } }) + await settle() + // The three pastes accumulate at the draft's head, so the caret is at the + // end of what they inserted — and the measured index is the newline before it. + expect(measured!.offset).toBe('pastedmoreagainblock\n'.length - 1) + expect(scroll.scrollTop).toBe(48 + 112) // from 48, by (524 + 24) - 436 }) it('a session switch refocuses without moving the transcript, and reveals the new draft caret', () => { @@ -387,13 +398,27 @@ describe('running and lock semantics (queue cut 1)', () => { Object.defineProperty(scroll, 'scrollHeight', { value: 964, configurable: true }) Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true, configurable: true }) Range.prototype.getBoundingClientRect = () => ({ top: 500, bottom: 524 }) as DOMRect + // The draft ends in a newline, so the reveal takes the after-newline path + // and needs a resolvable line-height (jsdom computes `normal`). + mirror.style.lineHeight = '24px' + // Which index the effect reveals at, not merely that it scrolled: a + // revealCaret(0) would land the same offset without this. + onTestFinished(() => { Range.prototype.setStart = NATIVE_SET_START }) + let measured: { node: Node; offset: number } | null = null + Range.prototype.setStart = function setStart(node: Node, offset: number): void { + measured = { node, offset } + NATIVE_SET_START.call(this, node, offset) + } const focused: (boolean | undefined)[] = [] textarea.focus = (options?: FocusOptions) => { focused.push(options?.preventScroll) } textarea.setSelectionRange(textarea.value.length, textarea.value.length) act(() => { view.rerender() }) expect(focused).toEqual([true]) - expect(scroll.scrollTop).toBe(88) // 524 - 436 - expect(mirror.firstChild).toBeInstanceOf(Text) + expect(scroll.scrollTop).toBe(112) // (524 + 24) - 436 + // The draft ends in a newline, so the rule measures that newline: the + // caret's own index is the mirror text's length minus its sentinel. + expect(measured!.node).toBe(mirror.firstChild) + expect(measured!.offset).toBe(textarea.value.length - 1) }) it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {