+ { docProps = p; saveDocProperties(p); }} />
diff --git a/src/lib/components/AutoTextDialog.svelte b/src/lib/components/AutoTextDialog.svelte
new file mode 100644
index 0000000..fc8b342
--- /dev/null
+++ b/src/lib/components/AutoTextDialog.svelte
@@ -0,0 +1,165 @@
+
+
+
+
+
diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md
index ba38fd0..9d34564 100644
--- a/src/lib/components/CLAUDE.md
+++ b/src/lib/components/CLAUDE.md
@@ -81,6 +81,13 @@ and built the same way: one row per change **id** (a paragraph boundary splits o
into several ranges), in the author's own colour, click to select the text, accept or
reject in place through `acceptRevision`/`rejectRevision`. Opened from the Review tab.
+## AutoText (`AutoTextDialog.svelte`, `editor/extensions/autoText.ts`)
+
+LibreOffice's Tools ▸ AutoText, reachable where Word puts it too (the ribbon's Insert ▸
+Text group, the classic chrome's Tools menu): the library, a click to insert, and "new
+from selection" — the selected slice serialized to HTML. `hasSelection` is derived off
+`open` because the dialog stays mounted and a modal freezes the selection it opened on.
+
## Navigator (`NavigatorPane.svelte`, `editor/extensions/outline.ts`)
LibreOffice's Navigator and Word's Navigation pane: the outline, click to jump, and the
diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte
index c8d97b4..116aaae 100644
--- a/src/lib/components/ribbon/Ribbon.svelte
+++ b/src/lib/components/ribbon/Ribbon.svelte
@@ -63,7 +63,7 @@
onSelectTheme,
docxBusy = false,
pdfBusy = false,
- onNew, onOpen, onSave, onSaveAs, onSaveDocx, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect, onNewComment, commentsOpen = false, onToggleComments, revisionsOpen = false, onToggleRevisions,
+ onNew, onOpen, onSave, onSaveAs, onSaveDocx, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect, onAutoText, onNewComment, commentsOpen = false, onToggleComments, revisionsOpen = false, onToggleRevisions,
navigatorOpen = false, onToggleNavigator,
recentFiles = [], onOpenRecent, onForgetRecent,
}: {
@@ -113,6 +113,7 @@
onAbout?: () => void;
onDocProperties?: () => void;
onAutoCorrect?: () => void;
+ onAutoText?: () => void;
onNewComment?: () => void;
commentsOpen?: boolean;
onToggleComments?: () => void;
@@ -332,7 +333,7 @@
{#if tab === 'home'}
(paragraphDialogOpen = true)} />
{:else if tab === 'insert'}
-
+
{:else if tab === 'layout'}
(paragraphDialogOpen = true)} />
{:else if tab === 'references'}
diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts
index 0948d60..f720e7b 100644
--- a/src/lib/components/ribbon/icons.ts
+++ b/src/lib/components/ribbon/icons.ts
@@ -137,6 +137,8 @@ export const STROKED = {
footer: ['M3.5 1.75h9v12.5h-9z', 'M5.4 4h5.2M5.4 6.1h5.2M5.4 8.2h3.1'],
pageNumber: ['M3.25 1.75h9.5v12.5h-9.5z', 'M6.9 6.4 8 5.3v5.2', 'M6.75 10.5h2.5'],
pageBreak: ['M2 2.5h12M2 13.5h12', 'M8 5.5v5.2', 'M6.2 8.9 8 10.7l1.8-1.8'],
+ // A page with one saved block picked out of its text: the stored entry.
+ quickParts: ['M3 1.75h10v12.5H3z', 'M5.2 4.4h5.6v3H5.2z', 'M5.2 9.9h5.6M5.2 12h3.4'],
} as const;
export const FILLED = {
diff --git a/src/lib/components/ribbon/tabs/InsertTab.svelte b/src/lib/components/ribbon/tabs/InsertTab.svelte
index f193bc2..850eea4 100644
--- a/src/lib/components/ribbon/tabs/InsertTab.svelte
+++ b/src/lib/components/ribbon/tabs/InsertTab.svelte
@@ -24,7 +24,7 @@
let {
editor, tick, hfActive = null, pageMargins, pageOrientation, pageFormat,
- onEditZone, onManageTableStyles,
+ onEditZone, onManageTableStyles, onAutoText,
}: {
editor: Editor | null;
tick: number;
@@ -34,6 +34,7 @@
pageFormat: PageFormat;
onEditZone?: (zone: HfZone) => void;
onManageTableStyles?: (family: StyleFamily) => void;
+ onAutoText?: () => void;
} = $props();
let hasSelection = $derived(tick >= 0 && !!editor && !editor.state.selection.empty);
@@ -255,6 +256,14 @@
+ onAutoText?.()}
+ />
loadDocProperties().author.trim() }),
AutoCorrect,
WordCompletion,
+ // Named blocks of text kept beside the documents; F3 expands a typed shortcut.
+ AutoText,
SearchReplace.configure({ sheet: styleSheet }),
// Word/LibreOffice key bindings that aren't TipTap defaults; body:true adds the
// ones the header/footer schema has no commands for (headings, line spacing, fields).
diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md
index c991a33..e5a9cae 100644
--- a/src/lib/editor/extensions/CLAUDE.md
+++ b/src/lib/editor/extensions/CLAUDE.md
@@ -44,6 +44,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`pageField.ts`** — inline atoms `pageNumber`/`pageCount`/`chapterField`, rendered as ``; `HeaderFooterLayer.svelte` patches each span's text to the real per-page value. `chapterField` is the running head (ODF `text:chapter`, Word `STYLEREF`): its `level` is the outline level to show, `text` the file's cached name, and the page's own chapter is the last heading at or above that level to have started (`Editor.svelte` collects the heading→page map on each pagination pass, only when a zone shows one).
- **`autoCorrect.ts`** — AutoCorrect while typing (LibreOffice's Tools ▸ AutoCorrect Options): typographic quotes per document language, its dash matrix, its replacement table, sentence capitalization and TWo INitials. One `handleTextInput` plugin, listed **last** in `extensions.ts` so every input rule (list markers, the rule line) still fires first; `autoCorrectFix(before, opts, lang)` is pure — the whole table is `tests/unit/autocorrect.test.ts`. URL recognition and auto-list are TipTap's own (`Link autolink` — gated by the same options — and the list input rules).
- **`wordCompletion.ts`** — LibreOffice's Word Completion (its AutoCorrect dialog's own tab): every word of at least `minLength` letters is collected as it is finished, and typing its first three letters offers the rest. Drawn as a **widget decoration** after the caret, not LibreOffice's tooltip and not its selected-text insert — the offer is outside the document until Enter takes it, so nothing has to be undone when it is not. Only while **typing**: the offer is dropped on a bare caret move, or an Enter meant to split the paragraph would complete a word instead. `priority: 1000`, so its `handleKeyDown` sees Enter ahead of the keymaps. The list and its settings live in `storage/wordCompletion.svelte.ts`, the collection and matching rules in `utils/wordCompletion.ts`.
+- **`autoText.ts`** — LibreOffice's AutoText / Word's Quick Parts: a named block of text, inserted from the library (`AutoTextDialog.svelte`) or by typing its shortcut and pressing **F3**, which is LibreOffice's key for it. An entry is stored as HTML and parsed back through the schema, so it keeps its marks and blocks and survives a schema that has grown. The library belongs to the app, not to a document (`storage/autoText.svelte.ts`) — as it does in both products, which keep it in a template of their own; nothing about it reaches a saved file.
- **`shortcuts.ts`** — see `src/lib/editor/CLAUDE.md`.
`extensions.ts` deliberately keeps TipTap's `resizable: false` (its columnResizing plugin stays
diff --git a/src/lib/editor/extensions/autoText.ts b/src/lib/editor/extensions/autoText.ts
new file mode 100644
index 0000000..b356cb8
--- /dev/null
+++ b/src/lib/editor/extensions/autoText.ts
@@ -0,0 +1,45 @@
+import { Extension, type Editor } from '@tiptap/core';
+import { wordBefore } from '../../utils/wordCompletion';
+import { entryForShortcut, type AutoTextEntry } from '../../storage/autoText';
+import { autoTextEntries } from '../../storage/autoText.svelte';
+
+// LibreOffice's AutoText: a named block of text, inserted by name or by typing its
+// shortcut and pressing F3. The entries live in storage/autoText.svelte.ts — they
+// belong to the app, not to a document, exactly as they do in both products.
+
+declare module '@tiptap/core' {
+ interface Commands {
+ autoText: {
+ insertAutoText: (entry: AutoTextEntry) => ReturnType;
+ expandAutoText: () => ReturnType;
+ };
+ }
+}
+
+/** The shortcut typed at the caret and the range it occupies, if it names an entry. */
+function shortcutAt(editor: Editor): { entry: AutoTextEntry; from: number; to: number } | null {
+ const { empty, $from } = editor.state.selection;
+ if (!empty || !$from.parent.isTextblock) return null;
+ const word = wordBefore($from.parent.textBetween(0, $from.parentOffset, undefined, ' '));
+ const entry = word ? entryForShortcut(autoTextEntries(), word) : null;
+ return entry ? { entry, from: $from.pos - word.length, to: $from.pos } : null;
+}
+
+export const AutoText = Extension.create({
+ name: 'autoText',
+
+ addCommands() {
+ return {
+ insertAutoText: (entry) => ({ commands }) => commands.insertContent(entry.html),
+ expandAutoText: () => ({ editor, chain }) => {
+ const hit = shortcutAt(editor);
+ if (!hit) return false;
+ return chain().focus().deleteRange({ from: hit.from, to: hit.to }).insertContent(hit.entry.html).run();
+ },
+ };
+ },
+
+ addKeyboardShortcuts() {
+ return { F3: () => this.editor.commands.expandAutoText() };
+ },
+});
diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts
index 59beef6..944eecd 100644
--- a/src/lib/i18n/locales/de.ts
+++ b/src/lib/i18n/locales/de.ts
@@ -40,6 +40,16 @@ const de: Messages = {
urls: '',
},
},
+ autoText: {
+ title: 'AutoText',
+ empty: 'Noch keine Einträge. Text markieren und unten hinzufügen.',
+ newEntry: 'Neu aus Auswahl',
+ name: 'Name',
+ shortcut: 'Kürzel',
+ insertEntry: 'Diesen Eintrag einfügen',
+ needsSelection: 'Zuerst den zu sichernden Text markieren',
+ f3Hint: 'Kürzel tippen und F3 drücken, um den Eintrag einzufügen.',
+ },
wordCompletion: {
title: 'Wortergänzung',
enabled: 'Wörter während der Eingabe ergänzen',
diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts
index 7851154..f2d4aa1 100644
--- a/src/lib/i18n/locales/en.ts
+++ b/src/lib/i18n/locales/en.ts
@@ -41,6 +41,16 @@ const en = {
urls: '',
},
},
+ autoText: {
+ title: 'AutoText',
+ empty: 'No entries yet. Select some text and add it below.',
+ newEntry: 'New from selection',
+ name: 'Name',
+ shortcut: 'Shortcut',
+ insertEntry: 'Insert this entry',
+ needsSelection: 'Select the text to keep first',
+ f3Hint: 'Type a shortcut and press F3 to insert its entry.',
+ },
wordCompletion: {
title: 'Word completion',
enabled: 'Complete words while typing',
diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md
index a6b9ab4..0825de0 100644
--- a/src/lib/storage/CLAUDE.md
+++ b/src/lib/storage/CLAUDE.md
@@ -14,6 +14,7 @@
- **`pageNumbering.ts`** — the page-number field's format and start value (`edentext-page-numbering`, absent at the 1/1 default). `HeaderFooterLayer.svelte` formats each page through `formatOrdinal`; the page **count** stays decimal, as the field both word processors write does. ODF keeps the format on the page layout and the start on the first paragraph's own style (`style:page-number` — probed, ODF has no document-level start); Word keeps both in the first section's `w:pgNumType`. A **later section** can restart the count (`HfSet.pageNumberStart`): ODF writes `style:page-number` on the same paragraph that switches master page, Word a `w:pgNumType w:start` — and that section is then exported as a page-starting break, since both word processors ignore a start on a continuous one (probed: LibreOffice drops it).
- **`hyphenation.ts`** — automatic hyphenation for the whole document (`edentext-hyphenation`, off by default as in both word processors). `Editor.svelte` puts `hyphens: auto` and the document `lang` on the body host — the browser needs the language to pick its patterns. ODF keeps it as `fo:hyphenate` in the **text** properties of the base paragraph style (probed: in paragraph-properties LibreOffice ignores it and drops it on the next save), Word as `w:autoHyphenation` in settings.xml. A **paragraph** can only opt *out* (`noHyphenation`, pageBreak.ts): Word has no per-paragraph "on", and both importers drop the flag under a document that does not hyphenate, where it says what is already true.
- **`wordCompletion.ts`** / **`.svelte.ts`** — the word-completion settings *and* the collected words (`edentext-word-completion`), LibreOffice's defaults: on, no appended space, words from eight letters, 1000 of them. The list outlives the document that filled it, as LibreOffice's profile copy does; a reactive singleton, since the extension both reads it and adds to it on every keystroke while the AutoCorrect dialog edits it.
+- **`autoText.ts`** / **`.svelte.ts`** — the AutoText library (`edentext-autotext`, the key removed when empty): name, shortcut and the entry's HTML. App-level, not document-level, so no exporter looks at it.
- **`tableOptions.svelte.ts`** — table number recognition (`edentext-number-recognition`, the key removed when off, as it is by default in LibreOffice). A reactive singleton like `trackChanges.svelte.ts`: the extension reads it on every transaction, both table surfaces flip it. It also holds the **number locale** — the document's language, which is what LibreOffice reads and writes a cell's number in — pushed by `App.svelte`'s language effect; the exporters take that locale from their own `language` argument instead, so they stay store-free. See `docs/architecture/tables.md`.
- **`pageDecor.ts`** — the page's own decoration (`edentext-page-decor`, the key removed when all three are off): background colour, a border around the text area at `paddingCm` from it, and a text watermark (LibreOffice's Format ▸ Watermark, field for field). All three ride the ODF page layout — the watermark as a `fontwork-plain-text` `` named `PowerPlusWaterMarkObject` in the **master page's header**, the only place LibreOffice reads one from (probed: hung off the master page directly it is dropped, and an undeclared `xmlns:draw` in styles.xml drops it too). Word keeps the background on `w:document` (plus `w:displayBackgroundShape`), the border in `w:sectPr` **after `w:pgMar`** — the child order is fixed, and ahead of the header references Word's reader loses those — and the watermark as the VML `_x0000_t136` shape, whose definition has to travel or LibreOffice cannot resolve it. `PageDecorLayer.svelte` draws border and watermark per page; the background is a `--color-page-bg` override on `.paper`.
- **`trackChanges.svelte.ts`** — whether the editor records revisions (`edentext-record-changes`, the key removed when off, as it is by default in both word processors). A reactive singleton because the extension reads it on every transaction while the Review tab flips it; the recorded changes themselves live in the document, on the marks.
diff --git a/src/lib/storage/autoText.svelte.ts b/src/lib/storage/autoText.svelte.ts
new file mode 100644
index 0000000..d3eb5ad
--- /dev/null
+++ b/src/lib/storage/autoText.svelte.ts
@@ -0,0 +1,24 @@
+// The AutoText library as a reactive singleton (like autoCorrect.svelte.ts): the
+// extension reads it on F3, the dialog adds to and removes from it.
+
+import { loadAutoText, saveAutoText, type AutoTextEntry } from './autoText';
+
+let current = $state(loadAutoText());
+
+export function autoTextEntries(): AutoTextEntry[] {
+ return current;
+}
+
+export function setAutoTextEntries(next: AutoTextEntry[]): void {
+ current = next;
+ saveAutoText(current);
+}
+
+/** Added, or replaced where an entry of that name is already there. */
+export function putAutoText(entry: AutoTextEntry): void {
+ setAutoTextEntries([...current.filter((e) => e.name !== entry.name), entry]);
+}
+
+export function removeAutoText(name: string): void {
+ setAutoTextEntries(current.filter((e) => e.name !== name));
+}
diff --git a/src/lib/storage/autoText.ts b/src/lib/storage/autoText.ts
new file mode 100644
index 0000000..ad14bda
--- /dev/null
+++ b/src/lib/storage/autoText.ts
@@ -0,0 +1,38 @@
+// LibreOffice's Tools ▸ AutoText / Word's Insert ▸ Quick Parts: named blocks of text
+// kept beside the documents, not in one. Both products store them in a template of
+// their own (`.bau`, a `.dotx`); here they live in localStorage, so they outlive the
+// document that made them.
+
+export type AutoTextEntry = {
+ name: string;
+ /** Typed in the text, then F3 — LibreOffice's shortcut, case-insensitive. */
+ shortcut: string;
+ /** The entry's content as HTML, parsed back through the schema on insert. */
+ html: string;
+};
+
+const KEY = 'edentext-autotext';
+
+export function loadAutoText(): AutoTextEntry[] {
+ try {
+ const raw = localStorage.getItem(KEY);
+ const data = raw ? (JSON.parse(raw) as AutoTextEntry[]) : [];
+ return Array.isArray(data)
+ ? data.filter((e) => e && typeof e.name === 'string' && typeof e.html === 'string')
+ .map((e) => ({ name: e.name, shortcut: String(e.shortcut ?? ''), html: e.html }))
+ : [];
+ } catch {
+ return [];
+ }
+}
+
+export function saveAutoText(list: AutoTextEntry[]): void {
+ if (list.length) localStorage.setItem(KEY, JSON.stringify(list));
+ else localStorage.removeItem(KEY);
+}
+
+/** The entry a typed shortcut names, matched as LibreOffice matches it: ignoring case. */
+export function entryForShortcut(list: AutoTextEntry[], word: string): AutoTextEntry | null {
+ const key = word.trim().toLowerCase();
+ return key ? list.find((e) => e.shortcut.toLowerCase() === key) ?? null : null;
+}
diff --git a/tests/unit/auto-text.test.ts b/tests/unit/auto-text.test.ts
new file mode 100644
index 0000000..97c7949
--- /dev/null
+++ b/tests/unit/auto-text.test.ts
@@ -0,0 +1,56 @@
+// LibreOffice's AutoText: a stored block of text, inserted by name or by typing its
+// shortcut and pressing F3. The entries belong to the app, not to a document.
+import { describe, it, expect, beforeEach } from 'vitest';
+import { Editor } from '@tiptap/core';
+import Document from '@tiptap/extension-document';
+import Paragraph from '@tiptap/extension-paragraph';
+import Text from '@tiptap/extension-text';
+import Bold from '@tiptap/extension-bold';
+import { AutoText } from '../../src/lib/editor/extensions/autoText';
+import { entryForShortcut } from '../../src/lib/storage/autoText';
+import { setAutoTextEntries } from '../../src/lib/storage/autoText.svelte';
+
+const entry = { name: 'Greeting', shortcut: 'mfg', html: '
Yours, Ada
' };
+
+describe('the shortcut', () => {
+ it('matches ignoring case, and only whole', () => {
+ expect(entryForShortcut([entry], 'MFG')?.name).toBe('Greeting');
+ expect(entryForShortcut([entry], 'mf')).toBeNull();
+ expect(entryForShortcut([entry], '')).toBeNull();
+ });
+});
+
+describe('expanding in the editor', () => {
+ beforeEach(() => setAutoTextEntries([entry]));
+
+ function makeEditor(content: string) {
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ return new Editor({ element: el, extensions: [Document, Paragraph, Text, Bold, AutoText], content });
+ }
+
+ it('replaces the typed shortcut with the entry, marks and all', () => {
+ const e = makeEditor('
Bye. mfg
');
+ e.commands.setTextSelection(e.state.doc.content.size - 1);
+ expect(e.commands.expandAutoText()).toBe(true);
+ expect(e.state.doc.textContent).toBe('Bye. Yours, Ada');
+ expect(e.state.doc.lastChild!.lastChild!.marks[0]?.type.name).toBe('bold');
+ e.destroy();
+ });
+
+ it('does nothing where the word at the caret names no entry', () => {
+ const e = makeEditor('
Bye. mfx
');
+ e.commands.setTextSelection(e.state.doc.content.size - 1);
+ expect(e.commands.expandAutoText()).toBe(false);
+ expect(e.state.doc.textContent).toBe('Bye. mfx');
+ e.destroy();
+ });
+
+ it('inserts an entry the library names, wherever the caret is', () => {
+ const e = makeEditor('