fix: resolve the chapter field per zone the way LibreOffice does

The header shows the chapter in force at the page's top (a heading
counts once it opens a page), the footer the last one begun on the
page. Both zones showed the footer's reading before.
This commit is contained in:
Steffen Becker committed 2026-08-30 10:06:43 +00:00
1 parent 35059d8126
commit f72be2f0e3
4 files changed
+29 -23

No files matched your search

+11 -6
View File
@@ -42,7 +42,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
import { applyPageSizeVars, pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { MAX_PAGE_COLUMNS } from '../storage/theme';
import { DEFAULT_HF_DISTANCES, hfIsEmpty, hfUsesChapterField, type HfDoc, type HfZone, type HfDistances, type HfSet } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, PAGE_GAP, pageOfElement, readVerticalMargins, type TableBreakBand } from '../editor/extensions/pageBreaks';
import { FORCE_PAGE_RECALC, PAGE_GAP, pageOfElement, readVerticalMargins, topInEditor, type TableBreakBand } from '../editor/extensions/pageBreaks';
import { findBookmark } from '../editor/extensions/bookmark';
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
import { fitPagesZoom, wheelZoomFactor } from '../utils/zoom';
@@ -95,23 +95,28 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
let sectionStartPages = $state<number[]>([]);
// Where each heading starts, for the running head's chapter field. Only collected
// when a zone actually shows one — it costs a layout read per heading.
let chapterStarts = $state<{ page: number; level: number; text: string }[]>([]);
let chapterStarts = $state<{ page: number; level: number; text: string; atTop: boolean }[]>([]);
let wantsChapters = $derived(hfUsesChapterField([
{ header: headerDoc, footer: footerDoc, headerFirst: headerFirstDoc, footerFirst: footerFirstDoc,
differentFirstPage, headerEven: headerEvenDoc, footerEven: footerEvenDoc, differentOddEven },
...extraHfSections,
]));
function collectChapterStarts(): { page: number; level: number; text: string }[] {
function collectChapterStarts(): { page: number; level: number; text: string; atTop: boolean }[] {
if (!editor || editor.isDestroyed) return [];
const grid = readVerticalMargins(editor.view.dom as HTMLElement).grid;
const out: { page: number; level: number; text: string }[] = [];
const vm = readVerticalMargins(editor.view.dom as HTMLElement);
const out: { page: number; level: number; text: string; atTop: boolean }[] = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name !== 'heading') return;
const text = node.textContent.trim();
const el = editor!.view.nodeDOM(pos) as HTMLElement | null;
if (!text || !el || el.nodeType !== 1) return;
out.push({ page: pageOfElement(editor!.view, el, grid), level: (node.attrs.level as number) ?? 1, text });
const top = topInEditor(editor!.view, el);
const page = vm.grid.pageAt(top);
// Nothing above the heading on its page (within half a line): the page's running
// head counts it, a heading below other content only counts from the next page.
const atTop = top - (vm.grid.topOf(page) + vm.top) < 10;
out.push({ page, level: (node.attrs.level as number) ?? 1, text, atTop });
});
return out;
}
+14 -13
View File
@@ -58,7 +58,7 @@
hfTick: number;
extraHfSections?: HfSet[];
sectionStartPages?: number[];
chapterStarts?: { page: number; level: number; text: string }[];
chapterStarts?: { page: number; level: number; text: string; atTop?: boolean }[];
/** False in a split view's second pane: it draws the zones, it does not edit them. */
interactive?: boolean;
} = $props();
@@ -212,16 +212,17 @@
return v === 'first' ? b.footerFirst : v === 'even' ? b.footerEven : b.footer;
}
// Page, page count, the zone's HTML and the chapter map: everything the two actions
// below re-run on (the map's identity changes with each pagination pass).
type ZoneParams = [number, number, string, unknown];
// Page, page count, the zone's HTML, the chapter map and the zone: everything the two
// actions below re-run on (the map's identity changes with each pagination pass).
type ZoneParams = [number, number, string, unknown, HfZone];
// The chapter a page runs under: the last heading at or above the field's outline
// level that has started by then (ODF text:chapter, Word STYLEREF).
function chapterOn(page: number, level: number): string {
// The chapter a page runs under (ODF text:chapter, probed): the header shows the
// chapter in force at the page's top — a heading counts there only once it opens a
// page — while the footer shows the last one begun on the page.
function chapterOn(page: number, level: number, zone: HfZone): string {
let text = '';
for (const c of chapterStarts) {
if (c.page > page) break;
if (c.page > page || (zone === 'header' && c.page === page && !c.atTop)) continue;
if (c.level <= level) text = c.text;
}
return text;
@@ -250,10 +251,10 @@
// 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: ZoneParams) {
const apply = ([page, total]: ZoneParams) => {
const apply = ([page, total, , , zone]: ZoneParams) => {
for (const el of Array.from(node.querySelectorAll('[data-page-field]'))) {
const kind = el.getAttribute('data-page-field');
if (kind === 'chapter') el.textContent = chapterOn(page, Number(el.getAttribute('data-level')) || 1);
if (kind === 'chapter') el.textContent = chapterOn(page, Number(el.getAttribute('data-level')) || 1, zone);
// The count stays decimal, as the field LibreOffice and Word write does.
else el.textContent = kind === 'count' ? String(total) : pageLabel(page);
}
@@ -367,7 +368,7 @@
if (!liveMount) return;
for (const el of Array.from(liveMount.querySelectorAll('[data-page-field]'))) {
const kind = el.getAttribute('data-page-field');
if (kind === 'chapter') el.textContent = chapterOn(editingPage, Number(el.getAttribute('data-level')) || 1);
if (kind === 'chapter') el.textContent = chapterOn(editingPage, Number(el.getAttribute('data-level')) || 1, hfActive ?? 'header');
else el.textContent = kind === 'count' ? String(numPages) : pageLabel(editingPage);
}
// Content height (unscaled by the zoom transform) drives the active zone's frame.
@@ -416,8 +417,8 @@
ondblclick={() => interactive && startEdit(zone, p)}
role="button"
tabindex="-1"
use:patchFields={[p, numPages, html, chapterStarts]}
use:layOutTabs={[p, numPages, html, chapterStarts]}
use:patchFields={[p, numPages, html, chapterStarts, zone]}
use:layOutTabs={[p, numPages, html, chapterStarts, zone]}
>
{@html html}
</div>
+1 -1
View File
@@ -44,7 +44,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`tableOfContents.ts`** — a generated index. The `index` attr picks the family: `toc` lists every heading down to `maxLevel` (ODF `text:outline-level`, Word's `TOC \o` range, default 5 — listing deeper than the file asks inflates the block by pages). `title: ''` is an index that deliberately has none (its heading is a separate paragraph, as Word and LibreOffice write it); only a missing title falls back to "Table of Contents". `levelStyles` is the named paragraph style each level's entries use (ODF's per-level entry template, which is what LibreOffice regenerates from — the cached body paragraphs point at automatic styles derived from it): the rows carry it as `data-style`, so the document stylesheet gives them the file's own indent, space-before and font, and `editor.css`'s 0.5cm-per-level fallback applies only to an index that names none. `figures`/`tables` instead list every caption paragraph counting that category (`caption.ts`), single-level as LibreOffice's are — ODF `text:illustration-index`/`text:table-index` (found by `text:caption-sequence-name`, not by outline level), Word the same TOC field with `\c "Figure"`. `bibliography` lists the sources cited (`bibliographyEntry.ts`) and is the one family that **never** has a page number or a leader — a bibliography row is the source and nothing else, which is how both word processors print one. Any other family drops both where the file says so (`pageNumbers: false` — Word's `TOC \n`, an ODF entry template naming no `<text:index-entry-page-number/>`; the export writes the stop out with the number, which is the pair LibreOffice's own dialog switches together).
- **`trailingNode.ts`** — keeps an empty paragraph after a trailing table, text box or columns section (they are isolating, so the caret would otherwise have nowhere to go). Also the trap at the start: Enter at the very first position of a table with nothing before it inserts a paragraph above and moves the caret there.
- **`headerFooter.ts`** (`hfExtensions`) — the header/footer schema: `Document` constrained to one paragraph, the body's marks, `HardBreak`, `TextAlign`, and the `pageField.ts` atoms. No image, no date field, no table.
- **`pageField.ts`** — inline atoms `pageNumber`/`pageCount`/`chapterField`, rendered as `<span data-page-field>`; `HeaderFooterLayer.svelte` patches each span's text to the real per-page value. `chapterField` is the running head (ODF `text:chapter`, Word `STYLEREF`): its `level` is the outline level to show, `text` the file's cached name, and the page's own chapter is the last heading at or above that level to have started (`Editor.svelte` collects the heading→page map on each pagination pass, only when a zone shows one).
- **`pageField.ts`** — inline atoms `pageNumber`/`pageCount`/`chapterField`, rendered as `<span data-page-field>`; `HeaderFooterLayer.svelte` patches each span's text to the real per-page value. `chapterField` is the running head (ODF `text:chapter`, Word `STYLEREF`): its `level` is the outline level to show, `text` the file's cached name. The page's chapter follows LibreOffice (probed): a **header** shows the one in force at the page's top (a heading counts once it opens a page — the `atTop` bit), a **footer** the last one begun on the page (`Editor.svelte` collects the heading→page map on each pagination pass, only when a zone shows one).
- **`autoCorrect.ts`** — AutoCorrect while typing (LibreOffice's Tools ▸ AutoCorrect Options): typographic quotes per document language, its dash matrix, its replacement table, sentence capitalization and TWo INitials. One `handleTextInput` plugin, listed **last** in `extensions.ts` so every input rule (list markers, the rule line) still fires first; `autoCorrectFix(before, opts, lang)` is pure — the whole table is `tests/unit/autocorrect.test.ts`. URL recognition and auto-list are TipTap's own (`Link autolink` — gated by the same options — and the list input rules).
- **`wordCompletion.ts`** — LibreOffice's Word Completion (its AutoCorrect dialog's own tab): every word of at least `minLength` letters is collected as it is finished, and typing its first three letters offers the rest. Drawn as a **widget decoration** after the caret, not LibreOffice's tooltip and not its selected-text insert — the offer is outside the document until Enter takes it, so nothing has to be undone when it is not. Only while **typing**: the offer is dropped on a bare caret move, or an Enter meant to split the paragraph would complete a word instead. `priority: 1000`, so its `handleKeyDown` sees Enter ahead of the keymaps. The list and its settings live in `storage/wordCompletion.svelte.ts`, the collection and matching rules in `utils/wordCompletion.ts`.
- **`autoText.ts`** — LibreOffice's AutoText / Word's Quick Parts: a named block of text, inserted from the library (`AutoTextDialog.svelte`) or by typing its shortcut and pressing **F3**, which is both products' key for it — and which the app also binds to find-next: the editor sees the key first, so it expands a shortcut where the caret has one and leaves the key alone otherwise (`App.svelte`'s window handler returns on `defaultPrevented`). An entry is stored as HTML and parsed back through the schema, so it keeps its marks and blocks and survives a schema that has grown. The library belongs to the app, not to a document (`storage/autoText.svelte.ts`) — as it does in both products, which keep it in a template of their own; nothing about it reaches a saved file.
+3 -3
View File
@@ -24,9 +24,9 @@ function pageFieldNode(name: string, kind: 'number' | 'count') {
export const PageNumber = pageFieldNode('pageNumber', 'number');
export const PageCount = pageFieldNode('pageCount', 'count');
// The chapter heading in force on the page — the running head every manual has. The
// same per-page patching applies; `text` is the file's cached name, shown until the
// layer resolves the page's own. Round-trips to ODF <text:chapter>.
// The chapter heading in force on the page — the running head every manual has (in a
// header the chapter at the page's top, in a footer the last begun on it, as
// LibreOffice resolves its <text:chapter>); `text` is the file's cached name.
export const ChapterField = Node.create({
name: 'chapterField',
group: 'inline',