feat: a footnotes and endnotes options dialog

Numbering format, start value, restart, position, prefix/suffix and the two
styles per class, plus the separator's length, thickness, spacing, alignment
and colour. The separator rides :root custom properties, so editor.css draws
the line and pageBreaks.ts reserves its band from the same values. A settings
change is no document change, so it carries a resync meta — without it the
numbering kept the format it was typed under.
This commit is contained in:
Steffen Becker committed 2026-08-13 11:50:53 +00:00
1 parent 3703cea1f2
commit 1aa74c0a9f
11 files changed
+381 -13

No files matched your search

+6
View File
@@ -40,6 +40,7 @@
import UiLanguagePicker from './lib/components/UiLanguagePicker.svelte';
import AboutDialog from './lib/components/AboutDialog.svelte';
import StyleManagerDialog from './lib/components/StyleManagerDialog.svelte';
import NoteOptionsDialog from './lib/components/NoteOptionsDialog.svelte';
import { t } from './lib/i18n/i18n.svelte';
import { withShortcut } from './lib/i18n/shortcut';
import { DEFAULT_SHORTCUTS, matchesEvent, shortcutHint } from './lib/editor/shortcuts';
@@ -56,6 +57,7 @@
let aboutOpen = $state(false);
let styleManagerOpen = $state(false);
let styleManagerFamily = $state<StyleFamily>('paragraph');
let noteOptionsOpen = $state(false);
function openStyleManager(family: StyleFamily) {
styleManagerFamily = family;
@@ -201,6 +203,7 @@
saveSpacingModel(spacingModel);
});
$effect(() => {
savePageRtl(pageRtl);
});
@@ -862,6 +865,7 @@
{hfActive}
onManageStyles={openStyleManager}
onManageTableStyles={() => openStyleManager('table')}
onNoteOptions={() => (noteOptionsOpen = true)}
onEditZone={(zone) => (hfActive = zone)}
onFind={openFind}
{namePlaceholder}
@@ -1096,6 +1100,7 @@
onEditZone={(zone) => (hfActive = zone)}
onDebugDump={handleDebugDump}
onManageTableStyles={() => openStyleManager('table')}
onNoteOptions={() => (noteOptionsOpen = true)}
/>
</div>
{@render toolbarScrollbar()}
@@ -1212,6 +1217,7 @@
<!-- One instance for every entry point (styles gallery, insert-table menu): the
callers only say which family to land on. -->
<StyleManagerDialog bind:open={styleManagerOpen} family={styleManagerFamily} editor={activeEditor} />
<NoteOptionsDialog bind:open={noteOptionsOpen} />
</main>
<style>
+22
View File
@@ -22,6 +22,9 @@
import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
import { type Orientation } from '../storage/pageOrientation';
import { type SpacingModel } from '../storage/spacingModel';
import { applyNoteVars } from '../storage/noteSettings';
import { noteSettings } from '../storage/notes.svelte';
import { RESYNC_NOTES } from '../editor/extensions/notes';
import { applyPageSizeVars, type PageFormat } from '../storage/pageFormat';
import { DEFAULT_HF_DISTANCES, hfIsEmpty, hfUsesChapterField, type HfDoc, type HfZone, type HfDistances, type HfSet } from '../storage/headerFooter';
import { FORCE_PAGE_RECALC, pageOfElement, readVerticalMargins, type TableBreakBand } from '../editor/extensions/pageBreaks';
@@ -261,6 +264,25 @@
});
});
// The separator's own band is part of the space pagination reserves for a page's
// notes, so a change to it has to re-measure — and the line is drawn from the same
// custom properties.
$effect(() => {
applyNoteVars(noteSettings());
const ed = editor;
if (!ed) return;
requestAnimationFrame(() => {
if (ed.view.dom.isConnected) {
// The numbering follows the settings too, and nothing about them is a
// document change the sync pass would otherwise see.
ed.view.dispatch(ed.state.tr
.setMeta('addToHistory', false)
.setMeta(RESYNC_NOTES, true)
.setMeta(FORCE_PAGE_RECALC, true));
}
});
});
// Bundled fonts (Liberation Serif, Carlito, …) load with font-display:swap, so text
// can reflow after pagination first runs (e.g. imported content in a not-yet-loaded
// font). Re-paginate when fonts finish so page positions and the HF band settle.
+246
View File
@@ -0,0 +1,246 @@
<script lang="ts">
import {
DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteRestart, type NoteSettings,
} from '../storage/noteSettings';
import { noteSettings, setNoteSettings } from '../storage/notes.svelte';
import { styleSheet } from '../styles/sheet.svelte';
import { formatOrdinal } from '../utils/orderedListTypes';
import { t } from '../i18n/i18n.svelte';
// LibreOffice's Tools ▸ Footnotes and Endnotes. Everything here round-trips to ODF's
// text:notes-configuration + style:footnote-sep and to Word's w:footnotePr.
let { open = $bindable(false) }: { open?: boolean } = $props();
let dialogEl = $state<HTMLDialogElement | null>(null);
let kind = $state<NoteKind>('footnote');
$effect(() => {
const el = dialogEl;
if (!el) return;
if (open && !el.open) el.showModal();
else if (!open && el.open) el.close();
});
const FORMATS: NoteNumFormat[] = ['1', 'i', 'I', 'a', 'A'];
const RESTARTS: NoteRestart[] = ['document', 'page', 'chapter'];
const ALIGNS = ['left', 'center', 'right'] as const;
const cls = $derived(noteSettings()[kind]);
const sep = $derived(noteSettings().separator);
// Three running values, so a format reads as a sequence rather than a letter.
const preview = (f: NoteNumFormat) => [0, 1, 2].map((i) => formatOrdinal(i + 1, f)).join(', ');
function patch(next: Partial<NoteSettings[NoteKind]>) {
const s = noteSettings();
setNoteSettings({ ...s, [kind]: { ...s[kind], ...next } });
}
function patchSep(next: Partial<NoteSettings['separator']>) {
const s = noteSettings();
setNoteSettings({ ...s, separator: { ...s.separator, ...next } });
}
const num = (e: Event) => parseFloat((e.currentTarget as HTMLInputElement).value.replace(',', '.'));
</script>
<dialog
bind:this={dialogEl}
onclose={() => (open = false)}
onclick={(e) => e.target === dialogEl && (open = false)}
aria-label={t().notesDialog.title}
>
<div class="body">
<h2>{t().notesDialog.title}</h2>
<div class="tabs" role="tablist">
{#each ['footnote', 'endnote'] as const as k}
<button
role="tab"
aria-selected={kind === k}
class:selected={kind === k}
onclick={() => (kind = k)}
>{k === 'footnote' ? t().notesDialog.tabFootnotes : t().notesDialog.tabEndnotes}</button>
{/each}
</div>
<fieldset>
<legend>{t().notesDialog.numbering}</legend>
<label class="row">
<span>{t().notesDialog.format}</span>
<select value={cls.numFormat} onchange={(e) => patch({ numFormat: e.currentTarget.value as NoteNumFormat })}>
{#each FORMATS as f}<option value={f}>{preview(f)}</option>{/each}
</select>
</label>
<label class="row">
<span>{t().notesDialog.startAt}</span>
<input type="number" min="1" max="9999" value={cls.startAt}
onchange={(e) => { const v = num(e); if (v >= 1) patch({ startAt: Math.round(v) }); }} />
</label>
<label class="row">
<span>{t().notesDialog.restart}</span>
<select value={cls.restart} onchange={(e) => patch({ restart: e.currentTarget.value as NoteRestart })}>
{#each RESTARTS as r}
<option value={r}>{r === 'page' ? t().notesDialog.restartPage : r === 'chapter' ? t().notesDialog.restartChapter : t().notesDialog.restartDocument}</option>
{/each}
</select>
</label>
{#if kind === 'footnote'}
<label class="row">
<span>{t().notesDialog.position}</span>
<select value={cls.position} onchange={(e) => patch({ position: e.currentTarget.value === 'document' ? 'document' : 'page' })}>
<option value="page">{t().notesDialog.positionPage}</option>
<option value="document">{t().notesDialog.positionDocument}</option>
</select>
</label>
{/if}
<div class="row">
<span>{t().notesDialog.affix}</span>
<input class="affix" value={cls.prefix} placeholder={t().notesDialog.prefix}
onchange={(e) => patch({ prefix: e.currentTarget.value })} />
<input class="affix" value={cls.suffix} placeholder={t().notesDialog.suffix}
onchange={(e) => patch({ suffix: e.currentTarget.value })} />
</div>
</fieldset>
<fieldset>
<legend>{t().notesDialog.styles}</legend>
<label class="row">
<span>{t().notesDialog.bodyStyle}</span>
<select value={cls.bodyStyle} onchange={(e) => patch({ bodyStyle: e.currentTarget.value })}>
{#each Object.keys(styleSheet().paragraph) as name}<option value={name}>{name}</option>{/each}
{#if !styleSheet().paragraph[cls.bodyStyle]}<option value={cls.bodyStyle}>{cls.bodyStyle}</option>{/if}
</select>
</label>
<label class="row">
<span>{t().notesDialog.citationStyle}</span>
<select value={cls.citationStyle} onchange={(e) => patch({ citationStyle: e.currentTarget.value })}>
{#each Object.keys(styleSheet().character) as name}<option value={name}>{name}</option>{/each}
{#if !styleSheet().character[cls.citationStyle]}<option value={cls.citationStyle}>{cls.citationStyle}</option>{/if}
</select>
</label>
</fieldset>
<!-- The separator is a footnote-only thing: the endnote list opens its own page. -->
{#if kind === 'footnote'}
<fieldset>
<legend>{t().notesDialog.separator}</legend>
<label class="row">
<span>{t().notesDialog.sepWidth}</span>
<input type="number" min="0" max="100" step="1" value={sep.relWidthPercent}
onchange={(e) => { const v = num(e); if (v >= 0 && v <= 100) patchSep({ relWidthPercent: v }); }} />
</label>
<label class="row">
<span>{t().notesDialog.sepWeight}</span>
<input type="number" min="0" max="20" step="0.25" value={sep.weightPt}
onchange={(e) => { const v = num(e); if (v >= 0) patchSep({ weightPt: v }); }} />
</label>
<label class="row">
<span>{t().notesDialog.sepAbove}</span>
<input type="number" min="0" max="5" step="0.05" value={sep.spaceAboveCm}
onchange={(e) => { const v = num(e); if (v >= 0) patchSep({ spaceAboveCm: v }); }} />
</label>
<label class="row">
<span>{t().notesDialog.sepBelow}</span>
<input type="number" min="0" max="5" step="0.05" value={sep.spaceBelowCm}
onchange={(e) => { const v = num(e); if (v >= 0) patchSep({ spaceBelowCm: v }); }} />
</label>
<label class="row">
<span>{t().notesDialog.sepAlign}</span>
<select value={sep.align} onchange={(e) => patchSep({ align: e.currentTarget.value as 'left' })}>
{#each ALIGNS as a}<option value={a}>{t().align[a]}</option>{/each}
</select>
</label>
<label class="row">
<span>{t().notesDialog.sepColor}</span>
<input type="color" value={sep.color} onchange={(e) => patchSep({ color: e.currentTarget.value })} />
</label>
</fieldset>
{/if}
<div class="actions">
<button class="reset" onclick={() => setNoteSettings(DEFAULT_NOTE_SETTINGS)}>{t().notesDialog.reset}</button>
<span class="spacer"></span>
<button class="primary" onclick={() => (open = false)}>{t().common.close}</button>
</div>
</div>
</dialog>
<style>
dialog {
/* The global reset zeroes every margin, which also takes the auto centring a
modal <dialog> gets by default. */
margin: auto;
border: none;
border-radius: 8px;
padding: 0;
background: var(--color-surface);
color: var(--color-text);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
dialog::backdrop { background: rgba(0, 0, 0, 0.35); }
.body {
display: flex;
flex-direction: column;
gap: 12px;
width: 460px;
max-height: 80vh;
overflow-y: auto;
padding: 18px 20px 16px;
font-family: var(--font-sans);
font-size: 0.85rem;
}
h2 { font-size: 1rem; }
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--color-border); }
.tabs button {
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--color-text-muted);
padding: 6px 12px;
font: inherit;
cursor: pointer;
}
.tabs button.selected { border-bottom-color: var(--color-primary); color: var(--color-text); }
fieldset { border: 1px solid var(--color-border); border-radius: var(--radius); padding: 8px 10px 10px; }
legend { color: var(--color-text-muted); padding: 0 4px; }
.row { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
.row > span { flex: 1; color: var(--color-text-muted); }
input, select {
height: 26px;
width: 150px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 0 6px;
font: inherit;
}
input[type='color'] { width: 44px; padding: 2px; }
.affix { width: 68px; }
.actions { display: flex; align-items: center; gap: 8px; }
.spacer { flex: 1; }
.actions button {
border: 1px solid var(--color-primary);
border-radius: var(--radius);
background: var(--color-primary);
color: #fff;
padding: 5px 14px;
font: inherit;
cursor: pointer;
}
.actions .reset {
border-color: var(--color-border);
background: var(--color-surface);
color: var(--color-text-muted);
}
.actions .reset:hover { background: var(--color-btn-hover); }
</style>
+14 -2
View File
@@ -30,8 +30,8 @@
import { t } from '../i18n/i18n.svelte';
import { shortcutHint, type ShortcutId } from '../editor/shortcuts';
let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('A4'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles }:
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; showRuler?: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void } = $props();
let { editor, tick, showFormattingMarks = $bindable(), showRuler = $bindable(true), pageMargins = $bindable(DEFAULT_MARGINS), pageOrientation = $bindable<Orientation>('portrait'), pageFormat = $bindable<PageFormat>('A4'), hfDistances = $bindable(DEFAULT_HF_DISTANCES), differentFirstPage = $bindable(false), differentOddEven = $bindable(false), hfActive = null, onEditZone, onDebugDump, onManageTableStyles, onNoteOptions }:
{ editor: Editor | null; tick: number; showFormattingMarks: boolean; showRuler?: boolean; pageMargins?: PageMargins; pageOrientation?: Orientation; pageFormat?: PageFormat; hfDistances?: HfDistances; differentFirstPage?: boolean; differentOddEven?: boolean; hfActive?: 'header' | 'footer' | null; onEditZone?: (zone: 'header' | 'footer') => void; onDebugDump?: () => void; onManageTableStyles?: () => void; onNoteOptions?: () => void } = $props();
const PAGE_FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[];
@@ -1749,6 +1749,18 @@
<path d="M2 13.5h9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
<button
onclick={() => onNoteOptions?.()}
disabled={!onNoteOptions}
title={t().notesDialog.title}
aria-label={t().notesDialog.title}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M2 3h8M2 6h6M2 9h8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<path d="M2 12.5h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<path d="M12 10.5a2 2 0 1 1 0 4 2 2 0 0 1 0-4z" stroke="currentColor" stroke-width="1.3" />
</svg>
</button>
<div class="link-wrap" use:linkClickOutside>
<button
class:active={isLink}
+3 -1
View File
@@ -47,6 +47,7 @@
hfActive = null,
onManageStyles,
onManageTableStyles,
onNoteOptions,
onEditZone,
onFind,
namePlaceholder = '',
@@ -74,6 +75,7 @@
hfActive?: HfZone | null;
onManageStyles?: (family: StyleFamily) => void;
onManageTableStyles?: (family: StyleFamily) => void;
onNoteOptions?: () => void;
onEditZone?: (zone: HfZone) => void;
onFind?: (mode: 'find' | 'replace') => void;
namePlaceholder?: string;
@@ -284,7 +286,7 @@
{:else if tab === 'layout'}
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat onParagraphDialog={() => (paragraphDialogOpen = true)} />
{:else if tab === 'references'}
<ReferencesTab {editor} {tick} {hfActive} />
<ReferencesTab {editor} {tick} {hfActive} {onNoteOptions} />
{:else if tab === 'review'}
<ReviewTab {editor} {tick} {documentLanguage} {onLanguage} />
{:else if tab === 'view'}
@@ -7,10 +7,11 @@
import { t } from '../../../i18n/i18n.svelte';
import { shortcutHint } from '../../../editor/shortcuts';
let { editor, tick, hfActive = null }: {
let { editor, tick, hfActive = null, onNoteOptions }: {
editor: Editor | null;
tick: number;
hfActive?: HfZone | null;
onNoteOptions?: () => void;
} = $props();
// The document holds at most one, so the first hit is it.
@@ -79,6 +80,13 @@
disabled={!editor || !!hfActive}
onclick={() => editor?.chain().focus().insertNote('endnote').run()}
/>
<RibbonButton
icon="tocLevels"
label={t().ribbon.noteOptions}
title={t().notesDialog.title}
disabled={!onNoteOptions}
onclick={() => onNoteOptions?.()}
/>
</RibbonGroup>
<style>
+5 -1
View File
@@ -210,6 +210,10 @@ export function noteLabels(refs: NoteRefInfo[], settings: NoteSettings): Map<str
const notesKey = new PluginKey('notes');
// Meta asking for a renumber without a document change: the numbering follows the
// settings, and changing those in the dialog edits nothing the sync pass would see.
export const RESYNC_NOTES = 'resyncNotes';
export interface NotesOptions {
settings: () => NoteSettings;
}
@@ -301,7 +305,7 @@ export const Notes = Extension.create<NotesOptions>({
// 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;
if (!trs.some((tr) => tr.docChanged || tr.getMeta(RESYNC_NOTES))) return null;
return syncStructure(newState) ?? syncNumbers(newState, getSettings());
},
}),
+29
View File
@@ -462,6 +462,35 @@ const de: Messages = {
noLeader: 'Kein Füllzeichen',
defaultInterval: 'Standardtabstopps',
},
notesDialog: {
title: 'Fuß- und Endnoten',
tabFootnotes: 'Fußnoten',
tabEndnotes: 'Endnoten',
numbering: 'Nummerierung',
format: 'Format',
startAt: 'Beginnen bei',
restart: 'Nummerierung neu beginnen',
restartDocument: 'Fortlaufend',
restartPage: 'Pro Seite',
restartChapter: 'Pro Abschnitt',
position: 'Position',
positionPage: 'Seitenende',
positionDocument: 'Dokumentende',
affix: 'Vor / nach der Nummer',
prefix: 'Davor',
suffix: 'Danach',
styles: 'Vorlagen',
bodyStyle: 'Notentext',
citationStyle: 'Notenanker',
separator: 'Trennlinie',
sepWidth: 'Länge (% der Textbreite)',
sepWeight: 'Dicke (pt)',
sepAbove: 'Abstand oben (cm)',
sepBelow: 'Abstand unten (cm)',
sepAlign: 'Ausrichtung',
sepColor: 'Farbe',
reset: 'Auf LibreOffice-Standard zurücksetzen',
},
ribbon: {
tabs: {
file: 'Datei',
+29
View File
@@ -466,6 +466,35 @@ const en = {
noLeader: 'No leader',
defaultInterval: 'Default tab stops',
},
notesDialog: {
title: 'Footnotes and Endnotes',
tabFootnotes: 'Footnotes',
tabEndnotes: 'Endnotes',
numbering: 'Numbering',
format: 'Format',
startAt: 'Start at',
restart: 'Restart numbering',
restartDocument: 'Continuous',
restartPage: 'Each page',
restartChapter: 'Each section',
position: 'Position',
positionPage: 'Bottom of page',
positionDocument: 'End of document',
affix: 'Before / after the number',
prefix: 'Before',
suffix: 'After',
styles: 'Styles',
bodyStyle: 'Note text',
citationStyle: 'Note anchor',
separator: 'Separator line',
sepWidth: 'Length (% of the text width)',
sepWeight: 'Thickness (pt)',
sepAbove: 'Space above (cm)',
sepBelow: 'Space below (cm)',
sepAlign: 'Alignment',
sepColor: 'Color',
reset: 'Reset to LibreOffice defaults',
},
ribbon: {
tabs: {
file: 'File',
+13
View File
@@ -133,3 +133,16 @@ export function loadNoteSettings(): NoteSettings {
export function saveNoteSettings(s: NoteSettings): void {
localStorage.setItem(KEY, JSON.stringify(s));
}
// The separator's geometry on :root, where editor.css draws the line from it and
// pageBreaks.ts reads back the band it has to reserve. The three lengths are
// registered (@property), so they resolve to px there.
export function applyNoteVars(s: NoteSettings): void {
const root = document.documentElement.style;
root.setProperty('--note-sep-above', `${s.separator.spaceAboveCm}cm`);
root.setProperty('--note-sep-below', `${s.separator.spaceBelowCm}cm`);
root.setProperty('--note-sep-weight', `${s.separator.weightPt}pt`);
root.setProperty('--note-sep-rel-width', `${s.separator.relWidthPercent}%`);
root.setProperty('--note-sep-color', s.separator.color);
root.setProperty('--note-sep-align', s.separator.align);
}
+5 -8
View File
@@ -284,11 +284,6 @@
@property --note-sep-above { syntax: '<length>'; inherits: true; initial-value: 0.1cm; }
@property --note-sep-weight { syntax: '<length>'; inherits: true; initial-value: 0.5pt; }
@property --note-sep-below { syntax: '<length>'; inherits: true; initial-value: 0.1cm; }
.paper .tiptap {
--note-sep-rel-width: 25%;
--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;
@@ -334,10 +329,12 @@
nothing. The reservation above still uses the true weight, as a hairline border
does — the line is a pixel wider than it measures, not the space it sits in. */
height: max(1px, var(--note-sep-weight));
background-image: linear-gradient(var(--note-sep-color), var(--note-sep-color));
background-size: var(--note-sep-rel-width) 100%;
/* The two non-length values carry their own default: only a registered property
takes one from @property, and the dialog writes all of them onto :root. */
background-image: linear-gradient(var(--note-sep-color, #000), var(--note-sep-color, #000));
background-size: var(--note-sep-rel-width, 25%) 100%;
background-repeat: no-repeat;
background-position: var(--note-sep-align) top;
background-position: var(--note-sep-align, left) top;
}
/* Formulas (formula.ts). MathML needs a font with an OpenType MATH table or Chromium