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.