feat: footnotes and endnotes can be inserted and edited

The anchor is an inline atom, the note text a block in the one noteSection
at the document end; a sync plugin keeps notes in anchor order, renumbers
them per class and drops the ones whose anchor is gone. Numbering, indent,
size and separator defaults are LibreOffice's, read out of a document it
saved. Endnotes still render in flow at the document end — lifting a
footnote to the foot of its page is the next step.
This commit is contained in:
Steffen Becker committed 2026-08-13 09:22:17 +00:00
1 parent 9d494b2966
commit 0c74fe94b1
12 files changed
+672 -4

No files matched your search

+24
View File
@@ -1725,6 +1725,30 @@
<path d="M12.5 3.5h1.5M13.5 6.5H14M12 9.5h2M12.5 12.5H14" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
<button
onclick={() => editor?.chain().focus().insertNote('footnote').run()}
disabled={!!hfActive}
title={hfActive ? t().toolbarExpanded.noteNotInHf : `${t().toolbarExpanded.insertFootnote} (${shortcutHint('footnote')})`}
aria-label={t().toolbarExpanded.insertFootnote}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 2.5h12M2 5h12M2 7.5h7" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<path d="M2 11h4.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<path d="M2 13.5h9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
<button
onclick={() => editor?.chain().focus().insertNote('endnote').run()}
disabled={!!hfActive}
title={hfActive ? t().toolbarExpanded.noteNotInHf : `${t().toolbarExpanded.insertEndnote} (${shortcutHint('endnote')})`}
aria-label={t().toolbarExpanded.insertEndnote}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 2.5h12M2 5h12M2 7.5h7" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<path d="M2 10.5h12" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<path d="M2 13.5h9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
<div class="link-wrap" use:linkClickOutside>
<button
class:active={isLink}
+4
View File
@@ -106,6 +106,10 @@ export const STROKED = {
link: ['M6.6 9.4 9.4 6.6', 'M7.4 4.4 9 2.8a2.7 2.7 0 0 1 3.8 3.8l-1.6 1.6', 'M8.6 11.6 7 13.2a2.7 2.7 0 0 1-3.8-3.8l1.6-1.6'],
bookmark: ['M4 2.25h8v11.5L8 10.5l-4 3.25z'],
crossRef: ['M2.5 3.5h6M2.5 6.5h4M2.5 9.5h5', 'M8.5 12.5h5M11 10l2.5 2.5L11 15'],
// Body text over a short rule with one note line under it — the footnote area as it
// is drawn; the endnote's rule runs the full width, because the page ends there.
footnote: ['M2 2.5h12M2 5h12M2 7.5h7', 'M2 11h4.5', 'M2 13.5h9'],
endnote: ['M2 2.5h12M2 5h12M2 7.5h7', 'M2 10.5h12', 'M2 13.5h9'],
// The whole page with one zone filled in, so which end it is reads at a glance.
header: ['M3.5 1.75h9v12.5h-9z', 'M5.4 7.8h5.2M5.4 9.9h5.2M5.4 12h3.1'],
footer: ['M3.5 1.75h9v12.5h-9z', 'M5.4 4h5.2M5.4 6.1h5.2M5.4 8.2h3.1'],
@@ -5,6 +5,7 @@
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
import type { HfZone } from '../../../storage/headerFooter';
import { t } from '../../../i18n/i18n.svelte';
import { shortcutHint } from '../../../editor/shortcuts';
let { editor, tick, hfActive = null }: {
editor: Editor | null;
@@ -62,6 +63,24 @@
</div>
</RibbonGroup>
<RibbonGroup label={t().ribbon.groups.notes}>
<RibbonButton
variant="big"
icon="footnote"
label={t().ribbon.footnote}
title={hfActive ? t().toolbarExpanded.noteNotInHf : `${t().toolbarExpanded.insertFootnote} (${shortcutHint('footnote')})`}
disabled={!editor || !!hfActive}
onclick={() => editor?.chain().focus().insertNote('footnote').run()}
/>
<RibbonButton
icon="endnote"
label={t().ribbon.endnote}
title={hfActive ? t().toolbarExpanded.noteNotInHf : `${t().toolbarExpanded.insertEndnote} (${shortcutHint('endnote')})`}
disabled={!editor || !!hfActive}
onclick={() => editor?.chain().focus().insertNote('endnote').run()}
/>
</RibbonGroup>
<style>
.rb-menu-wrap { position: relative; }
</style>
+11 -2
View File
@@ -55,13 +55,15 @@ import { TextBox } from './extensions/textBox';
import { Columns } from './extensions/columns';
import { ColumnsFlow } from './extensions/columnsFlow';
import { TableOfContents } from './extensions/tableOfContents';
import { Note, NoteRef, NoteSection, Notes } from './extensions/notes';
import { Shortcuts } from './extensions/shortcuts';
import { styleSheet } from '../styles/sheet.svelte';
import { noteSettings } from '../storage/notes.svelte';
export const extensions = [
// textBox and columns have their own groups so only the document (not
// cells/lists/boxes) admits them.
Document.extend({ content: '(block | textBox | columns)+' }),
// cells/lists/boxes) admits them; the note section is last or nowhere.
Document.extend({ content: '(block | textBox | columns)+ noteSection?' }),
Paragraph,
Text,
Bold,
@@ -111,6 +113,13 @@ export const extensions = [
// Generated table of contents from headings; round-trips to ODF
// <text:table-of-content> (export/odt.ts, import/odt.ts) and a DOCX TOC field.
TableOfContents,
// Footnotes and endnotes: the anchor rides the text, the note text lives in the one
// noteSection at the document end. pageBreaks.ts lifts a footnote to the foot of its
// anchor's page. Round-trips to ODF <text:note> and DOCX word/footnotes.xml.
NoteRef,
Note,
NoteSection,
Notes.configure({ settings: () => noteSettings() }),
LineHeight,
ParagraphSpacing,
// Paragraph background ("colored field") + borders ("colored rule line"); round-trips
+390
View File
@@ -0,0 +1,390 @@
import { Extension, Node, mergeAttributes } from '@tiptap/core';
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import type { EditorState, Transaction } from '@tiptap/pm/state';
import type { Node as PMNode } from '@tiptap/pm/model';
import { DEFAULT_SHORTCUTS } from '../shortcuts';
import { formatOrdinal } from '../../utils/orderedListTypes';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteSettings } from '../../storage/noteSettings';
// Footnotes and endnotes. The anchor (`noteRef`) rides the text; the note text itself is
// a block in the single `noteSection` at the document end, kept in anchor order by the
// sync plugin below. Footnotes are lifted to the foot of their page by pageBreaks.ts,
// endnotes stay in flow — which is where LibreOffice puts them. See
// docs/architecture/notes.md.
export type { NoteKind };
const KINDS: NoteKind[] = ['footnote', 'endnote'];
const readKind = (v: unknown): NoteKind => (v === 'endnote' ? 'endnote' : 'footnote');
let idCounter = 0;
export function newNoteId(): string {
idCounter += 1;
return `n${Date.now().toString(36)}${idCounter.toString(36)}`;
}
// The label a note shows: its own custom mark where a file gave it one, else the
// running number in the class's own format, wrapped in the configured prefix/suffix.
export function noteLabel(index: number, kind: NoteKind, settings: NoteSettings, custom?: string | null): string {
if (custom) return custom;
const s = settings[kind];
return `${s.prefix}${formatOrdinal(s.startAt + index, s.numFormat)}${s.suffix}`;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
notes: {
insertNote: (kind: NoteKind) => ReturnType;
goToNote: (id: string) => ReturnType;
};
}
}
export const NoteRef = Node.create({
name: 'noteRef',
group: 'inline',
inline: true,
atom: true,
addAttributes() {
return {
id: {
default: '',
parseHTML: (el) => el.getAttribute('data-note-ref') || '',
renderHTML: (attrs) => ({ 'data-note-ref': String(attrs.id ?? '') }),
},
kind: {
default: 'footnote' as NoteKind,
parseHTML: (el) => readKind(el.getAttribute('data-note-kind')),
renderHTML: (attrs) => ({ 'data-note-kind': readKind(attrs.kind) }),
},
// The resolved label, cached like a Word field's result so a static render and
// the exports read the same number the node view shows.
text: {
default: '',
parseHTML: (el) => el.getAttribute('data-text') ?? el.textContent ?? '',
renderHTML: (attrs) => ({ 'data-text': String(attrs.text ?? '') }),
},
};
},
parseHTML() {
return [{ tag: 'sup[data-note-ref]' }];
},
renderHTML({ node, HTMLAttributes }) {
return ['sup', mergeAttributes(HTMLAttributes, { class: 'note-ref' }), String(node.attrs.text ?? '')];
},
renderText({ node }) {
return String(node.attrs.text ?? '');
},
});
export const Note = Node.create({
name: 'note',
content: 'inline*',
defining: true,
isolating: true,
addAttributes() {
return {
id: {
default: '',
parseHTML: (el) => el.getAttribute('data-note-id') || '',
renderHTML: (attrs) => ({ 'data-note-id': String(attrs.id ?? '') }),
},
kind: {
default: 'footnote' as NoteKind,
parseHTML: (el) => readKind(el.getAttribute('data-note-kind')),
renderHTML: (attrs) => ({ 'data-note-kind': readKind(attrs.kind) }),
},
// A note whose file numbered it by hand (ODF text:note-citation/@text:label,
// Word's w:customMarkFollows); null = the running number.
label: {
default: null as string | null,
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.
text: {
default: '',
parseHTML: (el) => el.getAttribute('data-note-num') ?? '',
renderHTML: (attrs) => ({ 'data-note-num': String(attrs.text ?? '') }),
},
};
},
parseHTML() {
return [{ tag: 'div[data-note-id]' }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { class: 'note' }), 0];
},
});
export const NoteSection = Node.create({
name: 'noteSection',
group: 'notes',
content: 'note+',
isolating: true,
selectable: false,
parseHTML() {
return [{ tag: 'div[data-notes]' }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-notes': 'true', class: 'note-section' }), 0];
},
});
export type NoteRefInfo = { id: string; kind: NoteKind; pos: number };
// Every anchor in the body, in document order. The note section is skipped: a note's
// own text may hold an anchor, and that one numbers nothing.
export function collectNoteRefs(doc: PMNode): NoteRefInfo[] {
const out: NoteRefInfo[] = [];
doc.forEach((child, offset) => {
if (child.type.name === 'noteSection') return;
child.descendants((node, pos) => {
if (node.type.name === 'noteRef') {
out.push({ id: String(node.attrs.id ?? ''), kind: readKind(node.attrs.kind), pos: offset + 1 + pos });
}
});
if (child.type.name === 'noteRef') {
out.push({ id: String(child.attrs.id ?? ''), kind: readKind(child.attrs.kind), pos: offset });
}
});
return out;
}
export function findNoteSection(doc: PMNode): { node: PMNode; pos: number } | null {
const last = doc.lastChild;
if (!last || last.type.name !== 'noteSection') return null;
return { node: last, pos: doc.content.size - last.nodeSize };
}
// Anchors that ended up inside a note — a paste, since inserting one there is refused.
// They reference nothing and carry no number, so the sync pass drops them.
function strayRefsInNotes(doc: PMNode): number[] {
const section = findNoteSection(doc);
if (!section) return [];
const out: number[] = [];
section.node.descendants((node, pos) => {
if (node.type.name === 'noteRef') out.push(section.pos + 1 + pos);
});
return out;
}
export function inNote(state: EditorState): boolean {
const $from = state.selection.$from;
for (let d = $from.depth; d > 0; d--) if ($from.node(d).type.name === 'note') return true;
return false;
}
// Position of the first text place inside the note with this id.
function noteEntryPos(doc: PMNode, id: string): number | null {
const section = findNoteSection(doc);
if (!section) return null;
let found: number | null = null;
section.node.forEach((child, offset) => {
if (found === null && String(child.attrs.id ?? '') === id) found = section.pos + offset + 2;
});
return found;
}
// The label each anchor shows, keyed by note id: footnotes and endnotes count
// separately, each from its own start value.
export function noteLabels(refs: NoteRefInfo[], settings: NoteSettings): Map<string, string> {
const seen: Record<NoteKind, number> = { footnote: 0, endnote: 0 };
const out = new Map<string, string>();
for (const ref of refs) {
out.set(ref.id, noteLabel(seen[ref.kind], ref.kind, settings));
seen[ref.kind] += 1;
}
return out;
}
const notesKey = new PluginKey('notes');
export interface NotesOptions {
settings: () => NoteSettings;
}
export const Notes = Extension.create<NotesOptions>({
name: 'notes',
addOptions() {
return { settings: () => DEFAULT_NOTE_SETTINGS };
},
addCommands() {
return {
// Insert the anchor and its (empty) note in one step and drop the cursor into the
// note, so the user types where they are looking. The sync plugin only repairs.
insertNote:
(kind) =>
({ state, dispatch }) => {
const schema = state.schema;
const refType = schema.nodes.noteRef;
const noteType = schema.nodes.note;
const sectionType = schema.nodes.noteSection;
if (!refType || !noteType || !sectionType) return false;
// No note inside a note: LibreOffice and Word both refuse it, and the anchor
// would reference a note that can never be numbered.
if (inNote(state)) return false;
if (!dispatch) return true;
const id = newNoteId();
const tr = state.tr.replaceSelectionWith(refType.create({ id, kind, text: '' }), false);
// Where the new note belongs: after every note whose anchor precedes this one.
const section = findNoteSection(tr.doc);
const before = collectNoteRefs(tr.doc).findIndex((r) => r.id === id);
const note = noteType.create({ id, kind, text: '' });
if (!section) {
tr.insert(tr.doc.content.size, sectionType.create(null, note));
} else {
let offset = section.pos + 1;
section.node.forEach((child, childOffset, index) => {
if (index < before) offset = section.pos + 1 + childOffset + child.nodeSize;
});
tr.insert(offset, note);
}
const entry = noteEntryPos(tr.doc, id);
if (entry !== null) tr.setSelection(TextSelection.near(tr.doc.resolve(entry)));
dispatch(tr.scrollIntoView());
return true;
},
goToNote:
(id) =>
({ state, dispatch }) => {
const entry = noteEntryPos(state.doc, id);
if (entry === null || !dispatch) return entry !== null;
dispatch(state.tr.setSelection(TextSelection.near(state.doc.resolve(entry))).scrollIntoView());
return true;
},
};
},
addKeyboardShortcuts() {
return {
[DEFAULT_SHORTCUTS.footnote]: () => this.editor.commands.insertNote('footnote'),
[DEFAULT_SHORTCUTS.endnote]: () => this.editor.commands.insertNote('endnote'),
};
},
addProseMirrorPlugins() {
const getSettings = () => this.options.settings();
return [
new Plugin({
key: notesKey,
props: {
// 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) {
if (node.type.name !== 'noteRef') return false;
const entry = noteEntryPos(view.state.doc, String(node.attrs.id ?? ''));
if (entry === null) return false;
view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(entry))).scrollIntoView());
view.focus();
return true;
},
},
// Two rounds by design: structure first, numbering on the pass after it. Doing
// both at once would need positions from a document this transaction is still
// rewriting.
appendTransaction(trs, _oldState, newState) {
if (!trs.some((tr) => tr.docChanged)) return null;
return syncStructure(newState) ?? syncNumbers(newState, getSettings());
},
}),
];
},
});
// Keep exactly one note per anchor, in anchor order. Returns null when the document
// already satisfies that, so the pass converges after one repair.
function syncStructure(state: EditorState): Transaction | null {
const noteType = state.schema.nodes.note;
const sectionType = state.schema.nodes.noteSection;
if (!noteType || !sectionType) return null;
// Strays first and on their own: they shift every position after them, and the rest
// of this pass reads positions out of the document it was handed.
const strays = strayRefsInNotes(state.doc);
if (strays.length) {
const tr = state.tr;
for (let i = strays.length - 1; i >= 0; i--) tr.delete(strays[i], strays[i] + 1);
return tr.setMeta('addToHistory', false);
}
const refs = collectNoteRefs(state.doc);
const section = findNoteSection(state.doc);
const existing = new Map<string, PMNode>();
section?.node.forEach((child) => existing.set(String(child.attrs.id ?? ''), child));
const wanted: PMNode[] = [];
const reIds: { pos: number; id: string }[] = [];
const used = new Set<string>();
for (const ref of refs) {
// The same id twice means an anchor was copied: the copy earns its own note, a
// clone of the one it was duplicated from.
if (used.has(ref.id) || !ref.id) {
const source = existing.get(ref.id);
const id = newNoteId();
reIds.push({ pos: ref.pos, id });
wanted.push(noteType.create({ ...(source?.attrs ?? {}), id, kind: ref.kind }, source?.content));
used.add(id);
continue;
}
const own = existing.get(ref.id);
wanted.push(own ?? noteType.create({ id: ref.id, kind: ref.kind }));
used.add(ref.id);
}
const currentIds = section ? section.node.children.map((c) => String(c.attrs.id ?? '')) : [];
const wantedIds = wanted.map((c) => String(c.attrs.id ?? ''));
const sameOrder = currentIds.length === wantedIds.length && currentIds.every((v, i) => v === wantedIds[i]);
if (sameOrder && !reIds.length) return null;
const tr = state.tr;
for (const fix of reIds) tr.setNodeAttribute(fix.pos, 'id', fix.id);
if (!sameOrder) {
if (!wanted.length && section) tr.delete(section.pos, section.pos + section.node.nodeSize);
else if (!section) tr.insert(tr.doc.content.size, sectionType.create(null, wanted));
else tr.replaceWith(section.pos + 1, section.pos + section.node.nodeSize - 1, wanted);
}
return tr.setMeta('addToHistory', false);
}
// Write the running label onto every anchor and its note. Cached attrs, so nothing is
// dispatched once they agree.
function syncNumbers(state: EditorState, settings: NoteSettings): Transaction | null {
const refs = collectNoteRefs(state.doc);
const labels = noteLabels(refs, settings);
const section = findNoteSection(state.doc);
const tr = state.tr;
let changed = false;
for (const ref of refs) {
const label = labels.get(ref.id) ?? '';
if (state.doc.nodeAt(ref.pos)?.attrs.text === label) continue;
tr.setNodeAttribute(ref.pos, 'text', label);
changed = true;
}
section?.node.forEach((child, offset) => {
const own = child.attrs.label ? String(child.attrs.label) : labels.get(String(child.attrs.id ?? '')) ?? '';
if (child.attrs.text === own) return;
tr.setNodeAttribute(section.pos + 1 + offset, 'text', own);
changed = true;
});
return changed ? tr.setMeta('addToHistory', false) : null;
}
+7 -2
View File
@@ -17,11 +17,16 @@ export const TrailingNode = Extension.create({
new Plugin({
key: trailingNodeKey,
appendTransaction(_transactions, _oldState, newState) {
const last = newState.doc.lastChild;
// The note section is not body text and sits past it, so the block that needs
// a paragraph under it is the last one before the section.
const doc = newState.doc;
const notes = doc.lastChild?.type.name === 'noteSection' ? doc.lastChild : null;
const end = doc.content.size - (notes?.nodeSize ?? 0);
const last = notes ? doc.childBefore(end).node : doc.lastChild;
if (!last || !NEEDS_TRAILING_AFTER.has(last.type.name)) return null;
const paragraph = newState.schema.nodes.paragraph;
if (!paragraph) return null;
return newState.tr.insert(newState.doc.content.size, paragraph.create());
return newState.tr.insert(end, paragraph.create());
},
}),
];
+4
View File
@@ -38,6 +38,10 @@ export const DEFAULT_SHORTCUTS = {
softHyphen: 'Mod-Shift--',
dateField: 'Alt-Shift-d',
timeField: 'Alt-Shift-t',
// Word's own note keys. Letters, not digits: the AltGr collision only affects
// Mod-Alt-<digit> (see editor/CLAUDE.md).
footnote: 'Mod-Alt-f',
endnote: 'Mod-Alt-d',
// Application (window keydown in App.svelte)
open: 'Mod-o',
+7
View File
@@ -207,6 +207,9 @@ const de: Messages = {
bookmarkNotInHf: 'Lesezeichen sind in Kopf-/Fußzeilen nicht verfügbar',
insertCrossRef: 'Querverweis einfügen',
crossRefNeedsBookmark: 'Erst ein Lesezeichen setzen — ein Querverweis zeigt darauf',
insertFootnote: 'Fußnote einfügen',
insertEndnote: 'Endnote einfügen',
noteNotInHf: 'Notizen sind in Kopf-/Fußzeilen nicht verfügbar',
clearFormatting: 'Formatierung entfernen',
},
image: {
@@ -492,6 +495,7 @@ const de: Messages = {
symbols: 'Symbole',
pageSetup: 'Seite einrichten',
toc: 'Inhaltsverzeichnis',
notes: 'Fußnoten',
proofing: 'Rechtschreibung',
language: 'Sprache',
show: 'Anzeigen',
@@ -555,6 +559,9 @@ const de: Messages = {
toc: 'Inhaltsverzeichnis',
tocOptions: 'Ebenen',
tocMaxLevel: 'Tiefste angezeigte Ebene',
footnote: 'Fußnote',
endnote: 'Endnote',
noteOptions: 'Optionen',
debugDump: 'Dump',
cellMargins: 'Zellenränder',
altText: 'Alternativtext',
+7
View File
@@ -211,6 +211,9 @@ const en = {
bookmarkNotInHf: 'Bookmarks are not available in headers/footers',
insertCrossRef: 'Insert cross-reference',
crossRefNeedsBookmark: 'Add a bookmark first — a cross-reference points at one',
insertFootnote: 'Insert footnote',
insertEndnote: 'Insert endnote',
noteNotInHf: 'Notes are not available in headers/footers',
clearFormatting: 'Clear formatting',
},
image: {
@@ -496,6 +499,7 @@ const en = {
symbols: 'Symbols',
pageSetup: 'Page Setup',
toc: 'Table of Contents',
notes: 'Footnotes',
proofing: 'Proofing',
language: 'Language',
show: 'Show',
@@ -559,6 +563,9 @@ const en = {
toc: 'Table of contents',
tocOptions: 'Levels',
tocMaxLevel: 'Deepest level shown',
footnote: 'Footnote',
endnote: 'Endnote',
noteOptions: 'Options',
debugDump: 'Dump',
cellMargins: 'Cell margins',
altText: 'Alt text',
+135
View File
@@ -0,0 +1,135 @@
import type { OrderedTypeDef } from '../utils/orderedListTypes';
const KEY = 'odf-editor-notes';
// How footnotes and endnotes are numbered and where they sit. Every default here was
// read out of a document LibreOffice saved (docs/architecture/notes.md) — an
// editor-only default would land in every imported file as direct formatting.
export type NoteKind = 'footnote' | 'endnote';
export type NoteNumFormat = OrderedTypeDef['numFormat'];
// Where the count starts over. 'chapter' is ODF's own third value; nothing restarts
// it yet, so it round-trips without changing the numbering.
export type NoteRestart = 'document' | 'page' | 'chapter';
// A footnote sits at the foot of its page or is collected with the endnotes at the
// end of the document (LibreOffice's "Collect at end of document").
export type NotePosition = 'page' | 'document';
export type NoteClassSettings = {
numFormat: NoteNumFormat;
// The number of the *first* note. ODF stores it 0-based (text:start-value).
startAt: number;
restart: NoteRestart;
position: NotePosition;
prefix: string;
suffix: string;
citationStyle: string;
bodyStyle: string;
};
export type NoteSeparator = {
relWidthPercent: number;
weightPt: number;
spaceAboveCm: number;
spaceBelowCm: number;
align: 'left' | 'center' | 'right';
color: string;
};
export type NoteSettings = {
footnote: NoteClassSettings;
endnote: NoteClassSettings;
separator: NoteSeparator;
};
// LibreOffice numbers footnotes arabic and document-wide, endnotes lower roman.
export const DEFAULT_NOTE_SETTINGS: NoteSettings = {
footnote: {
numFormat: '1',
startAt: 1,
restart: 'document',
position: 'page',
prefix: '',
suffix: '',
citationStyle: 'Footnote Symbol',
bodyStyle: 'Footnote',
},
endnote: {
numFormat: 'i',
startAt: 1,
restart: 'document',
position: 'document',
prefix: '',
suffix: '',
citationStyle: 'Endnote Symbol',
bodyStyle: 'Endnote',
},
// style:footnote-sep as LibreOffice writes it: 0.0071in / 0.0398in.
separator: {
relWidthPercent: 25,
weightPt: 0.5,
spaceAboveCm: 0.1,
spaceBelowCm: 0.1,
align: 'left',
color: '#000000',
},
};
// The note text's own size and hanging indent — LibreOffice's Footnote/Endnote
// paragraph styles, which both exports write out and both imports suppress against.
export const NOTE_FONT_SIZE_PT = 10;
export const NOTE_INDENT_CM = 0.6;
const NUM_FORMATS: NoteNumFormat[] = ['1', 'a', 'A', 'i', 'I'];
function clampClass(raw: unknown, fallback: NoteClassSettings): NoteClassSettings {
const o = (raw ?? {}) as Partial<NoteClassSettings>;
const start = Number(o.startAt);
return {
numFormat: NUM_FORMATS.includes(o.numFormat as NoteNumFormat) ? o.numFormat as NoteNumFormat : fallback.numFormat,
startAt: Number.isFinite(start) ? Math.min(9999, Math.max(1, Math.round(start))) : fallback.startAt,
restart: o.restart === 'page' || o.restart === 'chapter' ? o.restart : 'document',
position: o.position === 'document' ? 'document' : fallback.position,
prefix: typeof o.prefix === 'string' ? o.prefix.slice(0, 8) : fallback.prefix,
suffix: typeof o.suffix === 'string' ? o.suffix.slice(0, 8) : fallback.suffix,
citationStyle: typeof o.citationStyle === 'string' && o.citationStyle ? o.citationStyle : fallback.citationStyle,
bodyStyle: typeof o.bodyStyle === 'string' && o.bodyStyle ? o.bodyStyle : fallback.bodyStyle,
};
}
function clampSeparator(raw: unknown): NoteSeparator {
const o = (raw ?? {}) as Partial<NoteSeparator>;
const d = DEFAULT_NOTE_SETTINGS.separator;
const num = (v: unknown, min: number, max: number, fb: number) =>
typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : fb;
return {
relWidthPercent: num(o.relWidthPercent, 0, 100, d.relWidthPercent),
weightPt: num(o.weightPt, 0, 20, d.weightPt),
spaceAboveCm: num(o.spaceAboveCm, 0, 5, d.spaceAboveCm),
spaceBelowCm: num(o.spaceBelowCm, 0, 5, d.spaceBelowCm),
align: o.align === 'center' || o.align === 'right' ? o.align : d.align,
color: /^#[0-9a-fA-F]{6}$/.test(String(o.color)) ? String(o.color) : d.color,
};
}
export function clampNoteSettings(raw: unknown): NoteSettings {
const o = (raw ?? {}) as Partial<NoteSettings>;
return {
footnote: clampClass(o.footnote, DEFAULT_NOTE_SETTINGS.footnote),
endnote: clampClass(o.endnote, DEFAULT_NOTE_SETTINGS.endnote),
separator: clampSeparator(o.separator),
};
}
export function loadNoteSettings(): NoteSettings {
try {
const raw = localStorage.getItem(KEY);
return raw ? clampNoteSettings(JSON.parse(raw)) : DEFAULT_NOTE_SETTINGS;
} catch {
return DEFAULT_NOTE_SETTINGS;
}
}
export function saveNoteSettings(s: NoteSettings): void {
localStorage.setItem(KEY, JSON.stringify(s));
}
+15
View File
@@ -0,0 +1,15 @@
// The document's note settings as a reactive singleton (same shape as sheet.svelte.ts):
// the extension reads noteSettings() when it renumbers, the dialog replaces it.
import { clampNoteSettings, loadNoteSettings, saveNoteSettings, type NoteSettings } from './noteSettings';
let current = $state<NoteSettings>(loadNoteSettings());
export function noteSettings(): NoteSettings {
return current;
}
export function setNoteSettings(next: NoteSettings): void {
current = clampNoteSettings(next);
saveNoteSettings(current);
}
+49
View File
@@ -276,6 +276,55 @@
white-space: nowrap;
}
/* Footnotes and endnotes (notes.ts). Sizes and indents are LibreOffice's own Footnote
style; the separator values follow its style:footnote-sep — see
docs/architecture/notes.md, all of them probed. The dialog overrides them on :root. */
.paper .tiptap {
--note-sep-rel-width: 25%;
--note-sep-weight: 0.5pt;
--note-sep-below: 0.1cm;
--note-sep-color: #000000;
--note-sep-align: left;
}
.paper .tiptap .note-ref {
/* style:text-position="super 58%" on LibreOffice's Footnote anchor style. */
vertical-align: super;
font-size: 58%;
line-height: 0;
cursor: pointer;
}
.paper .tiptap .note {
font-size: 10pt;
margin-left: 0.6cm;
text-indent: -0.6cm;
}
/* 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);
display: inline-block;
width: 0.6cm;
text-indent: 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. */
.paper .tiptap .note[data-note-kind='footnote'] {
position: absolute;
left: var(--page-margin-left);
right: var(--page-margin-right);
}
/* The separator rides the first note of a page as a background line, so its 25% width
costs no element of its own; a border-top would always span the full text width. */
.paper .tiptap .note[data-note-page-first] {
padding-top: calc(var(--note-sep-weight) + var(--note-sep-below));
background-image: linear-gradient(var(--note-sep-color), var(--note-sep-color));
background-size: var(--note-sep-rel-width) var(--note-sep-weight);
background-repeat: no-repeat;
background-position: var(--note-sep-align) top;
}
/* Formulas (formula.ts). MathML needs a font with an OpenType MATH table or Chromium
drops ∑/ϕ and won't stretch brackets — STIX Two Math is bundled for that. */
/* The family must be set on <math> itself: the UA stylesheet declares