From e2ef65d8cd7130e1111c948cb86636ca0f030ea7 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 11 Sep 2026 15:40:04 +0200 Subject: [PATCH] fix: the whole margin band opens the header/footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A double-click only reached the zone box (~28px of a 76px band), so the part nearest the page edge did nothing; a hit rect now covers the band edge to edge. The trailing-break discount no longer applies to an empty zone, where that break is the caret line itself — the placeholder sat a line below the typed text and jumped up on the first keystroke. --- src/lib/components/HeaderFooterLayer.svelte | 30 +++++++++++++++++++-- tests/dom/run.mjs | 17 ++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index 44ea670..042fd0b 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -155,6 +155,19 @@ if (zone === 'footer') return { ...b, top: b.top + b.height - height, height }; return { ...b, height }; } + // Double-click target: the whole margin band the zone sits in, edge to edge, not just + // the zone box — the band above the body is the header's, as it is in LibreOffice. + function hitBox(zone: HfZone, page: number) { + const box = boxOf(page); + const z = zoneBox(zone, page); + const m = marginsOf(page); + if (zone === 'header') { + const bottom = Math.max(box.top + cmToPx(m.top), z.top + z.height); + return { top: box.top, left: box.left, width: box.width, height: bottom - box.top }; + } + const top = Math.min(box.top + box.height - cmToPx(m.bottom), z.top); + return { top, left: box.left, width: box.width, height: box.top + box.height - top }; + } const boxStyle = (b: { top: number; left: number; width: number; height: number }) => `top: ${b.top}px; left: ${b.left}px; width: ${b.width}px; height: ${b.height}px;`; @@ -431,7 +444,9 @@ // A rendered trailing break adds one caret line past the real content; measure that // line height (the CSS var below shifts the editor down so it overflows the anchor). const p = tt?.querySelector('p') as HTMLElement | null; - const tb = p?.querySelector(':scope > br.ProseMirror-trailingBreak') as HTMLElement | null; + // Only past real content: in an empty zone that break is the caret line itself, and + // discounting it would drop the placeholder a line below the anchored edge. + const tb = p && p.childNodes.length > 1 ? (p.querySelector(':scope > br.ProseMirror-trailingBreak') as HTMLElement | null) : null; const lineH = p ? parseFloat(getComputedStyle(p).lineHeight) : 0; activeTrailingPx = tb && getComputedStyle(tb).display !== 'none' && Number.isFinite(lineH) ? lineH : 0; }); @@ -473,6 +488,9 @@
{#each pages as p} {#each ['header', 'footer'] as const as zone} + {#if interactive} +
startEdit(zone, p)} role="button" tabindex="-1">
+ {/if} {#if !(interactive && hfActive === zone && editingPage === p)} {@const html = zoneHtml(zone, p)} {@const total = html.includes('data-page-field="count"') ? numPages : 0} @@ -584,6 +602,13 @@ justify-content: flex-end; } + /* The margin band around a zone, double-clickable like the zone itself. Painted + before it, so the zone keeps the events inside its own box. */ + .hf-hit { + position: absolute; + pointer-events: auto; + } + /* Empty zone: invisible until hovered, then show a faint double-click hint. */ .hf-empty::before { content: attr(data-hf-label); @@ -596,7 +621,8 @@ .hf-footer.hf-empty::before { margin-top: auto; } - .hf-empty:hover::before { + .hf-empty:hover::before, + .hf-hit:hover + .hf-zone.hf-empty::before { opacity: 0.6; } diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs index 4decb12..46ae26b 100644 --- a/tests/dom/run.mjs +++ b/tests/dom/run.mjs @@ -155,6 +155,23 @@ try { const isDocx = saved[0] === 0x50 && saved[1] === 0x4b && saved.includes('word/document.xml'); check(isDocx, `Save As (.docx) without a picker downloads a DOCX (${download.suggestedFilename()}, ${saved.length} bytes)`); + // The whole margin band is the zone's double-click target, and an empty zone's + // placeholder sits where the first typed character lands — not a line below it. + await page.keyboard.press('Escape'); + const hit = page.locator('.hf-hit').nth(1); + const hitH = (await hit.boundingBox()).height; + await hit.dblclick({ position: { x: 8, y: hitH - 4 } }); + await page.waitForSelector('.hf-active.hf-footer .tiptap', { timeout: 5000 }); + check(true, 'a double-click in the bottom-left page corner opens the footer'); + const lineTop = () => page.evaluate(() => document.querySelector('.hf-active .tiptap p').getBoundingClientRect().top); + const emptyTop = await lineTop(); + await page.keyboard.type('x'); + const typedTop = await lineTop(); + check(Math.abs(emptyTop - typedTop) < 1, + `the footer placeholder sits on the typed line (${emptyTop.toFixed(1)} → ${typedTop.toFixed(1)})`); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Escape'); + // The header/footer switches live in the ribbon's Insert tab. A ticked "different // first page" makes page 1 a second zone, so what is typed there lands beside the // running one — and the distance travels to the app's own storage.