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:
1 parent
9d494b2966
commit
0c74fe94b1
12 files changed
+672
-4
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user