mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
The settle loop still had an rAF after it -- the horizontal scroll awaited a frame before point() measured, so the highlight upgrade could detach the glyphs in that window and reproduce the same bogus clipped-or-covered error. Nothing scrolls smoothly here (no scroll-behavior: smooth in our CSS or Pierre's) and getBoundingClientRect forces sync layout, so the await bought nothing: drop it and keep everything from the settle loop to measurement synchronous. point() now re-asserts liveness and reports detachment as itself rather than as pane geometry, which is what sent three rounds of fixes chasing viewport width. contentEl() is no longer non-null-asserted -- the deref sat inside the JSON.stringify building the diagnostic, so a container swap would have replaced the numbers with a TypeError -- and the never-settled error now names the synthetic-newline endpoint case too.
146 lines
6.9 KiB
TypeScript
146 lines
6.9 KiB
TypeScript
import type { Locator } from '@playwright/test'
|
|
|
|
export async function diffTextSelectionPoints(code: Locator, text: string) {
|
|
return code.evaluate(async (code, text) => {
|
|
// Why re-queried: if the upgrade replaces the container rather than its rows, a cached
|
|
// reference walks a detached tree and never matches again.
|
|
const contentEl = () => code.querySelector('[data-content]')
|
|
const collect = () => {
|
|
const found: Text[] = []
|
|
for (const row of contentEl()?.querySelectorAll('[data-line]') ?? []) {
|
|
if (found.length > 0 && !found.at(-1)!.data.endsWith('\n')) {
|
|
// Pierre renders line breaks as separate rows rather than text nodes.
|
|
found.push(document.createTextNode('\n'))
|
|
}
|
|
const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT)
|
|
while (walker.nextNode()) {
|
|
found.push(walker.currentNode as Text)
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
let nodes = collect()
|
|
const contents = nodes.map((node) => node.data).join('')
|
|
const start = contents.indexOf(text)
|
|
if (start === -1) {
|
|
throw new Error('Selection text is absent from the rendered diff')
|
|
}
|
|
const position = (offset: number) => {
|
|
for (const node of nodes) {
|
|
if (offset < node.length) {
|
|
return { node, offset }
|
|
}
|
|
offset -= node.length
|
|
}
|
|
throw new Error('Selection endpoint is outside the rendered diff')
|
|
}
|
|
const glyph = (offset: number) => {
|
|
const point = position(offset)
|
|
const range = document.createRange()
|
|
range.setStart(point.node, point.offset)
|
|
range.setEnd(point.node, point.offset + 1)
|
|
return range
|
|
}
|
|
let first = glyph(start)
|
|
let last = glyph(start + text.length - 1)
|
|
// Why: a short viewport (CI runs smaller than a dev window) can leave the target rows below
|
|
// the fold, so the computed points hit whatever covers them instead of the diff.
|
|
const rowOf = (range: Range) =>
|
|
(range.startContainer.parentElement ?? null)?.closest('[data-line]') ?? null
|
|
for (const row of [rowOf(last), rowOf(first)]) {
|
|
// inline: 'nearest' — the default would scroll the row start under the sticky line-number column.
|
|
row?.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
|
}
|
|
// Why after the scroll waits and not before them: the highlight upgrade replaces a row's
|
|
// nodes at an arbitrary frame after first paint. Checking before these awaits passes with
|
|
// fresh nodes, and the upgrade then detaches them during the very frames above, so every
|
|
// later measurement reads an all-zero rect.
|
|
let settled = false
|
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
if (
|
|
first.getBoundingClientRect().width > 0 &&
|
|
last.getBoundingClientRect().width > 0 &&
|
|
code.contains(first.startContainer) &&
|
|
code.contains(last.startContainer)
|
|
) {
|
|
settled = true
|
|
break
|
|
}
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
|
nodes = collect()
|
|
const reindexed = nodes
|
|
.map((node) => node.data)
|
|
.join('')
|
|
.indexOf(text)
|
|
if (reindexed === -1) {
|
|
continue
|
|
}
|
|
first = glyph(reindexed)
|
|
last = glyph(reindexed + text.length - 1)
|
|
}
|
|
if (!settled) {
|
|
// Why named: otherwise this surfaces below as a clipping error reporting a zero-width
|
|
// glyph, which misattributes a detached node to pane geometry.
|
|
throw new Error(
|
|
`Selection glyphs never settled for ${JSON.stringify(text)}: either the highlight upgrade ` +
|
|
'kept replacing the rows, or the text stopped matching (row unmounted by scrolling, or ' +
|
|
'an endpoint landed on a synthetic newline node, which is never attached).'
|
|
)
|
|
}
|
|
const viewport = code.getBoundingClientRect()
|
|
const left = Math.min(first.getBoundingClientRect().left, last.getBoundingClientRect().left)
|
|
const right = Math.max(first.getBoundingClientRect().right, last.getBoundingClientRect().right)
|
|
// Why measured and not a constant: the line-number column is sticky, so centering the glyph
|
|
// in a narrow pane slides it under the gutter and every point hit-tests as a line number.
|
|
// Why a filtered max and not the first: a side-by-side pane stacks two sticky number columns,
|
|
// so the first alone leaves the glyph under the second -- but an unfiltered max picks up
|
|
// number cells scrolled far to the right and overshoots.
|
|
const gutterRight = [...code.querySelectorAll('[data-line-number-content]')].reduce(
|
|
(widest, node) => {
|
|
const box = node.getBoundingClientRect()
|
|
return box.right < viewport.left + viewport.width / 2 ? Math.max(widest, box.right) : widest
|
|
},
|
|
0
|
|
)
|
|
const inset = Math.max(24, gutterRight > 0 ? gutterRight - viewport.left + 8 : 0)
|
|
// No await after this point: nothing here scrolls smoothly (no `scroll-behavior: smooth` in
|
|
// our CSS or Pierre's), and getBoundingClientRect forces sync layout. A frame yield here would
|
|
// reopen the window for the highlight upgrade to detach the glyphs just before measuring.
|
|
code.scrollLeft += left - viewport.left - Math.max(inset, (viewport.width - (right - left)) / 2)
|
|
const point = (range: Range, end: boolean) => {
|
|
const rect = range.getBoundingClientRect()
|
|
if (rect.width === 0 || !code.contains(range.startContainer)) {
|
|
throw new Error(
|
|
'Selection glyph was detached before measurement: the highlight upgrade replaced the ' +
|
|
'row nodes. This is not a geometry problem.'
|
|
)
|
|
}
|
|
const x = end ? rect.right - 0.5 : rect.left + 0.5
|
|
const y = rect.top + rect.height / 2
|
|
const root = code.getRootNode() as ShadowRoot
|
|
const hit = root.elementFromPoint(x, y)
|
|
if (!hit || !contentEl()?.contains(hit) || document.elementFromPoint(x, y) !== root.host) {
|
|
// Why the detail: this only reproduces on displays narrower than a dev machine, so the
|
|
// numbers have to come back from the runner rather than be guessed at locally.
|
|
const pane = code.getBoundingClientRect()
|
|
throw new Error(
|
|
`Selection endpoint is clipped or covered by another diff pane: ${JSON.stringify({
|
|
point: { x: Math.round(x), y: Math.round(y), isEnd: end },
|
|
glyph: { left: Math.round(rect.left), right: Math.round(rect.right) },
|
|
pane: { left: Math.round(pane.left), width: Math.round(pane.width) },
|
|
selectionWidth: Math.round(right - left),
|
|
inset: Math.round(inset),
|
|
scrollLeft: Math.round(code.scrollLeft),
|
|
window: { width: window.innerWidth, height: window.innerHeight },
|
|
hit: hit ? hit.tagName : null,
|
|
hitInContent: hit ? (contentEl()?.contains(hit) ?? false) : false
|
|
})}`
|
|
)
|
|
}
|
|
return { x, y }
|
|
}
|
|
return { start: point(first, false), end: point(last, true) }
|
|
}, text)
|
|
}
|