Files
edentext/src/lib/editor/extensions/tableOfContents.ts
T
Steffen Becker 1de879b490 fix: an index draws the leader its file declares, or none
A table-of-content entry template that declares its tab stop without a
style:leader-char means the gap is empty — the deputy-standards guidance
has one, and got a row of dots it never asked for. Round-trips.
2026-08-10 11:47:34 +00:00

310 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Node, mergeAttributes } from '@tiptap/core';
import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import { MAX_HEADING_LEVEL } from '../../export/odt';
import { readVerticalMargins, pageOfElement, topInEditor, FORCE_PAGE_RECALC } from './pageBreaks';
// A generated table of contents: a block atom listing every heading (levels 1–5) with its
// live page number. The node view regenerates entries from the headings + pagination and
// caches them in `entries` (persisted like a Word/LO TOC field); round-trips to ODF/DOCX.
export type TocEntry = { text: string; level: number; page: number };
// The heading above the entries. An imported TOC keeps the one its file used
// ("Inhalt", "Sommaire", …), or none at all where the file put its heading in a
// separate paragraph (`''`); a freshly inserted one takes this.
const TITLE = 'Table of Contents';
// Enough leader dots to cross the widest gap a page can offer; fillLeaders cuts each
// row's back to what its own gap holds, measuring one dot with this sample.
const LEADER_DOTS = 200;
const LEADER_PROBE = 10;
declare module '@tiptap/core' {
interface Commands<ReturnType> {
tableOfContents: {
setTableOfContents: () => ReturnType;
};
}
}
export const TableOfContents = Node.create({
name: 'tableOfContents',
group: 'block',
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
title: {
default: null as string | null,
parseHTML: el => (el as HTMLElement).getAttribute('data-toc-title'),
renderHTML: attrs => (attrs.title != null ? { 'data-toc-title': String(attrs.title) } : {}),
},
// Deepest heading level the index lists (ODF text:outline-level, Word's TOC \o
// range): listing more than the file asks for inflates the block by pages.
maxLevel: {
default: MAX_HEADING_LEVEL,
parseHTML: el => Number((el as HTMLElement).getAttribute('data-toc-levels')) || MAX_HEADING_LEVEL,
renderHTML: attrs => ({ 'data-toc-levels': String(attrs.maxLevel ?? MAX_HEADING_LEVEL) }),
},
// The character filling the gap to the page number; null = the file's index leaves
// it empty. Round-trips to style:leader-char on the entry template.
leader: {
default: '.',
parseHTML: el => (el as HTMLElement).getAttribute('data-toc-leader') || null,
renderHTML: attrs => (attrs.leader ? { 'data-toc-leader': String(attrs.leader) } : {}),
},
entries: {
default: [] as TocEntry[],
parseHTML: el => {
try { return JSON.parse((el as HTMLElement).getAttribute('data-entries') ?? '[]'); }
catch { return []; }
},
renderHTML: attrs => ({ 'data-entries': JSON.stringify(attrs.entries ?? []) }),
},
};
},
parseHTML() {
return [{ tag: 'div[data-toc]' }];
},
renderHTML({ HTMLAttributes, node }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-toc': 'true' }), tocTitle(node.attrs.title)];
},
addCommands() {
return {
setTableOfContents:
() =>
({ commands }) =>
commands.insertContent({ type: this.name }),
};
},
addNodeView() {
return ({ editor, getPos }) => new TocView(editor, getPos as () => number);
},
});
// `''` is a title the file deliberately doesn't have, so only a missing one defaults.
const tocTitle = (title: unknown): string => (typeof title === 'string' ? title : TITLE);
type HeadingRef = { text: string; level: number; pos: number };
// Node view: renders the title + one clickable row per heading. Recomputes on each
// pagination settle (pm-pagecount, caught on the .paper ancestor) and on doc change,
// writing entries back to the node attr — guarded by a serialized key against a loop.
class TocView {
dom: HTMLElement;
private editor: Editor;
private getPos: () => number;
private scheduled = false;
private lastKey = '';
private wasPaginated = false;
private paper: HTMLElement | null = null;
private onPageCount = () => this.schedule();
constructor(editor: Editor, getPos: () => number) {
this.editor = editor;
this.getPos = getPos;
this.dom = document.createElement('div');
this.dom.className = 'toc';
this.dom.dataset.toc = 'true';
this.dom.setAttribute('contenteditable', 'false');
// Mount deferred so .paper exists and the first pagination pass has run.
requestAnimationFrame(() => {
this.paper = this.dom.closest('.paper') as HTMLElement | null;
this.paper?.addEventListener('pm-pagecount', this.onPageCount);
this.render();
});
}
private schedule(): void {
if (this.scheduled) return;
this.scheduled = true;
requestAnimationFrame(() => {
this.scheduled = false;
this.render();
});
}
// Every heading with non-empty text down to the index's own level, in document order.
private headings(): HeadingRef[] {
const max = Math.min(MAX_HEADING_LEVEL, Number(this.node()?.attrs?.maxLevel) || MAX_HEADING_LEVEL);
const out: HeadingRef[] = [];
this.editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
const text = node.textContent.trim();
const level = Math.min(MAX_HEADING_LEVEL, (node.attrs.level as number) ?? 1);
if (text && level <= max) out.push({ text, level, pos });
}
});
return out;
}
private cycle(): number {
return readVerticalMargins(this.editor.view.dom as HTMLElement).cycle;
}
private pageOf(pos: number, cycle: number): number {
const el = this.editor.view.nodeDOM(pos) as HTMLElement | null;
if (!el || el.nodeType !== 1) return 1;
return pageOfElement(this.editor.view, el, cycle);
}
private render(): void {
if (this.editor.isDestroyed || !this.dom.isConnected) return;
const heads = this.headings();
const cycle = this.cycle();
const entries: TocEntry[] = heads.map(h => ({ text: h.text, level: h.level, page: this.pageOf(h.pos, cycle) }));
const key = JSON.stringify(entries);
if (key !== this.lastKey) {
this.lastKey = key;
this.paint(entries, heads);
this.syncAttr(entries);
}
this.paginate();
}
// The index is a block atom: it has no inner document positions for pagination to put
// a spacer at, so one longer than a page breaks itself — the row that would cross the
// boundary takes the gap to the next page's content top as its margin.
private paginate(): void {
const rows = Array.from(this.dom.querySelectorAll<HTMLElement>('.toc-entry'));
if (!rows.length) return;
const view = this.editor.view;
const vm = readVerticalMargins(view.dom as HTMLElement);
for (const row of rows) row.style.marginTop = '';
// Read every natural top first: applying a gap moves each row below it, and one
// reflow for the whole index beats one per row.
const tops = rows.map(row => topInEditor(view, row));
let shift = 0;
let moved = false;
rows.forEach((row, i) => {
const top = tops[i] + shift;
const page = Math.max(1, Math.floor(top / vm.cycle) + 1);
if (top + row.offsetHeight <= (page - 1) * vm.cycle + vm.top + vm.contentHeight) return;
const gap = page * vm.cycle + vm.top - top;
if (gap <= 0) return;
row.style.marginTop = `${gap}px`;
shift += gap;
moved = true;
});
// The index just changed height, and pagination measured the old one.
if (moved !== this.wasPaginated) {
this.wasPaginated = moved;
view.dispatch(view.state.tr.setMeta('addToHistory', false).setMeta(FORCE_PAGE_RECALC, true));
}
}
private paint(entries: TocEntry[], heads: HeadingRef[]): void {
this.dom.textContent = '';
const titleText = tocTitle(this.node()?.attrs?.title);
if (titleText) {
const title = document.createElement('div');
title.className = 'toc-title';
title.textContent = titleText;
this.dom.appendChild(title);
}
if (entries.length === 0) {
const empty = document.createElement('div');
empty.className = 'toc-empty';
empty.textContent = 'No headings yet — add an H1/H2/H3 to build the contents.';
this.dom.appendChild(empty);
return;
}
const fill = typeof this.node()?.attrs?.leader === 'string' ? String(this.node()!.attrs.leader) : '';
entries.forEach((e, i) => {
const row = document.createElement('div');
row.className = `toc-entry toc-level-${e.level}`;
const text = document.createElement('span');
text.className = 'toc-text';
text.textContent = e.text;
const leader = document.createElement('span');
leader.className = 'toc-leader';
// Real fill characters, as a word processor draws them: they scale with the font
// and reach the PDF as text. The row clips whatever the gap has no room for.
leader.textContent = fill.repeat(fill ? LEADER_DOTS : 0);
const page = document.createElement('span');
page.className = 'toc-page';
page.textContent = String(e.page);
row.append(text, leader, page);
const pos = heads[i]?.pos;
if (pos != null) {
row.addEventListener('mousedown', ev => {
ev.preventDefault();
ev.stopPropagation();
this.goTo(pos);
});
}
this.dom.appendChild(row);
});
this.fillLeaders();
}
// As many leader dots as the gap holds. The row clips the rest on screen, but nothing
// else does: 200 of them reach the PDF, the clipboard and every measurement as text.
private fillLeaders(): void {
const leaders = Array.from(this.dom.querySelectorAll<HTMLElement>('.toc-leader'));
if (!leaders.length) return;
const fill = leaders[0].textContent?.[0];
if (!fill) return;
leaders[0].textContent = fill.repeat(LEADER_PROBE);
const range = document.createRange();
range.selectNodeContents(leaders[0]);
const one = range.getBoundingClientRect().width / LEADER_PROBE;
if (!(one > 0)) return;
for (const el of leaders) {
el.textContent = fill.repeat(Math.max(0, Math.floor(el.getBoundingClientRect().width / one)));
}
}
// Scroll the heading into view and drop the cursor into it.
private goTo(pos: number): void {
const dom = this.editor.view.nodeDOM(pos) as HTMLElement | null;
dom?.scrollIntoView({ behavior: 'smooth', block: 'start' });
this.editor.chain().focus().setTextSelection(pos + 1).run();
}
private node(): PMNode | null {
const pos = this.getPos();
return typeof pos === 'number' ? this.editor.state.doc.nodeAt(pos) : null;
}
private syncAttr(entries: TocEntry[]): void {
const pos = this.getPos();
if (typeof pos !== 'number') return;
const node = this.editor.state.doc.nodeAt(pos);
if (!node || node.type.name !== 'tableOfContents') return;
if (JSON.stringify(node.attrs.entries ?? []) === JSON.stringify(entries)) return;
const tr = this.editor.state.tr.setNodeAttribute(pos, 'entries', entries).setMeta('addToHistory', false);
this.editor.view.dispatch(tr);
}
update(node: PMNode): boolean {
if (node.type.name !== 'tableOfContents') return false;
this.schedule();
return true;
}
// Own the mouse on entry rows (navigation) but let clicks on the TOC background reach
// ProseMirror so the block can be selected + deleted.
stopEvent(event: Event): boolean {
return event.type.startsWith('mouse') && !!(event.target as HTMLElement)?.closest?.('.toc-entry');
}
ignoreMutation(): boolean {
return true;
}
destroy(): void {
this.paper?.removeEventListener('pm-pagecount', this.onPageCount);
}
}