fix(web): reveal a caret that sits after a newline, where the engines disagree

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.
This commit is contained in:
creatixchu
2026-07-31 17:03:55 +08:00
parent 4aa0c51653
commit 1008ca865b
6 changed files with 56 additions and 13 deletions
@@ -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