feat: a note renders in the file's own style, marker included

The marker moves from a CSS ::before to a widget decoration: generated
content is no text node, so it reached neither the PDF nor a measurement.
The note also carries the paragraph style its file names, so font, size and
colour are the document's; only the hanging indent stays the editor's,
because the style model has no first-line half to pair with the margin.
Adds a unit test for the numbering and the settings, plus the architecture
doc and the two open parity findings.
This commit is contained in:
Steffen Becker committed 2026-08-13 12:17:45 +00:00
1 parent 1aa74c0a9f
commit bbbe72bd22
13 files changed
+286 -22

No files matched your search

+1 -1
View File
@@ -68,6 +68,7 @@
- Special characters picker
- Date and time fields: picker with 7 date and 4 time formats (live samples) and an "update automatically" toggle — fixed fields keep the inserted moment, auto fields refresh on open. Round-trips to ODF `text:date`/`text:time` (minted `number:date/time-style`) and DOCX `DATE`/`TIME` fields; the field carries the surrounding font
- Table of contents: generated from headings (H1–H5) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field
- Footnotes and endnotes (Ctrl+Alt+F / Ctrl+Alt+D): a footnote is drawn at the foot of the page its anchor sits on, with the separator line above it and the body text moved up to make room; endnotes are collected on their own page at the document end. Notes renumber themselves as anchors move, and deleting an anchor deletes its note. An options dialog covers numbering format, start value, restart, position, prefix/suffix, the two styles and the separator's length, thickness, spacing, alignment and colour. Round-trips to ODF `text:note` + `text:notes-configuration` and to Word's `word/footnotes.xml`/`endnotes.xml` + `w:footnotePr`
- Formulas: dialog with a LaTeX field and live preview; only the LaTeX is stored, the MathML the browser typesets, the ODF formula object and the OMML are derived from it. Inline or as a centered display line, double-click to edit. Round-trips as a real embedded ODF formula object (`draw:object` + `Formula{n}/content.xml`, our LaTeX kept in the MathML `annotation`) and as Word's `m:oMath`. STIX Two Math is bundled, so stretched brackets and ∑/∫ look the same on every platform
- Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF `text:a` round-trip
- Manual page break (Ctrl+Enter); round-trips to ODF `fo:break-before`
@@ -115,7 +116,6 @@
The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
**Content an imported document loses**
- Footnotes / endnotes: dropped on import with a warning (`text:note`, `w:footnotes`), and there is no way to add one
- Comments / annotations: dropped on import (`office:annotation`, `w:comment`)
- Track changes / revisions: no recording, no accept/reject, no author colors (`text:tracked-changes`, `w:ins`/`w:del`); imported revisions are flattened to their current state
- Bookmarks and cross-references: no anchor, no reference field ("see chapter 3 on page 7"), none survive an import — this also blocks internal hyperlinks
+1
View File
@@ -107,4 +107,5 @@ Directory-level `CLAUDE.md` files load automatically when you touch that directo
| Table extensions + table styles | `docs/architecture/tables.md` |
| Text effects, indents, tab stops, list markers, date fields | `docs/architecture/formatting.md` |
| Formulas (LaTeX model, MathML, OMML, the math font) | `docs/architecture/formulas.md` |
| Footnotes and endnotes (model, page-foot reservation) | `docs/architecture/notes.md` |
| Headless browser + PDF-export debugging | `docs/headless-testing.md` |
+134
View File
@@ -0,0 +1,134 @@
# Footnotes and endnotes
Covers `editor/extensions/notes.ts`, the reservation loop in `pageBreaks.ts`, the
`storage/noteSettings.ts` model, and the ODF/DOCX halves in `export/` and `import/`.
## Model
Three nodes. The anchor rides the text, the note text is a **block**, and every note
lives in the one `noteSection` at the document end (`Document` is
`'(block | textBox | columns)+ noteSection?'`).
| Node | Content | Attrs |
|---|---|---|
| `noteRef` | inline atom | `id`, `kind`, `text` (the resolved label, cached like a Word field's result) |
| `note` | `inline*` | `id`, `kind`, `label` (a mark the file set by hand), `text`, `styleName` |
| `noteSection` | `note+` | — |
**Why the note text is a top-level block and not inline content of its anchor.** Only
then does its DOM hang directly under `.tiptap`, which is `position: relative` — the same
document-px space `pageBreaks.ts` and `.band-layer` already compute in. Inline in the
paragraph, the containing block would be that paragraph, whose own `position: relative`
(the line-height shift) would have to be defeated with an `!important` special case, the
way `editor.css` does for a page-anchored frame. It also makes **endnotes nearly free**:
they simply stay in the flow at the document end.
A note holds one paragraph of inline content. An imported note of several paragraphs is
flattened to hard breaks, exactly as `convertHfZone` does for a header/footer zone.
**The note's own marker is a widget decoration, not generated content.** A CSS `::before`
draws no text node, so it reaches neither the PDF, the clipboard nor a `Range`
measurement — the index draws its leader dots as real characters for the same reason. It
is not document content either: there is nothing to edit, delete or serialize.
**A note carries the file's own paragraph style** as `styleName` → `data-style`, so the
document stylesheet gives it that file's font, size and colour; `editor.css` supplies
LibreOffice's 10pt only for a note that names none. Its **indent** is the exception: a
note style declares a hanging pair (`fo:margin-left` with a negative `fo:text-indent`) and
`ParaProps` has no first-line half, so applying the margin alone would move every note
right by the indent. The hanging pair therefore stays the editor's — see
`tests/render-parity/FINDINGS.md`.
**The sync plugin** (`appendTransaction`) keeps one note per anchor, in anchor order, and
renumbers each class. It runs in two rounds by design — structure first, numbering on the
pass after — because doing both at once would need positions out of a document the same
transaction is still rewriting. An anchor pasted twice gets a fresh id and a clone of the
note it was copied from; an anchor that ends up *inside* a note is dropped, and inserting
one there is refused (LibreOffice and Word refuse it too). A settings change is no
document change, so the dialog's path carries the `RESYNC_NOTES` meta.
## Placement (`pageBreaks.ts`)
A footnote is out of the flow (`position: absolute`, `left`/`right` = the page margins)
and `pageBreaks.ts` gives it its `top`. Endnotes stay in flow and the first one carries
`forceBreakBefore`, because LibreOffice starts the endnote list on a new page (probed).
The reservation runs **inside** one pagination pass — reading it back across passes would
break the file's own rule that a pass must not read its own last answer:
1. `collectLeaves` skips footnote notes and walks the section for the endnotes.
2. Each footnote's height is measured once: out of flow at a fixed width, it does not
depend on the page it lands on.
3. `placeLeaves(reserved)` takes a per-page reservation off each `contentEnd`. A bounded
loop (`MAX_NOTE_FIT_PASSES` = 3) re-runs it until the reservation and the pages its own
anchors land on agree.
4. The anchor's page comes from the leaf that holds it plus the breaks above it: every
break carries the leaf offset it applies from (`naturalY`), so an anchor below a line
split follows its half of the paragraph onto the next page.
5. `prevPlacementsKey` includes the note tops, or a frozen layout would leave every
footnote on the page it used to belong to.
**Deliberate ceiling:** a note is never split across pages. A page whose notes outgrow its
content area keeps one line of body text and lets them overflow. Splitting the note, as
LibreOffice does, is the upgrade path (`ponytail:` comment at the cap).
## LibreOffice's defaults, as probed
Read out of a document `soffice` saved from a two-note source. These are the editor's
defaults — an editor-only default would land in every imported file as direct formatting.
| | value |
|---|---|
| Footnote numbering | `1, 2, 3`, start 1, **restart: document-wide** (not per page), position: bottom of page |
| Endnote numbering | `i, ii, iii`, start 1 |
| `Footnote` / `Endnote` style | parent `Standard`, 10pt, `fo:margin-left` 0.6cm with `fo:text-indent` −0.6cm (hanging) |
| `Footnote anchor` / `Endnote anchor` | `style:text-position="super 58%"` |
| `Footnote Symbol` / `Endnote Symbol` | empty — the note's own marker is plain text on the baseline, only the anchor is raised |
| `style:footnote-sep` | width 0.5pt, `rel-width` 25%, `adjustment` left, solid, `#000000`, 0.1cm above and below |
Rendered geometry from the same probe: the footnote area is carved out of the **bottom of
the content area**, above the bottom margin; the endnote list starts at the content top of
a fresh page.
**The separator is drawn a pixel wider than it measures.** 0.5pt is 0.66px, which Chromium
rounds away to nothing, so `editor.css` paints it at `max(1px, …)`. The reservation still
uses the true weight — the same thing a hairline border does.
The three separator lengths are registered with `@property … syntax: '<length>'`, so
`getComputedStyle` resolves them to px for the reservation; an unregistered custom
property reads back as the literal `"0.1cm"`. The dialog writes all six onto `:root`.
## ODF (`export/odt.ts`, `import/odt.ts`)
Export follows the text-box hoist: the anchor becomes an `FNT A{i}` sentinel run
(U+E017) and the note is re-emitted as its own top-level paragraph opening with
`FNT B{i}`, so its runs ride **every** existing pass — marks, character styles, links,
bookmarks, fields and formulas included. `applyNotes` runs last of the content.xml passes
and cuts each region back out into
```xml
<text:note text:id="ftn1" text:note-class="footnote">
<text:note-citation>1</text:note-citation>
<text:note-body><text:p text:style-name="Footnote">…</text:p></text:note-body>
</text:note>
```
`rewriteStylesXml` writes the six named styles, a `<text:notes-configuration>` per class,
and `<style:footnote-sep>` into the page layout. ODF counts `text:start-value` from 0, so
the first note carries `startAt - 1`. Import mirrors all of it;
`StyleResolver.noteSettings()` reads the configuration back.
## DOCX (`export/docx.ts`, `import/docx.ts`)
The `docx` package carries both parts natively (`footnotes`/`endnotes` maps plus
`FootnoteReferenceRun`/`EndnoteReferenceRun`), so no post-pack pass is needed for the
notes themselves — each class counts from 1, because each has its own part. Import parses
`word/footnotes.xml` and `word/endnotes.xml`, **skipping the entries that carry a
`w:type`**: those are Word's separator and continuation-separator notes, referenced from
nowhere. Word opens a note with its own marker run and a tab; the leading tab is stripped,
since the editor draws the marker and the indent from the note's own style.
The numbering settings ride `<w:footnotePr>`/`<w:endnotePr>` in `word/settings.xml` — a
post-pack pass beside `applyMirrorMarginsDocx`, as the package exposes neither. Word's
`eachSect` is ODF's `chapter`; the separator is not Word's to describe (it draws a fixed
one), so it keeps the editor's.
+2 -1
View File
@@ -3,7 +3,7 @@
One file per feature (`image.ts`, `indent.ts`, …), never `XyzExtension.ts`. All are assembled in
`../extensions.ts`, which also lists the TipTap built-ins in use (Bold, Italic, Highlight
`multicolor`, TextStyle/FontFamily/FontSize, Heading, HardBreak, lists, Table family, History,
Placeholder, TextAlign). `Document` is widened to `'(block | textBox | columns)+'`.
Placeholder, TextAlign). `Document` is widened to `'(block | textBox | columns)+ noteSection?'`.
Five topics are large enough to have their own deep-dive — read the file before changing them:
@@ -14,6 +14,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
| `table*.ts`, `tableStyle.ts` | `docs/architecture/tables.md` |
| `textEffects.ts`, `indent.ts`, `tabStops.ts`, `listMarker.ts`, `paragraphBox.ts`, `dateTimeField.ts`, `bookmark.ts`, `crossReference.ts` | `docs/architecture/formatting.md` |
| `formula.ts` | `docs/architecture/formulas.md` |
| `notes.ts` | `docs/architecture/notes.md` |
| `paragraphStyle.ts`, `characterStyle.ts` | `src/lib/styles/CLAUDE.md` |
## The rest, one line each
+32 -3
View File
@@ -1,5 +1,6 @@
import { Extension, Node, mergeAttributes } from '@tiptap/core';
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { EditorState, Transaction } from '@tiptap/pm/state';
import type { Node as PMNode } from '@tiptap/pm/model';
import { DEFAULT_SHORTCUTS } from '../shortcuts';
@@ -14,8 +15,6 @@ import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteSettings } from '../../s
export type { NoteKind };
const KINDS: NoteKind[] = ['footnote', 'endnote'];
const readKind = (v: unknown): NoteKind => (v === 'endnote' ? 'endnote' : 'footnote');
let idCounter = 0;
@@ -107,12 +106,20 @@ export const Note = Node.create({
parseHTML: (el) => el.getAttribute('data-note-label'),
renderHTML: (attrs) => (attrs.label ? { 'data-note-label': String(attrs.label) } : {}),
},
// The marker drawn in front of the text, via editor.css's ::before.
// The marker drawn in front of the text, by the widget decoration below.
text: {
default: '',
parseHTML: (el) => el.getAttribute('data-note-num') ?? '',
renderHTML: (attrs) => ({ 'data-note-num': String(attrs.text ?? '') }),
},
// The file's own note paragraph style, rendered as data-style like any block's:
// the document stylesheet then gives the note that file's size and indent, and
// editor.css only supplies LibreOffice's for a note that names none.
styleName: {
default: null as string | null,
parseHTML: (el) => el.getAttribute('data-style'),
renderHTML: (attrs) => (attrs.styleName ? { 'data-style': String(attrs.styleName) } : {}),
},
};
},
@@ -289,6 +296,28 @@ export const Notes = Extension.create<NotesOptions>({
key: notesKey,
props: {
// The note's own marker, as a widget rather than generated content: real text
// reaches the PDF, the clipboard and every measurement, which is why the index
// draws its leader dots the same way. Not document content — nothing to edit,
// delete or serialize.
decorations(state) {
const section = findNoteSection(state.doc);
if (!section) return null;
const decos: Decoration[] = [];
section.node.forEach((child, offset) => {
const label = String(child.attrs.text ?? '');
if (!label) return;
decos.push(Decoration.widget(section.pos + offset + 2, () => {
const el = document.createElement('span');
el.className = 'note-marker';
el.setAttribute('contenteditable', 'false');
el.textContent = label;
return el;
}, { side: -1, key: `nm:${child.attrs.id}:${label}` }));
});
return DecorationSet.create(state.doc, decos);
},
// Clicking an anchor jumps to its note, the way the TOC's rows jump to their
// heading. A note's own marker jumps nowhere — it is where you already are.
handleClickOn(view, _pos, node) {
+2 -1
View File
@@ -47,4 +47,5 @@ Sentinel order matters — the passes assume it. Full list of sentinels in use:
U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles),
`CST` U+E00E (character styles), `TEF` U+E00F (text effects), `PBX` (paragraph boxes), `SEC` U+E010
(section breaks), `LEAD` U+E011 (a tab stop's leader, inside `style:type`), `MTH` U+E012 (formulas),
`BMS`/`BME` U+E013/U+E014 (a bookmark's range), `XRF` U+E015 (cross-references).
`BMS`/`BME` U+E013/U+E014 (a bookmark's range), `XRF` U+E015 (cross-references),
`CHP` U+E016 (the chapter field), `FNT` U+E017 (footnotes/endnotes).
+5 -3
View File
@@ -639,14 +639,14 @@ function replaceTextBoxes(doc: TiptapNode, boxes: TextBoxExport[]): TiptapNode {
}
// One footnote/endnote, collected by replaceNotes and emitted by applyNotes.
type NoteExport = { kind: 'footnote' | 'endnote'; citation: string; label: string | null };
type NoteExport = { kind: 'footnote' | 'endnote'; citation: string; label: string | null; styleName: string | null };
// Anchors become an FNT A{i} sentinel run in the running text; the note section is
// dissolved and each note re-emitted as its own top-level paragraph opening with an
// FNT B{i} sentinel. Hoisted like a text box, so the note text rides every existing
// pass — marks, character styles, links, bookmarks, fields and formulas all included.
function replaceNotes(doc: TiptapNode, notes: NoteExport[]): TiptapNode {
const bodies = new Map<string, { content: TiptapNode[]; label: string | null }>();
const bodies = new Map<string, { content: TiptapNode[]; label: string | null; styleName: string | null }>();
const body: TiptapNode[] = [];
for (const child of doc.content ?? []) {
if (child.type !== 'noteSection') { body.push(child); continue; }
@@ -655,6 +655,7 @@ function replaceNotes(doc: TiptapNode, notes: NoteExport[]): TiptapNode {
bodies.set(String(a.id ?? ''), {
content: note.content ?? [],
label: typeof a.label === 'string' && a.label ? a.label : null,
styleName: typeof a.styleName === 'string' && a.styleName ? a.styleName : null,
});
}
}
@@ -675,6 +676,7 @@ function replaceNotes(doc: TiptapNode, notes: NoteExport[]): TiptapNode {
kind: a.kind === 'endnote' ? 'endnote' : 'footnote',
citation: String(a.text ?? ''),
label: bodies.get(id)?.label ?? null,
styleName: bodies.get(id)?.styleName ?? null,
});
return { type: 'text', text: `${FNT}A${i}${FNT}` };
}),
@@ -3461,7 +3463,7 @@ function applyNotes(odtBytes: Uint8Array, notes: NoteExport[]): Uint8Array {
const label = note.label ? ` text:label="${escapeXml(note.label)}"` : '';
return `<text:note text:id="${endnote ? 'edn' : 'ftn'}${i + 1}" text:note-class="${note.kind}">`
+ `<text:note-citation${label}>${escapeXml(note.citation)}</text:note-citation>`
+ `<text:note-body><text:p text:style-name="${endnote ? 'Endnote' : 'Footnote'}">${bodies[i]}</text:p></text:note-body>`
+ `<text:note-body><text:p text:style-name="${odfStyleName(note.styleName ?? (endnote ? 'Endnote' : 'Footnote'))}">${bodies[i]}</text:p></text:note-body>`
+ '</text:note>';
});
// Defensive: strip any sentinel the passes above left behind.
+2 -1
View File
@@ -31,7 +31,8 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame.
- **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text.
- **A hoisted text box keeps what its anchor paragraph gave it**: an as-char figure frame is centred by the paragraph it sits in, so `wrapAlign` carries that (or the frame's own `style:horizontal-pos`) onto the box. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default.
- **Graceful degradation** (collected as `warnings`, surfaced once): footnotes/comments dropped, hyperlinks flattened to text, nested tables flattened to paragraphs, non-`Pictures` drawings dropped, unreadable pictures skipped (each with a warning). Page size is normalized to A4. Floating/wrapped images are imported as left/right/top-bottom wrap (free x/y positions collapse to the nearest side; see `image.ts`).
- **Footnotes and endnotes** round-trip: `text:note` (ODF) and `word/footnotes.xml`/`endnotes.xml` (DOCX) become a `noteRef` anchor plus a `note` in the document-final `noteSection`, and the numbering configuration becomes `NoteSettings`. See `docs/architecture/notes.md`.
- **Graceful degradation** (collected as `warnings`, surfaced once): comments dropped, hyperlinks flattened to text, nested tables flattened to paragraphs, non-`Pictures` drawings dropped, unreadable pictures skipped (each with a warning). Page size is normalized to A4. Floating/wrapped images are imported as left/right/top-bottom wrap (free x/y positions collapse to the nearest side; see `image.ts`).
`tests/roundtrip.test.ts` (export→import) and `tests/lo-roundtrip.test.ts` (export→LibreOffice re-save→import, needs `soffice`) verify the round trip; run with `npm test` / `npm run test:lo`.
+10 -3
View File
@@ -67,7 +67,7 @@ type Ctx = {
// word/footnotes.xml and endnotes.xml by w:id, and the notes the body referenced, in
// anchor order — the editor keeps them in one section at the document end (notes.ts).
noteParts: Record<NoteKind, Map<string, Element>>;
notes: { id: string; kind: NoteKind; text: string; content: Node[] }[];
notes: { id: string; kind: NoteKind; text: string; content: Node[]; styleName: string | null }[];
};
// The real notes of a part, by w:id: Word's own separator entries carry a w:type and
@@ -198,7 +198,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
if (ctx.notes.length) {
blocks.push({ type: 'noteSection', content: ctx.notes.map((n) => ({
type: 'note',
attrs: { id: n.id, kind: n.kind, label: null, text: n.text },
attrs: { id: n.id, kind: n.kind, label: null, text: n.text, styleName: n.styleName },
...(n.content.length ? { content: n.content } : {}),
})) });
}
@@ -1067,7 +1067,14 @@ function noteRefNode(wid: string | null, kind: NoteKind, ctx: Ctx, baseRun: RunP
const seen = ctx.notes.filter((n) => n.kind === kind).length;
const text = formatOrdinal(seen + 1, kind === 'endnote' ? 'i' : '1');
const id = `${kind}${wid}`;
ctx.notes.push({ id, kind, text, content });
// The note renders at the file's own size and indent: its first paragraph names the
// style (Word's FootnoteText), and collectStyleSheet only keeps a style in use.
const firstPara = Array.from(note.children).find((c) => c.namespaceURI === W && c.localName === 'p');
const pStyle = fc(fc(firstPara ?? null, 'pPr'), 'pStyle');
const styleId = pStyle ? wVal(pStyle) : null;
const styleName = styleId ? ctx.styleNames.get(styleId) ?? null : null;
if (styleId && styleName) ctx.usedStyles.add(styleId);
ctx.notes.push({ id, kind, text, content, styleName });
return { type: 'noteRef', attrs: { id, kind, text } };
}
+20 -4
View File
@@ -119,7 +119,7 @@ type Ctx = {
openBookmarks: Set<string>;
// Footnotes/endnotes in anchor order: the file stores each note's text at its anchor,
// the editor keeps them in one section at the document end (notes.ts).
notes: { id: string; kind: NoteKind; label: string | null; text: string; content: Node[] }[];
notes: { id: string; kind: NoteKind; label: string | null; text: string; content: Node[]; styleName: string | null }[];
};
// Read a Pictures/ entry into a base64 data-URI; null when it's missing or in a format
@@ -610,7 +610,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
if (ctx.notes.length) {
blocks.push({ type: 'noteSection', content: ctx.notes.map((n) => ({
type: 'note',
attrs: { id: n.id, kind: n.kind, label: n.label, text: n.text },
attrs: { id: n.id, kind: n.kind, label: n.label, text: n.text, styleName: n.styleName },
...(n.content.length ? { content: n.content } : {}),
})) });
}
@@ -1537,6 +1537,19 @@ function convertDateTimeField(e: Element, ctx: Ctx): Node | null {
return { type: 'dateTimeField', attrs: { kind, format, fixed, value: fieldValue(kind, raw) } };
}
// The note's paragraph style: the one its own <text:p> names, else the class's
// configured default. Registered as used, or collectStyleSheet drops it.
function noteBodyStyle(e: Element, ctx: Ctx, kind: NoteKind): string | null {
const body = e.getElementsByTagNameNS(NS.text, 'note-body')[0] ?? null;
const own = Array.from(body?.children ?? []).find((c) => c.localName === 'p' || c.localName === 'h');
const raw = own?.getAttributeNS(NS.text, 'style-name')
?? ctx.resolver.noteSettings()[kind].bodyStyle.replace(/ /g, '_20_');
const named = ctx.resolver.namedAncestor(raw, 'paragraph');
if (!named) return null;
ctx.usedStyles.add(named);
return ctx.styleNames.get(named) ?? named.replace(/_20_/g, ' ');
}
// <text:note> → the anchor node, with the note's own text collected into ctx for the
// section the document end gets. A body of several paragraphs is flattened to hard
// breaks — the editor's note holds one paragraph, as convertHfZone does for a zone.
@@ -1553,9 +1566,12 @@ function convertNote(e: Element, ctx: Ctx, defaults: BlockDefaults): Node | null
if (content.length) content.push({ type: 'hardBreak' });
content.push(...convertInline(para, ctx, {}, defaults));
}
// The file's own id is unique within it, so it is the anchor's id too.
// The file's own id is unique within it, so it is the anchor's id too. The note takes
// the paragraph style its notes-configuration names, so it renders at the file's own
// size and indent rather than at LibreOffice's.
const id = e.getAttributeNS(NS.text, 'id') || `${kind}${ctx.notes.length + 1}`;
ctx.notes.push({ id, kind, label, text: shown, content });
const styleName = noteBodyStyle(e, ctx, kind);
ctx.notes.push({ id, kind, label, text: shown, content, styleName });
return { type: 'noteRef', attrs: { id, kind, text: shown } };
}
+1
View File
@@ -32,6 +32,7 @@ Both margins and orientation are passed into the ODT export so the exported docu
- **Recent fonts:** `odf-editor-recent-fonts` — JSON string array (ToolbarExpanded).
- **Header/footer:** `odf-editor-header` / `-footer` (HfDoc), `odf-editor-hf-distances`.
- **Styles:** `odf-editor-styles` — the style registry (`styles/sheet.svelte.ts`).
- **Notes:** `odf-editor-notes` — footnote/endnote numbering and the separator (`noteSettings.ts`, reactive singleton in `notes.svelte.ts`); `applyNoteVars` puts the separator on `:root`, where `editor.css` draws it and `pageBreaks.ts` reserves its band.
## Themes (`theme.ts`, `styles/global.css`)
+15 -5
View File
@@ -292,21 +292,31 @@
cursor: pointer;
}
/* Padding, not a margin, for the hanging indent: the box then still spans the whole
text width, which is what the separator's 25% is measured against. */
text width, which is what the separator's 25% is measured against. LibreOffice's own
Footnote style, and only for a note whose file names none of its own. */
.paper .tiptap .note {
font-size: 10pt;
padding-left: 0.6cm;
text-indent: -0.6cm;
}
.paper .tiptap .note:not([data-style]) {
font-size: 10pt;
}
/* The note's own marker is plain text at the baseline — LibreOffice's Footnote Symbol
style carries nothing, only the anchor is raised. An inline-block the width of the
hanging indent stands in for the tab LibreOffice puts after it. */
.paper .tiptap .note::before {
content: attr(data-note-num);
.paper .tiptap .note-marker {
display: inline-block;
width: 0.6cm;
min-width: 0.6cm;
text-indent: 0;
}
/* A note style declares its indent as a hanging pair (fo:margin-left with a negative
fo:text-indent) and the style model has no first-line half, so its margin alone would
just push the note right. The hanging pair stays LibreOffice's; the font, size and
colour are the file's. */
.paper .tiptap .note[data-style] {
margin-left: 0;
margin-right: 0;
}
/* A footnote is lifted out of the flow to the foot of its anchor's page; pageBreaks.ts
sets `top` in document px and marks the first note of each page. The margin custom
properties are .tiptap's own, so the box spans exactly the text width. */
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { noteLabel, noteLabels, type NoteRefInfo } from '../../src/lib/editor/extensions/notes';
import { clampNoteSettings, DEFAULT_NOTE_SETTINGS, type NoteSettings } from '../../src/lib/storage/noteSettings';
const REF = (id: string, kind: 'footnote' | 'endnote'): NoteRefInfo => ({ id, kind, pos: 0 });
const withFootnote = (over: Partial<NoteSettings['footnote']>): NoteSettings =>
({ ...DEFAULT_NOTE_SETTINGS, footnote: { ...DEFAULT_NOTE_SETTINGS.footnote, ...over } });
describe('note numbering', () => {
it('follows LibreOffice by default: footnotes arabic, endnotes lower roman', () => {
const s = DEFAULT_NOTE_SETTINGS;
expect([0, 1, 2].map((i) => noteLabel(i, 'footnote', s))).toEqual(['1', '2', '3']);
expect([0, 1, 2].map((i) => noteLabel(i, 'endnote', s))).toEqual(['i', 'ii', 'iii']);
});
it('renders all five ODF formats', () => {
const at = (f: NoteSettings['footnote']['numFormat']) =>
[0, 1, 2].map((i) => noteLabel(i, 'footnote', withFootnote({ numFormat: f })));
expect(at('1')).toEqual(['1', '2', '3']);
expect(at('a')).toEqual(['a', 'b', 'c']);
expect(at('A')).toEqual(['A', 'B', 'C']);
expect(at('i')).toEqual(['i', 'ii', 'iii']);
expect(at('I')).toEqual(['I', 'II', 'III']);
});
it('counts from the start value and wraps the number in prefix/suffix', () => {
const s = withFootnote({ startAt: 4, prefix: '[', suffix: ']' });
expect([0, 1].map((i) => noteLabel(i, 'footnote', s))).toEqual(['[4]', '[5]']);
});
it('keeps a note the file numbered by hand', () => {
expect(noteLabel(0, 'footnote', DEFAULT_NOTE_SETTINGS, '*')).toBe('*');
});
it('counts the two classes separately, in anchor order', () => {
const refs = [REF('a', 'footnote'), REF('b', 'endnote'), REF('c', 'footnote'), REF('d', 'endnote')];
const labels = noteLabels(refs, DEFAULT_NOTE_SETTINGS);
expect([...labels.values()]).toEqual(['1', 'i', '2', 'ii']);
});
});
describe('note settings', () => {
it('falls back to the defaults for anything a stored value gets wrong', () => {
const s = clampNoteSettings({ footnote: { numFormat: 'z', startAt: -3, restart: 'nonsense' }, separator: { relWidthPercent: 500 } });
expect(s.footnote.numFormat).toBe('1');
expect(s.footnote.startAt).toBe(1);
expect(s.footnote.restart).toBe('document');
expect(s.separator.relWidthPercent).toBe(100);
});
it('keeps values that are in range', () => {
const s = clampNoteSettings({ ...DEFAULT_NOTE_SETTINGS, footnote: { ...DEFAULT_NOTE_SETTINGS.footnote, numFormat: 'I', startAt: 7, restart: 'page' } });
expect(s.footnote).toMatchObject({ numFormat: 'I', startAt: 7, restart: 'page' });
});
it('survives a garbage payload whole', () => {
expect(clampNoteSettings(null)).toEqual(DEFAULT_NOTE_SETTINGS);
expect(clampNoteSettings('nope')).toEqual(DEFAULT_NOTE_SETTINGS);
});
});