feat: a table of contents keeps the heading its file gave it
A German document's "Inhalt" came back as "Table of Contents" — the node carried no title of its own, so the generated one always won. Now the imported heading rides on the node and goes back out with it.
This commit is contained in:
1 parent
f62559707c
commit
b217de06c1
5 files changed
+54
-20
No files matched your search
@@ -13,6 +13,8 @@ export type TocEntry = { text: string; level: number; page: number };
|
||||
// heading is its offsetTop within .tiptap (spacers included) divided by the cycle.
|
||||
const PAGE_GAP = 20;
|
||||
const FALLBACK_PAGE_HEIGHT = 1123;
|
||||
// The heading above the entries. An imported TOC keeps the one its file used
|
||||
// ("Inhalt", "Sommaire", …); a freshly inserted one takes this.
|
||||
const TITLE = 'Table of Contents';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
@@ -32,6 +34,11 @@ export const TableOfContents = Node.create({
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
title: {
|
||||
default: null as string | null,
|
||||
parseHTML: el => (el as HTMLElement).getAttribute('data-toc-title') || null,
|
||||
renderHTML: attrs => (attrs.title ? { 'data-toc-title': String(attrs.title) } : {}),
|
||||
},
|
||||
entries: {
|
||||
default: [] as TocEntry[],
|
||||
parseHTML: el => {
|
||||
@@ -47,8 +54,8 @@ export const TableOfContents = Node.create({
|
||||
return [{ tag: 'div[data-toc]' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-toc': 'true' }), TITLE];
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-toc': 'true' }), tocTitle(node.attrs.title)];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
@@ -65,6 +72,8 @@ export const TableOfContents = Node.create({
|
||||
},
|
||||
});
|
||||
|
||||
const tocTitle = (title: unknown): string => (typeof title === 'string' && title ? title : TITLE);
|
||||
|
||||
type HeadingRef = { text: string; level: number; pos: number };
|
||||
|
||||
// Node view: renders the title + one clickable row per heading. Recomputes on each
|
||||
@@ -155,7 +164,7 @@ class TocView {
|
||||
this.dom.textContent = '';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'toc-title';
|
||||
title.textContent = TITLE;
|
||||
title.textContent = tocTitle(this.node()?.attrs?.title);
|
||||
this.dom.appendChild(title);
|
||||
|
||||
if (entries.length === 0) {
|
||||
@@ -197,6 +206,11 @@ class TocView {
|
||||
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;
|
||||
|
||||
@@ -843,8 +843,9 @@ function blocksToDocx(content: TiptapNode[], num: Numbering, contentWidthCm: num
|
||||
// A real, recognized TOC field (levels 1–3, hyperlinked); Word/LibreOffice populate
|
||||
// + link it on field update (features.updateFields does this on open). Title is a
|
||||
// plain bold paragraph so it isn't itself listed; our importer regenerates the node.
|
||||
out.push(new Paragraph({ children: [new TextRun({ text: 'Table of Contents', bold: true, size: 32 })], spacing: { after: cmToTwip(0.3) } }));
|
||||
out.push(new TableOfContents('Table of Contents', { hyperlink: true, headingStyleRange: `1-${MAX_HEADING_LEVEL}` }));
|
||||
const tocTitle = typeof node.attrs?.title === 'string' && node.attrs.title ? node.attrs.title : 'Table of Contents';
|
||||
out.push(new Paragraph({ children: [new TextRun({ text: tocTitle, bold: true, size: 32 })], spacing: { after: cmToTwip(0.3) } }));
|
||||
out.push(new TableOfContents(tocTitle, { hyperlink: true, headingStyleRange: `1-${MAX_HEADING_LEVEL}` }));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -547,7 +547,7 @@ function replaceColumns(doc: TiptapNode, cols: ColumnsExport[]): TiptapNode {
|
||||
// One generated table of contents, collected by replaceTableOfContents and emitted by
|
||||
// applyToc. Entries are the cached heading→page rows (the node view keeps them current).
|
||||
type TocEntry = { text: string; level: number; page: number };
|
||||
type TocExport = { entries: TocEntry[] };
|
||||
type TocExport = { entries: TocEntry[]; title: string | null };
|
||||
|
||||
// Swap each top-level tableOfContents node for a marker paragraph carrying the TOC
|
||||
// sentinel and collect its cached entries. Top-level only (like replacePageBreaks): a
|
||||
@@ -565,7 +565,8 @@ function replaceTableOfContents(doc: TiptapNode, tocs: TocExport[]): TiptapNode
|
||||
level: Math.min(MAX_HEADING_LEVEL, Math.max(1, Number(e.level) || 1)),
|
||||
page: Math.max(1, Number(e.page) || 1),
|
||||
}));
|
||||
tocs.push({ entries });
|
||||
const rawTitle = child.attrs?.title;
|
||||
tocs.push({ entries, title: typeof rawTitle === 'string' && rawTitle ? rawTitle : null });
|
||||
content.push({ type: 'paragraph', content: [{ type: 'text', text: `${TOC_SENT}${tocs.length - 1}${TOC_SENT}` }] });
|
||||
continue;
|
||||
}
|
||||
@@ -2758,8 +2759,8 @@ function contentsHeadingStyle(): string {
|
||||
// carry a right tab stop, page number, and link markers so LibreOffice rebuilds it as a
|
||||
// linking TOC) plus a cached index-body (title + one entry paragraph per heading).
|
||||
function tocXml(toc: TocExport, index: number): string {
|
||||
const title = 'Table of Contents';
|
||||
const name = `${title}${index + 1}`;
|
||||
const title = toc.title ?? 'Table of Contents';
|
||||
const name = `Table of Contents${index + 1}`;
|
||||
const source =
|
||||
`<text:table-of-content-source text:outline-level="${MAX_HEADING_LEVEL}" text:use-index-marks="false" text:use-index-source-styles="false">` +
|
||||
`<text:index-title-template text:style-name="Contents_20_Heading">${escapeXml(title)}</text:index-title-template>` +
|
||||
|
||||
+13
-1
@@ -293,6 +293,15 @@ function sdtIsToc(sdt: Element): boolean {
|
||||
return Array.from(content.getElementsByTagNameNS(W, 'instrText')).some(i => /\bTOC\b/.test(i.textContent ?? ''));
|
||||
}
|
||||
|
||||
// The heading Word caches above a TOC's entries ("Inhalt", "Sommaire", …): the control's
|
||||
// first paragraph, when it is plain text rather than part of the field itself.
|
||||
function tocHeading(content: Element | null): string | null {
|
||||
const first = content ? fcAll(content, 'p')[0] : null;
|
||||
if (!first || first.getElementsByTagNameNS(W, 'fldChar').length || first.getElementsByTagNameNS(W, 'instrText').length) return null;
|
||||
const text = (first.textContent ?? '').trim();
|
||||
return text && text.length <= 60 ? text : null;
|
||||
}
|
||||
|
||||
function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDefault = false): Node[] {
|
||||
const out: Node[] = [];
|
||||
const stack: { ilvl: number; numId: number; list: Node }[] = [];
|
||||
@@ -387,7 +396,10 @@ function convertBlocks(children: Element[], ctx: Ctx, kind: BlockKind, boldByDef
|
||||
flush();
|
||||
if (sdtIsToc(el)) {
|
||||
// A content-control-wrapped TOC → one node (regenerated live); skip its content.
|
||||
if (kind === 'body') out.push({ type: 'tableOfContents', attrs: { entries: [] } });
|
||||
if (kind === 'body') {
|
||||
const title = tocHeading(fc(el, 'sdtContent'));
|
||||
out.push({ type: 'tableOfContents', attrs: { entries: [], ...(title ? { title } : {}) } });
|
||||
}
|
||||
} else {
|
||||
const content = fc(el, 'sdtContent');
|
||||
if (content) out.push(...convertBlocks(Array.from(content.children), ctx, kind, boldByDefault));
|
||||
|
||||
+16
-10
@@ -9,7 +9,7 @@ import { TABLE_REGIONS, tableLookAttr, type TableLook, type TableRegion } from '
|
||||
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
|
||||
import { matchFormat, toDateValue, type Token } from '../utils/dateTime';
|
||||
import { imageDataUrl, type ConvertedImages } from './imageFormats';
|
||||
import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats';
|
||||
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
|
||||
@@ -170,16 +170,20 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null {
|
||||
.filter(Boolean);
|
||||
if (!hrefs.length) return null;
|
||||
const href = hrefs.find(h => loadImageDataUrl(h, ctx)) ?? hrefs[0];
|
||||
const src = loadImageDataUrl(href, ctx);
|
||||
if (!src) {
|
||||
ctx.warnings.add(ctx.files[href]
|
||||
? 'Images in a format the browser can’t display (e.g. WMF, EMF, SVM, TIFF) were removed'
|
||||
: 'Some images could not be read and were skipped');
|
||||
return null;
|
||||
}
|
||||
const attrs: Record<string, unknown> = { src };
|
||||
let src = loadImageDataUrl(href, ctx);
|
||||
const wCm = lengthToCm(frame.getAttributeNS(NS.svg, 'width'));
|
||||
const hCm = lengthToCm(frame.getAttributeNS(NS.svg, 'height'));
|
||||
if (!src) {
|
||||
// The frame still occupies its box, so an undrawable picture comes in as a
|
||||
// placeholder of that size rather than collapsing the layout around it.
|
||||
if (wCm == null || hCm == null) {
|
||||
ctx.warnings.add('Some images could not be read and were skipped');
|
||||
return null;
|
||||
}
|
||||
ctx.warnings.add('Images in a format the browser can’t display (e.g. WMF, EMF, SVM) were replaced by a placeholder');
|
||||
src = placeholderImage('Image', cmToPx(wCm), cmToPx(hCm));
|
||||
}
|
||||
const attrs: Record<string, unknown> = { src };
|
||||
if (wCm != null) attrs.width = Math.round(cmToPx(wCm));
|
||||
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
|
||||
const title = frame.getElementsByTagNameNS(NS.svg, 'title')[0]?.textContent;
|
||||
@@ -641,7 +645,9 @@ function convertToc(el: Element): Node {
|
||||
if (text) entries.push({ text, level, page: Math.max(1, parseInt(page, 10) || 1) });
|
||||
}
|
||||
}
|
||||
return { type: 'tableOfContents', attrs: { entries } };
|
||||
// The file's own heading ("Inhalt", "Sommaire", …), so a reopened index keeps its name.
|
||||
const title = el.getElementsByTagNameNS(NS.text, 'index-title')[0]?.textContent?.trim();
|
||||
return { type: 'tableOfContents', attrs: { entries, ...(title ? { title } : {}) } };
|
||||
}
|
||||
|
||||
// Split a TOC entry paragraph around its last <text:tab/>: the text before it is the
|
||||
|
||||
Reference in new issue
Block a user