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:
@@ -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
|
||||
|
||||
@@ -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(<InputBar {...props} sessionId={'s2' as SessionId} />) })
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user