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:
1 parent
1aa74c0a9f
commit
bbbe72bd22
13 files changed
+286
-22
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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 } };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user