From 2c0c2f861b8a73149c3171f28b392b110b856a37 Mon Sep 17 00:00:00 2001 From: Phil Ewels Date: Mon, 5 Oct 2026 12:57:38 +0200 Subject: [PATCH] annotations: open the note beside its marker when it covers only the empty space of a longer line next to it fitsBeside() tested the note against whole line rows, so a longer line above or below rejected the beside position even though the note covered none of its text. It now tests the note against the glyph boxes of each text node, minus their top and bottom quarter, which the note may overlap. Closes #9 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/e2e/annotations.test.ts | 15 ++++++++++++ .../src/client/annotations.ts | 23 +++++++++---------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/docs/e2e/annotations.test.ts b/docs/e2e/annotations.test.ts index a4de786..ba5fecb 100644 --- a/docs/e2e/annotations.test.ts +++ b/docs/e2e/annotations.test.ts @@ -181,6 +181,21 @@ test('the note opens out of its marker with room after the line, and under it ot } }); +test('the note opens out of its marker when only the empty space of a longer line next to it is under the box', async ({ + page, + isMobile, +}) => { + test.skip(isMobile, 'A phone has no room beside the line.'); + const block = example(page); + const marker = block.getByRole('button', { name: 'Annotation 1' }); + await marker.evaluate((el) => { + const above = el.closest('.ec-line')?.previousElementSibling?.querySelector('.code'); + above?.append(' # a longer line above'); + }); + const { note } = await open(block, 1); + await expect(note).toHaveClass(/scb-annotation-end/); +}); + test('with the keyboard, several notes can stay open, the badge shows focus, and Escape closes them', async ({ page, }) => { diff --git a/packages/starlight-codeblocks/src/client/annotations.ts b/packages/starlight-codeblocks/src/client/annotations.ts index 1649da2..06c93fc 100644 --- a/packages/starlight-codeblocks/src/client/annotations.ts +++ b/packages/starlight-codeblocks/src/client/annotations.ts @@ -5,9 +5,11 @@ const END = 'scb-annotation-end'; const WAIT = 'scb-annotation-wait'; const stops = new WeakMap void>(); -/** The right edge of a line's code text, without its markers and popovers. */ -function textEnd(line: Element) { - let right = Number.NEGATIVE_INFINITY; +const intersects = (a: DOMRect, b: DOMRect) => + a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; + +/** True when the box covers a glyph of the line's code text, not counting its markers and popovers. */ +function coversText(line: Element, box: DOMRect) { const range = document.createRange(); const walker = document.createTreeWalker(line, NodeFilter.SHOW_TEXT); for (let node = walker.nextNode(); node; node = walker.nextNode()) { @@ -15,14 +17,14 @@ function textEnd(line: Element) { if (!end || node.parentElement?.closest('.scb-annotation, .scb-float')) continue; range.setStart(node, 0); range.setEnd(node, end); - right = Math.max(right, range.getBoundingClientRect().right); + for (const r of range.getClientRects()) { + // A glyph box reaches past the ink of most glyphs, so the note may cover its top and bottom quarter. + if (intersects(new DOMRect(r.x, r.y + r.height / 4, r.width, r.height / 2), box)) return true; + } } - return right; + return false; } -const intersects = (a: DOMRect, b: DOMRect) => - a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; - /** True when the box, beside the marker, stays in the visible block and the viewport and covers no code or other marker. */ function fitsBeside(popover: HTMLElement, pre: HTMLElement) { const box = popover.getBoundingClientRect(); @@ -36,10 +38,7 @@ function fitsBeside(popover: HTMLElement, pre: HTMLElement) { ) { return false; } - for (const line of pre.querySelectorAll('.ec-line')) { - const r = line.getBoundingClientRect(); - if (r.bottom > box.top && r.top < box.bottom && textEnd(line) > box.left) return false; - } + for (const line of pre.querySelectorAll('.ec-line')) if (coversText(line, box)) return false; for (const marker of pre.querySelectorAll('button.scb-annotation')) { if (marker !== popover.previousElementSibling && intersects(marker.getBoundingClientRect(), box)) return false; }