feat: header/footer position

This commit is contained in:
Steffen Becker committed 2026-06-15 15:48:15 +02:00
1 parent ee217610ba
commit 7b67969774
8 files changed
+197 -26

No files matched your search

+16 -2
View File
@@ -10,7 +10,7 @@
import { loadTheme, saveTheme, applyTheme, loadToolbarExpanded, saveToolbarExpanded, loadFormattingMarks, saveFormattingMarks, type ThemeMode } from './lib/storage/theme';
import { loadPageMargins, savePageMargins, type PageMargins } from './lib/storage/pageMargins';
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
import { loadHfDoc, saveHfDoc, type HfDoc, type HfZone } from './lib/storage/headerFooter';
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter';
let editor: Editor | null = $state(null);
let tick: number = $state(0);
@@ -21,6 +21,7 @@
// top toolbars target hfEditor instead of the body editor (activeEditor below).
let headerDoc: HfDoc = $state(loadHfDoc('header'));
let footerDoc: HfDoc = $state(loadHfDoc('footer'));
let hfDistances: HfDistances = $state(loadHfDistances());
let hfEditor: Editor | null = $state(null);
let hfActive: HfZone | null = $state(null);
let hfTick: number = $state(0);
@@ -56,6 +57,10 @@
saveHfDoc('footer', footerDoc);
});
$effect(() => {
saveHfDistances(hfDistances);
});
function setZoom(value: number) {
zoom = Math.max(20, Math.min(300, value));
localStorage.setItem('odf-editor-zoom', String(zoom));
@@ -83,7 +88,10 @@
async function handleExport() {
if (editor) {
await exportToOdt(editor, pageMargins, pageOrientation, { header: headerDoc, footer: footerDoc, pageCount: numPages });
await exportToOdt(editor, pageMargins, pageOrientation, {
header: headerDoc, footer: footerDoc, pageCount: numPages,
headerDistanceCm: hfDistances.header, footerDistanceCm: hfDistances.footer,
});
}
}
@@ -112,6 +120,10 @@
hfActive = null;
headerDoc = result.header;
footerDoc = result.footer;
hfDistances = {
header: result.headerDistanceCm ?? DEFAULT_HF_DISTANCES.header,
footer: result.footerDistanceCm ?? DEFAULT_HF_DISTANCES.footer,
};
if (result.warnings.length) {
console.warn('[import] Unsupported content in opened file:', result.warnings);
@@ -248,6 +260,7 @@
bind:showFormattingMarks
bind:pageMargins
bind:pageOrientation
bind:hfDistances
hfActive={hfActive}
onEditZone={(zone) => (hfActive = zone)}
/>
@@ -262,6 +275,7 @@
bind:hfEditor
bind:hfActive
bind:hfTick
{hfDistances}
{zoom}
{showFormattingMarks}
{pageMargins}
+4 -3
View File
@@ -9,7 +9,7 @@
import { saveDocument, loadDocument } from '../storage/autosave';
import { applyMarginVars, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import { applyOrientationVars, type Orientation } from '../storage/pageOrientation';
import type { HfDoc, HfZone } from '../storage/headerFooter';
import { DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, type TableBreakBand } from './pageBreaks';
import { recordTransaction, resetHistoryLog } from './historyLog.svelte';
import '../../styles/editor.css';
@@ -19,12 +19,12 @@
let {
editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1),
zoom = 100, showFormattingMarks = false, pageMargins = DEFAULT_MARGINS, orientation = 'portrait',
headerDoc = $bindable(null), footerDoc = $bindable(null),
headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES,
hfEditor = $bindable(null), hfActive = $bindable(null), hfTick = $bindable(0),
}: {
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number;
showFormattingMarks?: boolean; pageMargins?: PageMargins; orientation?: Orientation;
headerDoc?: HfDoc; footerDoc?: HfDoc;
headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances;
hfEditor?: Editor | null; hfActive?: HfZone | null; hfTick?: number;
} = $props();
@@ -420,6 +420,7 @@
{currentPage}
{pageMargins}
{orientation}
{hfDistances}
/>
</div>
</div>
+10 -5
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { Editor, generateHTML } from '@tiptap/core';
import { hfExtensions } from './hfExtensions';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfZone } from '../storage/headerFooter';
import { hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from '../storage/headerFooter';
import { cmToPx, type PageMargins } from '../storage/pageMargins';
import { PAGE_W_PORTRAIT, PAGE_H_PORTRAIT, type Orientation } from '../storage/pageOrientation';
@@ -12,6 +12,7 @@
currentPage,
pageMargins,
orientation,
hfDistances = DEFAULT_HF_DISTANCES,
hfEditor = $bindable(),
hfActive = $bindable(),
hfTick = $bindable(),
@@ -22,13 +23,13 @@
currentPage: number;
pageMargins: PageMargins;
orientation: Orientation;
hfDistances?: HfDistances;
hfEditor: Editor | null;
hfActive: HfZone | null;
hfTick: number;
} = $props();
const PAGE_GAP = 20;
const HF_DISTANCE_PX = cmToPx(HF_DISTANCE_CM);
// Schema for static (read-only) rendering of the inactive zones.
const renderExts = hfExtensions();
@@ -42,6 +43,10 @@
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, clamped below the body margin so the zone fits within
// the margin band (matches the export's Math.min(dist, margin) clamp).
let headerDistPx = $derived(Math.min(cmToPx(hfDistances.header), mTop));
let footerDistPx = $derived(Math.min(cmToPx(hfDistances.footer), mBottom));
let pages = $derived(Array.from({ length: Math.max(1, numPages) }, (_, i) => i + 1));
@@ -49,11 +54,11 @@
const left = mLeft;
const width = contentWidth;
if (zone === 'header') {
const top = (page - 1) * cycle + HF_DISTANCE_PX;
return { top, left, width, height: Math.max(0, mTop - HF_DISTANCE_PX) };
const top = (page - 1) * cycle + headerDistPx;
return { top, left, width, height: Math.max(0, mTop - headerDistPx) };
}
const top = (page - 1) * cycle + pageHeightPx - mBottom;
return { top, left, width, height: Math.max(0, mBottom - HF_DISTANCE_PX) };
return { top, left, width, height: Math.max(0, mBottom - footerDistPx) };
}
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;`;
+90 -3
View File
@@ -11,9 +11,10 @@
} from './fontDetect';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { DEFAULT_HF_DISTANCES, clampHfDistance, type HfDistances } from '../storage/headerFooter';
let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), hfActive = null, onEditZone }:
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void } = $props();
let { editor, tick, showFormattingMarks = $bindable(), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), hfActive = null, onEditZone }:
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; hfDistances?: HfDistances; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void } = $props();
type AlignValue = 'left' | 'center' | 'right' | 'justify';
@@ -606,6 +607,54 @@
}
}
// --- Header/footer edge distances (cm) ---
type HfAxis = 'header' | 'footer';
const HF_DIST_FIELDS: { axis: HfAxis; label: string }[] = [
{ axis: 'header', label: 'Header from top' },
{ axis: 'footer', label: 'Footer from bottom' },
];
let hfDistInputs = $state<Record<HfAxis, string>>({ header: '', footer: '' });
function syncHfDistInputs() {
hfDistInputs = { header: fmtCm(hfDistances.header), footer: fmtCm(hfDistances.footer) };
}
function setHfDist(axis: HfAxis, value: number) {
const clamped = clampHfDistance(value);
hfDistances = { ...hfDistances, [axis]: clamped };
hfDistInputs[axis] = fmtCm(clamped);
}
function stepHfDist(axis: HfAxis, delta: number) {
setHfDist(axis, hfDistances[axis] + delta);
}
function commitHfDistInput(axis: HfAxis) {
const n = parseFloat(hfDistInputs[axis]);
if (Number.isNaN(n)) {
hfDistInputs[axis] = fmtCm(hfDistances[axis]); // revert invalid input
return;
}
setHfDist(axis, n);
}
function onHfDistKeydown(axis: HfAxis, e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
commitHfDistInput(axis);
(e.target as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
hfDistInputs[axis] = fmtCm(hfDistances[axis]);
(e.target as HTMLInputElement).blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
stepHfDist(axis, MARGIN_STEP);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
stepHfDist(axis, -MARGIN_STEP);
}
}
function openLayout() {
fontOpen = false;
sizeOpen = false;
@@ -615,7 +664,10 @@
highlightColorOpen = false;
alignOpen = false;
layoutOpen = !layoutOpen;
if (layoutOpen) syncMarginInputs();
if (layoutOpen) {
syncMarginInputs();
syncHfDistInputs();
}
}
function layoutClickOutside(node: HTMLElement) {
@@ -1072,6 +1124,38 @@
onclick={() => onEditZone?.('footer')}
>Edit footer</button>
</div>
<div class="lh-section-label">Position (cm)</div>
<div class="margin-grid">
{#each HF_DIST_FIELDS as f}
<div class="margin-field">
<span class="margin-label">{f.label}</span>
<div class="margin-input-wrap">
<input
type="text"
class="margin-input"
bind:value={hfDistInputs[f.axis]}
onkeydown={(e) => onHfDistKeydown(f.axis, e)}
onblur={() => commitHfDistInput(f.axis)}
inputmode="decimal"
title="{f.label} (cm)"
/>
<div class="margin-steppers">
<button class="margin-step" tabindex="-1" onclick={() => stepHfDist(f.axis, MARGIN_STEP)} title="Increase" aria-label="Increase {f.label.toLowerCase()}">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 5.5l3-3 3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="margin-step" tabindex="-1" onclick={() => stepHfDist(f.axis, -MARGIN_STEP)} title="Decrease" aria-label="Decrease {f.label.toLowerCase()}">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1 2.5l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
@@ -1570,6 +1654,9 @@
.margin-input-wrap {
display: flex;
align-items: stretch;
/* Pin the input to the bottom of its grid cell so a wrapped two-line label
(e.g. "Footer from bottom") doesn't push its box below the sibling's. */
margin-top: auto;
height: 1.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
+24 -11
View File
@@ -872,15 +872,26 @@ function applyLineBreaks(odtBytes: Uint8Array): Uint8Array {
return rezipOdt(files);
}
// Header/footer input for the export: one single-paragraph doc per zone (or null)
// plus the page count snapshot used as the stored <text:page-count> value.
export type HfExport = { header: HfDoc; footer: HfDoc; pageCount: number };
// Header/footer input for the export: one single-paragraph doc per zone (or null),
// the page count snapshot for the stored <text:page-count> value, and the page-edge
// distances (cm; default HF_DISTANCE_CM) for header (from top) / footer (from bottom).
export type HfExport = {
header: HfDoc;
footer: HfDoc;
pageCount: number;
headerDistanceCm?: number;
footerDistanceCm?: number;
};
// The full document → .odt pipeline, DOM-free (exportToOdt adds the download).
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport): Promise<Uint8Array> {
const raw = replaceHardBreaks(docJson);
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
// Distance from the page edge to the header (top) / footer (bottom). Becomes the
// ODF page margin; clamped below the body margin so the body still starts at it.
const headerDist = Math.min(hf?.headerDistanceCm ?? HF_DISTANCE_CM, margins.top);
const footerDist = Math.min(hf?.footerDistanceCm ?? HF_DISTANCE_CM, margins.bottom);
let json = injectCustomTypes(raw);
if (headerPara || footerPara) {
json = { ...json, content: [{ type: CUST_HF }, ...(json.content ?? [])] };
@@ -904,8 +915,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
// With a header/footer, ODF's vertical margin is the page-edge→header distance;
// applyHfPostProcess sizes the header box so the body still starts at the
// editor's margin (Word-style mapping).
marginTop: `${headerPara ? HF_DISTANCE_CM : margins.top}cm`,
marginBottom: `${footerPara ? HF_DISTANCE_CM : margins.bottom}cm`,
marginTop: `${headerPara ? headerDist : margins.top}cm`,
marginBottom: `${footerPara ? footerDist : margins.bottom}cm`,
marginLeft: `${margins.left}cm`,
marginRight: `${margins.right}cm`,
unknownNodeHandler(node: TiptapNode, doc: OdtDocument) {
@@ -978,7 +989,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const cleaned = collapseRunWhitespace(styledRows);
const withBreaks = applyLineBreaks(cleaned);
const withStyles = rewriteStylesXml(withBreaks);
return applyHfPostProcess(withStyles, margins, headerPara, footerPara);
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist);
}
function hfAlign(para: TiptapNode): AlignValue | null {
@@ -991,7 +1002,7 @@ function hfAlign(para: TiptapNode): AlignValue | null {
// Word-style mapping (page margin = HF distance, min-height fills up to the body
// margin so the body starts exactly at the editor's margin), and apply the
// paragraph alignment to the named Header/Footer styles.
function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPara: TiptapNode | null, footerPara: TiptapNode | null): Uint8Array {
function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPara: TiptapNode | null, footerPara: TiptapNode | null, headerDist: number, footerDist: number): Uint8Array {
if (!headerPara && !footerPara) return odtBytes;
const files = unzipSync(odtBytes);
@@ -1006,8 +1017,10 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa
styles = styles.replace(new RegExp(`${PGC}(\\d*)${PGC}`, 'g'), '<text:page-count>$1</text:page-count>');
const round3 = (v: number) => Math.round(v * 1000) / 1000;
const zone = (kind: 'header' | 'footer', para: TiptapNode, bodyMarginCm: number) => {
const minH = round3(Math.max(0.2, bodyMarginCm - HF_DISTANCE_CM));
const zone = (kind: 'header' | 'footer', para: TiptapNode, bodyMarginCm: number, distCm: number) => {
// min-height fills the gap between the edge→zone distance and the body margin,
// so the body still starts at bodyMarginCm (page margin was set to distCm).
const minH = round3(Math.max(0.2, bodyMarginCm - distCm));
// The spacing attr sits on the body-facing side: below the header, above the footer.
const spacingAttr = kind === 'header' ? 'fo:margin-bottom' : 'fo:margin-top';
styles = styles.replace(
@@ -1023,8 +1036,8 @@ function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPa
);
}
};
if (headerPara) zone('header', headerPara, margins.top);
if (footerPara) zone('footer', footerPara, margins.bottom);
if (headerPara) zone('header', headerPara, margins.top, headerDist);
if (footerPara) zone('footer', footerPara, margins.bottom, footerDist);
files['styles.xml'] = strToU8(styles);
return rezipOdt(files);
+6
View File
@@ -26,6 +26,9 @@ export interface OdtImportResult {
// Single-paragraph docs in the hfExtensions schema; null = no zone.
header: Node | null;
footer: Node | null;
// Edge→zone distance (cm): header from top, footer from bottom. null = no zone.
headerDistanceCm: number | null;
footerDistanceCm: number | null;
warnings: string[];
}
@@ -87,12 +90,15 @@ export function importOdt(bytes: Uint8Array): OdtImportResult {
}
const geometry = resolver.pageGeometry();
const edge = resolver.edgeDistancesCm();
return {
content: { type: 'doc', content: blocks },
margins: geometry?.margins ?? null,
orientation: geometry?.orientation ?? null,
header: hf.header ? convertHfZone(hf.header, ctx) : null,
footer: hf.footer ? convertHfZone(hf.footer, ctx) : null,
headerDistanceCm: hf.header ? edge?.top ?? null : null,
footerDistanceCm: hf.footer ? edge?.bottom ?? null : null,
warnings: [...warnings],
};
}
+12
View File
@@ -319,4 +319,16 @@ export class StyleResolver {
const orientation: Orientation = w != null && h != null && w > h ? 'landscape' : 'portrait';
return { margins, orientation };
}
// Raw page margins (cm) = ODF's edge→zone distance, i.e. the header distance from
// the top and footer distance from the bottom when a header/footer is present.
edgeDistancesCm(): { top: number; bottom: number } | null {
const props = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null;
if (!props) return null;
const cm = (attr: string) => {
const v = lengthToCm(props.getAttributeNS(NS.fo, attr));
return v == null ? null : Math.min(10, Math.max(0, Math.round(v * 100) / 100));
};
return { top: cm('margin-top') ?? 1.25, bottom: cm('margin-bottom') ?? 1.25 };
}
}
+35 -2
View File
@@ -11,8 +11,41 @@ const KEYS: Record<HfZone, string> = {
// Word's default distance from the page edge to the header/footer text. The body
// margin stays the body margin (Word semantics); export/import convert to ODF's
// margin-to-header model (see export/odt.ts applyHeaderFooterGeometry).
export const HF_DISTANCE_CM = 1.27;
// margin-to-header model (see export/odt.ts applyHfPostProcess).
export const HF_DISTANCE_CM = 1.25;
// Per-zone distance from the page edge to the header (from top) / footer (from
// bottom), in cm — user-configurable in the Layout panel.
export type HfDistances = { header: number; footer: number };
export const DEFAULT_HF_DISTANCES: HfDistances = { header: HF_DISTANCE_CM, footer: HF_DISTANCE_CM };
const DIST_KEY = 'odf-editor-hf-distances';
const DIST_MIN = 0;
const DIST_MAX = 10;
export function clampHfDistance(n: number): number {
if (!Number.isFinite(n)) return HF_DISTANCE_CM;
return Math.min(DIST_MAX, Math.max(DIST_MIN, Math.round(n * 100) / 100));
}
export function loadHfDistances(): HfDistances {
const raw = localStorage.getItem(DIST_KEY);
if (!raw) return { ...DEFAULT_HF_DISTANCES };
try {
const p = JSON.parse(raw);
return {
header: typeof p.header === 'number' ? clampHfDistance(p.header) : HF_DISTANCE_CM,
footer: typeof p.footer === 'number' ? clampHfDistance(p.footer) : HF_DISTANCE_CM,
};
} catch {
return { ...DEFAULT_HF_DISTANCES };
}
}
export function saveHfDistances(d: HfDistances): void {
localStorage.setItem(DIST_KEY, JSON.stringify(d));
}
export function loadHfDoc(zone: HfZone): HfDoc {
const raw = localStorage.getItem(KEYS[zone]);