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; }