diff --git a/src/App.svelte b/src/App.svelte index 97676e7..07ff1ca 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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} diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index a86c41c..8dff2c2 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -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` diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 43f430f..1c78dc0 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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} /> diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index 622b3b8..487ad1a 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -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([ { 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}
{ 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)}
{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}` + : ''}
{t().hf.insert} @@ -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); diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index d3be9b4..be54dbf 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -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', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 3e4ac74..5577530 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -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', diff --git a/src/lib/storage/headerFooter.ts b/src/lib/storage/headerFooter.ts index e9e534a..9dba7b9 100644 --- a/src/lib/storage/headerFooter.ts +++ b/src/lib/storage/headerFooter.ts @@ -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[] { diff --git a/tests/render-parity/README.md b/tests/render-parity/README.md index 391fb4e..a151fc1 100644 --- a/tests/render-parity/README.md +++ b/tests/render-parity/README.md @@ -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