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:
1 parent
35059d8126
commit
f72be2f0e3
4 files changed
+29
-23
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user