feat: a later section's header and footer can be edited

The zones already imported, rendered and exported per section, but only
section 1 was editable state — the rest were marked read-only.

Editing now targets the section of the page it starts on: a double-click
edits the zone under the pointer, the Layout-panel buttons the current
page's. zoneKey/writeZone route the single live editor's read and its
write-back to either the six per-zone props or the extraHfSections entry.
This commit is contained in:
Steffen Becker committed 2026-08-08 17:00:49 +00:00
1 parent 9d85ddb213
commit aad4e7a3e4
8 files changed
+54 -45

No files matched your search

+3 -2
View File
@@ -70,7 +70,8 @@
let footerEvenDoc: HfDoc = $state(loadHfDoc('footer', 'even'));
let differentOddEven: boolean = $state(loadDifferentOddEven());
let hfDistances: HfDistances = $state(loadHfDistances());
// Sections past the first: imported and exported, not editable (see HeaderFooterLayer).
// Sections past the first; the layer edits them in place, section 1 stays the
// per-zone state above.
let extraHfSections: HfSet[] = $state(loadExtraHfSections());
let hfEditor: Editor | null = $state(null);
let hfActive: HfZone | null = $state(null);
@@ -1045,7 +1046,7 @@
bind:hfTick
{hfDistances}
{tabIntervalCm}
{extraHfSections}
bind:extraHfSections
{zoom}
onZoom={setZoom}
{showFormattingMarks}
+5 -2
View File
@@ -57,8 +57,11 @@ In dev builds a **Debug** button (`App.svelte`) downloads a JSON snapshot combin
A body block carrying `sectionBreak` (pageBreak.ts) opens a section; `HeaderFooterLayer`
picks that section's `HfSet` per page from `sectionStartPages`, which `pageBreaks.ts`
reports on `pm-pagecount`. Section 1 is the app's own editable state, the rest ride along
from the file (`extraHfSections`, persisted whole) and are read-only.
reports on `pm-pagecount`. Section 1 is the app's six per-zone states, the rest live in
`extraHfSections` (bound up to `App`, persisted whole). Editing targets the section of the
page it starts on, so a double-click edits the zone under the pointer and the Layout-panel
buttons the current page's; `zoneKey`/`writeZone` route the one live editor's read and
write-back to either side. Page geometry is still document-wide.
Word's "different first page" is per section, so a later section's first page shows its
own variant. `Editor.svelte` publishes each section's zone reaches as `--pb-section-reach`
+2 -2
View File
@@ -42,7 +42,7 @@
headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false,
headerEvenDoc = $bindable(null), footerEvenDoc = $bindable(null), differentOddEven = false,
hfEditor = $bindable(null), hfActive = $bindable(null), hfTick = $bindable(0),
extraHfSections = [],
extraHfSections = $bindable([]),
}: {
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number;
onZoom?: (zoom: number) => void;
@@ -853,7 +853,7 @@
{orientation}
{pageFormat}
{hfDistances}
{extraHfSections}
bind:extraHfSections
{sectionStartPages}
/>
</div>
+36 -31
View File
@@ -26,7 +26,7 @@
hfEditor = $bindable(),
hfActive = $bindable(),
hfTick = $bindable(),
extraHfSections = [],
extraHfSections = $bindable([]),
sectionStartPages = [],
}: {
headerDoc: HfDoc;
@@ -114,7 +114,7 @@
return '';
}
}
// Section 1 is the app's own editable state; the rest ride along from the file.
// Section 1 is the app's own editable state; the rest live in extraHfSections.
let sets = $derived<HfSet[]>([
{
header: headerDoc, footer: footerDoc,
@@ -192,11 +192,26 @@
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;
// Which HfSet field a zone + variant is.
type ZoneKey = 'header' | 'footer' | 'headerFirst' | 'footerFirst' | 'headerEven' | 'footerEven';
const zoneKey = (zone: HfZone, variant: HfVariant): ZoneKey =>
(zone + (variant === 'first' ? 'First' : variant === 'even' ? 'Even' : '')) as ZoneKey;
function zoneDoc(index: number, zone: HfZone, variant: HfVariant): HfDoc {
return (sets[index] ?? sets[0])[zoneKey(zone, variant)];
}
// Section 1's zones are separate bindable props; a later section's live in the array,
// replaced whole so the reassignment reaches App (which persists it).
function writeZone(index: number, zone: HfZone, variant: HfVariant, doc: HfDoc): void {
const key = zoneKey(zone, variant);
if (index > 0) {
extraHfSections = extraHfSections.map((s, i) => (i === index - 1 ? { ...s, [key]: doc } : s));
} else if (key === 'header') headerDoc = doc;
else if (key === 'footer') footerDoc = doc;
else if (key === 'headerFirst') headerFirstDoc = doc;
else if (key === 'footerFirst') footerFirstDoc = doc;
else if (key === 'headerEven') headerEvenDoc = doc;
else footerEvenDoc = doc;
}
function emptyDoc(): HfDoc {
return { type: 'doc', content: [{ type: 'paragraph' }] };
@@ -229,28 +244,21 @@
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);
// Which section and variant this edit session targets — fixed for its lifetime (the
// edited page's; App ends the edit when a flag toggles).
const editingIndex = sectionOf(editingPage);
const editingVariant = variantFor(editingPage, editingIndex);
const ed = new Editor({
element: mount,
extensions: hfExtensions(zone === 'header' ? t().hf.headerPlaceholder : t().hf.footerPlaceholder),
content: (zoneDoc(zone, editingVariant) ?? emptyDoc()) as Content,
content: (zoneDoc(editingIndex, 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;
writeZone(editingIndex, zone, editingVariant, editor.getJSON() as HfDoc);
},
editorProps: {
handleKeyDown: (_view, event) => {
@@ -296,14 +304,12 @@
{#each ['header', 'footer'] as const as zone}
{#if !(hfActive === zone && editingPage === p)}
{@const html = zoneHtml(zone, p)}
{@const locked = sectionOf(p) > 0}
<div
class="hf-zone hf-{zone}"
class:hf-empty={!html && !locked}
class:hf-locked={locked}
class:hf-empty={!html}
data-hf-label={zone === 'header' ? t().hf.addHeaderHint : t().hf.addFooterHint}
style={boxStyle(zoneBox(zone, p))}
ondblclick={() => { if (!locked) startEdit(zone, p); }}
ondblclick={() => startEdit(zone, p)}
role="button"
tabindex="-1"
use:patchFields={[p, numPages, html]}
@@ -317,12 +323,16 @@
{#if hfActive}
{@const box = activeZoneBox(hfActive, editingPage)}
{@const variant = variantFor(editingPage)}
{@const index = sectionOf(editingPage)}
{@const variant = variantFor(editingPage, index)}
<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)}
: (variant === 'first' ? t().hf.firstPageFooter : variant === 'even' ? t().hf.evenPageFooter : t().hf.footerLabel)}{sets.length >
1
? ` · ${t().hf.section} ${index + 1}`
: ''}
</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>
@@ -365,11 +375,6 @@
justify-content: flex-end;
}
/* A section past the first carries the file's zones and has no editing UI yet. */
.hf-locked {
cursor: default;
}
/* Empty zone: invisible until hovered, then show a faint double-click hint. */
.hf-empty::before {
content: attr(data-hf-label);
+1
View File
@@ -393,6 +393,7 @@ const de: Messages = {
footerPlaceholder: 'Fußzeile…',
headerLabel: 'Kopfzeile',
footerLabel: 'Fußzeile',
section: 'Abschnitt',
firstPageHeader: 'Kopfzeile erste Seite',
firstPageFooter: 'Fußzeile erste Seite',
evenPageHeader: 'Kopfzeile gerade Seiten',
+1
View File
@@ -397,6 +397,7 @@ const en = {
footerPlaceholder: 'Footer…',
headerLabel: 'Header',
footerLabel: 'Footer',
section: 'Section',
firstPageHeader: 'First page header',
firstPageFooter: 'First page footer',
evenPageHeader: 'Even page header',
+3 -4
View File
@@ -7,8 +7,7 @@ export type HfVariant = 'default' | 'first' | 'even';
export type HfDoc = { type: 'doc'; content?: unknown[] } | null;
// One section's zones. A document has one per section (a body block carrying
// `sectionBreak` starts the next one); section 1 is the app's own editable state,
// the rest ride along from the file.
// `sectionBreak` starts the next one).
export type HfSet = {
header: HfDoc;
footer: HfDoc;
@@ -32,8 +31,8 @@ export function hfSetIsEmpty(s: HfSet): boolean {
&& hfIsEmpty(s.headerEven) && hfIsEmpty(s.footerEven);
}
// Sections past the first, in order — imported and exported but not editable, so they
// are persisted whole rather than per zone like section 1's.
// Sections past the first, in order. Persisted whole rather than per zone like
// section 1's, whose six docs each have their own key.
const EXTRA_KEY = 'odf-editor-hf-sections';
export function loadExtraHfSections(): HfSet[] {
+3 -4
View File
@@ -95,10 +95,9 @@ LibreOffice's 48, with 62 line-level differences, most of them the justification
one is rendered: a line box avoids a float's whole *margin* box, so applying the
vertical one as a top margin makes dead space where Word flows text — measured, the
thesis' figure page went from 131mm off to 187mm and the document grew a page.
- **A later section's header/footer cannot be edited.** Its zones import, render and
export, but the Layout panel still edits section 1 only — the layer marks the others
read-only (`hf-locked`). Page geometry stays document-wide too, so a file whose
sections disagree on margins keeps the last one's.
- **Page geometry is document-wide.** Headers and footers are per section (each is
editable in place), but margins, orientation and format are not — a file whose sections
disagree on them keeps the last one's.
- **Line height follows the paragraph, not the line.** The block's CSS strut applies to
every line; a word processor takes each line's own runs and the paragraph mark only on
the last. The mark's font rides the block (`blockFontSize.ts`), and a paragraph whose