diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index f6f218c..29a770e 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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([]); // 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; } diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index 9ca168e..3bd440e 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -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} diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index 3461b33..6c9536c 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -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 ``; 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 ``; `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 ``; `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. diff --git a/src/lib/editor/extensions/pageField.ts b/src/lib/editor/extensions/pageField.ts index cb0f54b..de49aaa 100644 --- a/src/lib/editor/extensions/pageField.ts +++ b/src/lib/editor/extensions/pageField.ts @@ -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 . +// 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` is the file's cached name. export const ChapterField = Node.create({ name: 'chapterField', group: 'inline',