Files
edentext/src/lib/components/HeaderFooterLayer.svelte
T

476 lines
18 KiB
Svelte

<script lang="ts">
import { Editor, generateHTML, type Content } from '@tiptap/core';
import { hfExtensions } from '../editor/extensions/headerFooter';
import { hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfVariant, type HfDistances } from '../storage/headerFooter';
import { cmToPx, PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import { type Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { t } from '../i18n/i18n.svelte';
let {
headerDoc = $bindable(),
footerDoc = $bindable(),
headerFirstDoc = $bindable(),
footerFirstDoc = $bindable(),
differentFirstPage = false,
headerEvenDoc = $bindable(),
footerEvenDoc = $bindable(),
differentOddEven = false,
numPages,
currentPage,
pageMargins,
orientation,
pageFormat = 'A4',
hfDistances = DEFAULT_HF_DISTANCES,
hfEditor = $bindable(),
hfActive = $bindable(),
hfTick = $bindable(),
}: {
headerDoc: HfDoc;
footerDoc: HfDoc;
headerFirstDoc: HfDoc;
footerFirstDoc: HfDoc;
differentFirstPage?: boolean;
headerEvenDoc: HfDoc;
footerEvenDoc: HfDoc;
differentOddEven?: boolean;
numPages: number;
currentPage: number;
pageMargins: PageMargins;
orientation: Orientation;
pageFormat?: PageFormat;
hfDistances?: HfDistances;
hfEditor: Editor | null;
hfActive: HfZone | null;
hfTick: number;
} = $props();
const PAGE_GAP = 20;
// Minimum zone height (~one 12pt line), so a thin margin band (footer distance ≥
// bottom margin, as some Word docs have) still renders instead of collapsing to 0.
const MIN_ZONE_PX = 20;
// Schema for static (read-only) rendering of the inactive zones.
const renderExts = hfExtensions();
// All geometry is in unscaled document px — the layer lives inside the scaled
// .paper, so the zoom transform applies to it identically to the page background.
let pageWidthPx = $derived(pageDimsCm(pageFormat, orientation).w * PX_PER_CM);
let pageHeightPx = $derived(pageDimsCm(pageFormat, orientation).h * PX_PER_CM);
let cycle = $derived(pageHeightPx + PAGE_GAP);
let mTop = $derived(cmToPx(pageMargins.top));
let mBottom = $derived(cmToPx(pageMargins.bottom));
let mLeft = $derived(cmToPx(pageMargins.left));
let mRight = $derived(cmToPx(pageMargins.right));
let contentWidth = $derived(Math.max(0, pageWidthPx - mLeft - mRight));
// Edge→zone distance in px. The footer may sit farther from the edge than the body
// bottom margin (Word's w:footer > w:bottom); the zone then grows up into the margin.
let headerDistPx = $derived(Math.min(cmToPx(hfDistances.header), pageHeightPx));
let footerDistPx = $derived(Math.min(cmToPx(hfDistances.footer), pageHeightPx));
let pages = $derived(Array.from({ length: Math.max(1, numPages) }, (_, i) => i + 1));
function zoneBox(zone: HfZone, page: number) {
const left = mLeft;
const width = contentWidth;
if (zone === 'header') {
const top = (page - 1) * cycle + headerDistPx;
return { top, left, width, height: Math.max(MIN_ZONE_PX, mTop - headerDistPx) };
}
// Footer anchors its bottom edge at footerDistPx from the page bottom and grows up.
const height = Math.max(MIN_ZONE_PX, mBottom - footerDistPx);
const top = (page - 1) * cycle + pageHeightPx - footerDistPx - height;
return { top, left, width, height };
}
// The active (edited) zone grows to fit its content, keeping the anchored edge fixed
// (footer bottom at footerDistPx, header top at headerDistPx) so the Word-style
// boundary line sits exactly at the content's edge toward the body.
function activeZoneBox(zone: HfZone, page: number) {
const b = zoneBox(zone, page);
// A visible ProseMirror trailing break (a caret line past the real content) is
// discounted: the frame tracks the real content and the break overflows past the
// anchored edge, so entering edit never shifts the text.
const contentPx = Math.max(0, activeContentPx - activeTrailingPx);
const height = Math.max(b.height, contentPx);
if (zone === 'footer') return { ...b, top: b.top + b.height - height, height };
return { ...b, height };
}
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;`;
function staticHtml(doc: HfDoc): string {
if (hfIsEmpty(doc)) return '';
try {
const html = generateHTML(doc as Parameters<typeof generateHTML>[0], renderExts);
// A paragraph ending in a hardBreak loses its last blank line: a bare trailing <br>
// collapses, so the live editor adds a ProseMirror trailing break but generateHTML
// doesn't. Append one (past any mark close-tags) so static matches editing.
return html.replace(/(<br\s*\/?>)((?:<\/[a-zA-Z]+>)*<\/p>)/g, '$1<br>$2');
} catch {
return '';
}
}
let headerHtml = $derived(staticHtml(headerDoc));
let footerHtml = $derived(staticHtml(footerDoc));
let headerFirstHtml = $derived(staticHtml(headerFirstDoc));
let footerFirstHtml = $derived(staticHtml(footerFirstDoc));
let headerEvenHtml = $derived(staticHtml(headerEvenDoc));
let footerEvenHtml = $derived(staticHtml(footerEvenDoc));
// Which variant a page shows (Word precedence): page 1 → first (if on), other even
// pages → even (if on), everything else → default/odd. Each variant, when on, always
// shows its own (possibly empty) zone, never the default.
function variantFor(page: number): HfVariant {
if (differentFirstPage && page === 1) return 'first';
if (differentOddEven && page % 2 === 0) return 'even';
return 'default';
}
function zoneHtml(zone: HfZone, page: number): string {
const v = variantFor(page);
if (zone === 'header') return v === 'first' ? headerFirstHtml : v === 'even' ? headerEvenHtml : headerHtml;
return v === 'first' ? footerFirstHtml : v === 'even' ? footerEvenHtml : footerHtml;
}
// Replace the placeholder text in every page-field span with the real value:
// current page number, or the total page count. Re-runs when its param changes.
function patchFields(node: HTMLElement, params: [number, number, string]) {
const apply = ([page, total]: [number, number, string]) => {
for (const el of Array.from(node.querySelectorAll('[data-page-field]'))) {
el.textContent = String(el.getAttribute('data-page-field') === 'count' ? total : page);
}
};
apply(params);
return { update: apply };
}
// --- live editing of one zone ---
let liveMount = $state<HTMLDivElement | null>(null);
let activeContentPx = $state(0);
let activeTrailingPx = $state(0);
let editingPage = $state(1);
// Set by a double-click (that page); null when editing is triggered externally
// (Layout-panel buttons), where the current page is used instead.
let pendingPage: number | null = null;
let liveZone: HfZone | null = null;
// The doc for a zone + which variant is being edited.
function zoneDoc(zone: HfZone, variant: HfVariant): HfDoc {
if (variant === 'first') return zone === 'header' ? headerFirstDoc : footerFirstDoc;
if (variant === 'even') return zone === 'header' ? headerEvenDoc : footerEvenDoc;
return zone === 'header' ? headerDoc : footerDoc;
}
function emptyDoc(): HfDoc {
return { type: 'doc', content: [{ type: 'paragraph' }] };
}
function startEdit(zone: HfZone, page: number) {
pendingPage = page;
hfActive = zone; // the $effect below mounts the live editor
}
function destroyLive() {
hfEditor?.destroy();
hfEditor = null;
liveZone = null;
}
// Mount / swap / unmount the single live editor as hfActive changes. Driven from
// double-click (sets editingPage) or the Layout-panel buttons (use currentPage).
$effect(() => {
const zone = hfActive;
const mount = liveMount; // read unconditionally so it's always a tracked dep
if (!zone) {
if (hfEditor) destroyLive();
return;
}
if (!mount) return;
if (hfEditor && liveZone === zone) return; // already editing this zone
if (hfEditor) destroyLive();
editingPage = pendingPage ?? currentPage;
pendingPage = null;
liveZone = zone;
// Which variant this edit session targets — fixed for its lifetime (the edited page's
// variant; App ends the edit when a flag toggles).
const editingVariant = variantFor(editingPage);
const ed = new Editor({
element: mount,
extensions: hfExtensions(zone === 'header' ? t().hf.headerPlaceholder : t().hf.footerPlaceholder),
content: (zoneDoc(zone, editingVariant) ?? emptyDoc()) as Content,
// No autofocus: its scrollIntoView nudges the page so the just-clicked zone
// appears to jump. Focus the zone explicitly without scrolling instead.
onTransaction: () => {
hfTick++;
},
onUpdate: ({ editor }) => {
const json = editor.getJSON() as HfDoc;
if (editingVariant === 'first') {
if (zone === 'header') headerFirstDoc = json;
else footerFirstDoc = json;
} else if (editingVariant === 'even') {
if (zone === 'header') headerEvenDoc = json;
else footerEvenDoc = json;
} else if (zone === 'header') headerDoc = json;
else footerDoc = json;
},
editorProps: {
handleKeyDown: (_view, event) => {
if (event.key === 'Escape') {
hfActive = null;
return true;
}
return false;
},
},
});
ed.commands.focus('end', { scrollIntoView: false });
hfEditor = ed;
});
// Keep the live editor's own page-field spans showing the edited page / total.
$effect(() => {
void hfTick;
void numPages;
void editingPage;
if (!liveMount) return;
for (const el of Array.from(liveMount.querySelectorAll('[data-page-field]'))) {
el.textContent = String(el.getAttribute('data-page-field') === 'count' ? numPages : editingPage);
}
// Content height (unscaled by the zoom transform) drives the active zone's frame.
const tt = liveMount.querySelector('.tiptap') as HTMLElement | null;
activeContentPx = tt ? tt.offsetHeight : 0;
// 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;
const lineH = p ? parseFloat(getComputedStyle(p).lineHeight) : 0;
activeTrailingPx = tb && getComputedStyle(tb).display !== 'none' && Number.isFinite(lineH) ? lineH : 0;
});
function insertField(kind: 'pageNumber' | 'pageCount') {
hfEditor?.chain().focus().insertContent({ type: kind }).run();
}
</script>
<div class="hf-layer">
{#each pages as p}
{#each ['header', 'footer'] as const as zone}
{#if !(hfActive === zone && editingPage === p)}
{@const html = zoneHtml(zone, p)}
<div
class="hf-zone hf-{zone}"
class:hf-empty={!html}
data-hf-label={zone === 'header' ? t().hf.addHeaderHint : t().hf.addFooterHint}
style={boxStyle(zoneBox(zone, p))}
ondblclick={() => startEdit(zone, p)}
role="button"
tabindex="-1"
use:patchFields={[p, numPages, html]}
>
{@html html}
</div>
{/if}
{/each}
{/each}
{#if hfActive}
{@const box = activeZoneBox(hfActive, editingPage)}
{@const variant = variantFor(editingPage)}
<div class="hf-zone hf-{hfActive} hf-active" style={boxStyle(box) + ` --hf-tb-offset: ${-activeTrailingPx}px;`} bind:this={liveMount}></div>
<div class="hf-tag" style="top: {box.top}px; left: {box.left}px;">
{hfActive === 'header'
? (variant === 'first' ? t().hf.firstPageHeader : variant === 'even' ? t().hf.evenPageHeader : t().hf.headerLabel)
: (variant === 'first' ? t().hf.firstPageFooter : variant === 'even' ? t().hf.evenPageFooter : t().hf.footerLabel)}
</div>
<div class="hf-bar" style="top: {box.top}px; left: {box.left + box.width}px;">
<span class="hf-bar-label">{t().hf.insert}</span>
<button class="hf-bar-btn" title={t().hf.pageNumberTitle} onmousedown={(e) => e.preventDefault()} onclick={() => insertField('pageNumber')}>{t().hf.pageNumber}</button>
<button class="hf-bar-btn" title={t().hf.pageCountTitle} onmousedown={(e) => e.preventDefault()} onclick={() => insertField('pageCount')}>{t().hf.pageCount}</button>
<span class="hf-bar-sep"></span>
<button class="hf-bar-btn hf-bar-done" title={t().hf.doneTitle} onmousedown={(e) => e.preventDefault()} onclick={() => (hfActive = null)}>{t().hf.done}</button>
</div>
{/if}
</div>
<style>
/* Sits inside the scaled .paper, like .band-layer. Zones opt back into pointer
events so the margin areas are double-clickable to edit (Word behaviour). */
.hf-layer {
position: absolute;
inset: 0;
z-index: 22;
pointer-events: none;
}
.hf-zone {
position: absolute;
display: flex;
flex-direction: column;
pointer-events: auto;
/* Content taller than the margin band spills into the margin (footer up, header
down), like Word's auto-growing zones; the anchored edge stays put. */
overflow: visible;
font-family: var(--font-serif);
font-size: 12pt;
color: var(--color-page-text);
cursor: text;
}
.hf-header {
justify-content: flex-start;
}
.hf-footer {
justify-content: flex-end;
}
/* Empty zone: invisible until hovered, then show a faint double-click hint. */
.hf-empty::before {
content: attr(data-hf-label);
color: var(--color-text-muted);
font-size: 0.9rem;
font-family: var(--font-sans);
opacity: 0;
transition: opacity 0.12s;
}
.hf-footer.hf-empty::before {
margin-top: auto;
}
.hf-empty:hover::before {
opacity: 0.6;
}
/* Word-style affordance: a single dashed line at the edge facing the page body
(footer top, header bottom) instead of a rectangle around the content. */
.hf-active::after {
content: '';
position: absolute;
left: 0;
right: 0;
border-top: 1px dashed var(--color-primary);
pointer-events: none;
}
.hf-active.hf-footer::after {
top: 0;
}
.hf-active.hf-header::after {
bottom: 0;
}
/* Word-style zone label tab, pinned to the zone's top-left corner. */
.hf-tag {
position: absolute;
transform: translateY(calc(-100% - 2px));
z-index: 150;
padding: 1px 6px;
background: var(--color-primary);
color: #fff;
font-family: var(--font-sans);
font-size: 0.68rem;
letter-spacing: 0.02em;
border-radius: calc(var(--radius) - 3px) calc(var(--radius) - 3px) 0 0;
pointer-events: none;
white-space: nowrap;
user-select: none;
}
/* Strip the page margins from the rendered header/footer paragraph. pre-wrap keeps
runs of spaces (used to push a right-side field over) that HTML would collapse —
matching the live editor's ProseMirror rendering. */
.hf-zone :global(p) {
margin: 0;
line-height: 1.15;
white-space: pre-wrap;
}
/* The live editor's editable root is also a `.tiptap`, so the global
`.paper .tiptap` rules (96px padding, 1123px min-height, page-background
gradient) leak in and push the text out of the clipped zone. Reset them —
higher specificity than `.paper .tiptap`, plus !important for the gradient. */
.hf-layer .hf-zone :global(.tiptap) {
padding: 0;
min-height: 0;
width: 100%;
background: none !important;
font-size: 12pt;
line-height: 1.15;
outline: none;
/* Keep the editable root at its content height (min-height:0 would otherwise let
the flex column shrink it to the band, hiding lines and misreporting height). */
flex-shrink: 0;
/* Drop the editor by the trailing-break line so the caret line overflows past the
anchored edge while the real content stays put (set per active zone). */
margin-bottom: var(--hf-tb-offset, 0px);
}
/* The live editor's paragraph also matches `.paper .tiptap p` (margin-bottom
0.212cm), which the static `<p>` doesn't — with flex-end alignment that gap
shifts the text up on activation. Match the static zero margin (higher
specificity than `.paper .tiptap p`). */
.hf-layer .hf-zone :global(.tiptap p) {
margin: 0;
}
/* A paragraph ending in an inline atom (a page field) gets a phantom trailing <br> the
static <p> lacks; ProseMirror marks that case with a separator <img>, so hide the
break only then. A real Enter-made line has no separator and keeps its caret. */
.hf-layer .hf-zone :global(.tiptap p:has(img.ProseMirror-separator) > br.ProseMirror-trailingBreak) {
display: none;
}
.hf-zone :global([data-page-field]) {
white-space: pre;
}
.hf-bar {
position: absolute;
transform: translate(calc(-100% - 4px), -100%);
z-index: 151;
display: flex;
align-items: center;
gap: 2px;
padding: 3px 4px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14);
pointer-events: auto;
white-space: nowrap;
}
.hf-bar-label {
padding: 0 4px 0 2px;
color: var(--color-text-muted);
font-family: var(--font-sans);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
user-select: none;
}
.hf-bar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 1.8rem;
padding: 0 0.55rem;
border: none;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--color-text);
font-family: var(--font-sans);
font-size: 0.8rem;
white-space: nowrap;
cursor: pointer;
transition: background 0.12s;
}
.hf-bar-btn:hover {
background: var(--color-btn-hover);
}
.hf-bar-done {
color: var(--color-primary);
font-weight: 600;
}
.hf-bar-sep {
width: 1px;
align-self: stretch;
margin: 3px 2px;
background: var(--color-border);
}
</style>