feat: AutoText keeps a block of text under a shortcut

LibreOffice's Tools ▸ AutoText, in the ribbon where Word keeps its Quick
Parts: store the selection under a name and a shortcut, insert it from the
library or by typing the shortcut and pressing F3 — LibreOffice's key for
it.

An entry is HTML parsed back through the schema, so it keeps its marks and
blocks; the library is app-level, as it is in both products, so no file
carries it.
This commit is contained in:
Steffen Becker committed 2026-08-15 10:33:16 +02:00
1 parent ee801967f6
commit 405b175e49
16 files changed
+384 -4

No files matched your search

+2 -1
View File
@@ -57,6 +57,7 @@
- Paragraph borders and shading: per-side borders with presets (all / single edges / none), width and color, plus a background fill, on paragraphs and headings; typing `---`, `___` or `===` on a line of its own turns it into a rule line (Word/LibreOffice AutoCorrect). Round-trips to ODF `fo:border-*`/`fo:background-color` and DOCX `w:pBdr`/`w:shd`
- AutoCorrect while typing (LibreOffice's Tools ▸ AutoCorrect Options, one checkbox per rule): typographic quotes per document language (`"…"` / `„…“`), its dash matrix (`A - B` → `A – B`, `A--B` → `A—B`), its replacement table (`-->` → `→`, `(C)` → `©`, `...` → `…`), capitalize the first letter of a sentence — with an abbreviation exception list — and TWo INitial CApitals. URL recognition and auto-list ride the same switches
- Word completion (the same dialog's own section, as LibreOffice keeps it): every word of at least eight letters is remembered as it is typed, and typing its first three letters offers the rest in grey after the caret — Enter takes the offer, Esc drops it. It is offered while typing only, so an Enter meant to split a paragraph never completes a word instead, and the offer stays outside the document until it is accepted. Minimum length, an appended space and clearing the collected words are all settable
- AutoText (LibreOffice's Tools ▸ AutoText, Word's Insert ▸ Quick Parts): keep a selected block of text under a name and a shortcut, then insert it from the library or by typing the shortcut and pressing F3 — LibreOffice's own key. An entry keeps its formatting, and the library belongs to the app rather than to a document, as it does in both products
- Automatic hyphenation for the whole document (Layout ▸ Hyphenation): the browser hyphenates in the document's own language, which shortens a justified paragraph the way LibreOffice does. Round-trips as ODF `fo:hyphenate` on the base style — where LibreOffice keeps it, in its *text* properties — and Word's `w:autoHyphenation`
- Manual line breaks (Shift+Enter)
@@ -161,7 +162,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14.
- Linked / chained text frames
- Find & Replace by font, size or colour — the weight/slant/underline and paragraph-style half is implemented, but LibreOffice's Format dialog is the whole character dialog
- Multi-document management: one document is open at a time, so there is no window list and no side-by-side compare
- Thesaurus, grammar check, AutoText / building blocks
- Thesaurus and grammar check
- Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby)
- Password-protected ODT/DOCX; digital signatures
- Split view: two views of one document, scrolled independently
+7
View File
@@ -54,6 +54,7 @@
import NavigatorPane from './lib/components/NavigatorPane.svelte';
import { OPEN_COMMENT_EVENT } from './lib/editor/extensions/comment';
import AutoCorrectDialog from './lib/components/AutoCorrectDialog.svelte';
import AutoTextDialog from './lib/components/AutoTextDialog.svelte';
import StyleManagerDialog from './lib/components/StyleManagerDialog.svelte';
import NoteOptionsDialog from './lib/components/NoteOptionsDialog.svelte';
import { t } from './lib/i18n/i18n.svelte';
@@ -173,6 +174,7 @@
let pageDecor: PageDecor = $state(loadPageDecor());
let lineNumbering: LineNumbering = $state(loadLineNumbering());
let autoCorrectOpen = $state(false);
let autoTextOpen = $state(false);
let commentsOpen = $state(false);
let revisionsOpen = $state(false);
let navigatorOpen = $state(false);
@@ -1044,6 +1046,7 @@
onAbout={() => (aboutOpen = true)}
onDocProperties={() => (docPropsOpen = true)}
onAutoCorrect={() => (autoCorrectOpen = true)}
onAutoText={() => (autoTextOpen = true)}
onNewComment={addComment}
{commentsOpen}
onToggleComments={() => (commentsOpen = !commentsOpen)}
@@ -1235,6 +1238,9 @@
<button class="theme-option" onclick={() => { themeOpen = false; autoCorrectOpen = true; }} role="menuitem">
<span>{t().ribbon.autoCorrect}</span>
</button>
<button class="theme-option" onclick={() => { themeOpen = false; autoTextOpen = true; }} role="menuitem">
<span>{t().autoText.title}</span>
</button>
<div class="theme-heading">{t().ribbon.chrome.title}</div>
<button
class="theme-option"
@@ -1427,6 +1433,7 @@
<AboutDialog bind:open={aboutOpen} />
<AutoCorrectDialog bind:open={autoCorrectOpen} />
<AutoTextDialog bind:open={autoTextOpen} editor={activeEditor} />
<DocPropertiesDialog bind:open={docPropsOpen} props={docProps} onApply={(p) => { docProps = p; saveDocProperties(p); }} />
<!-- One instance for every entry point (styles gallery, insert-table menu): the
callers only say which family to land on. -->
+165
View File
@@ -0,0 +1,165 @@
<script lang="ts">
import { getHTMLFromFragment, type Editor } from '@tiptap/core';
import { autoTextEntries, putAutoText, removeAutoText } from '../storage/autoText.svelte';
import { t } from '../i18n/i18n.svelte';
// LibreOffice's Tools ▸ AutoText / Word's Quick Parts: the library, plus "new from
// selection". Inserting one puts it where the caret is.
let { open = $bindable(false), editor }: { open?: boolean; editor: Editor | null } = $props();
let dialogEl = $state<HTMLDialogElement | null>(null);
let name = $state('');
let shortcut = $state('');
// Re-read on each open: the dialog stays mounted, and a modal freezes the selection
// it was opened on.
const hasSelection = $derived(open && !!editor && !editor.state.selection.empty);
$effect(() => {
const el = dialogEl;
if (!el) return;
if (open && !el.open) el.showModal();
else if (!open && el.open) el.close();
});
function add() {
const slice = editor?.state.selection.content();
if (!editor || !slice || !name.trim()) return;
const html = getHTMLFromFragment(slice.content, editor.schema);
putAutoText({ name: name.trim(), shortcut: shortcut.trim(), html });
name = '';
shortcut = '';
}
function insert(html: string) {
editor?.chain().focus().insertContent(html).run();
open = false;
}
</script>
<dialog
bind:this={dialogEl}
onclose={() => (open = false)}
onclick={(e) => e.target === dialogEl && (open = false)}
aria-label={t().autoText.title}
>
<div class="body">
<h2>{t().autoText.title}</h2>
{#if autoTextEntries().length}
<ul>
{#each autoTextEntries() as entry (entry.name)}
<li>
<button class="entry" onclick={() => insert(entry.html)} title={t().autoText.insertEntry}>
<span class="entry-name">{entry.name}</span>
{#if entry.shortcut}<span class="entry-key">{entry.shortcut}</span>{/if}
</button>
<button class="small" onclick={() => removeAutoText(entry.name)} title={t().common.remove}>×</button>
</li>
{/each}
</ul>
<p class="hint">{t().autoText.f3Hint}</p>
{:else}
<p class="hint">{t().autoText.empty}</p>
{/if}
<h3>{t().autoText.newEntry}</h3>
<div class="new-row">
<input bind:value={name} placeholder={t().autoText.name} disabled={!hasSelection} />
<input class="key" bind:value={shortcut} placeholder={t().autoText.shortcut} disabled={!hasSelection} />
<button class="small" onclick={add} disabled={!hasSelection || !name.trim()}>{t().common.insert}</button>
</div>
{#if !hasSelection}<p class="hint">{t().autoText.needsSelection}</p>{/if}
<div class="actions">
<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: 8px;
width: 420px;
max-height: 80vh;
overflow-y: auto;
padding: 18px 20px 16px;
font-family: var(--font-sans);
font-size: 0.85rem;
}
h2 { font-size: 1rem; }
h3 { font-size: 0.85rem; padding-top: 8px; border-top: 1px solid var(--color-border); }
.hint { color: var(--color-text-muted); }
ul { display: flex; flex-direction: column; gap: 4px; list-style: none; }
li { display: flex; align-items: center; gap: 6px; }
.entry {
display: flex;
flex: 1;
align-items: baseline;
gap: 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
padding: 4px 8px;
font: inherit;
text-align: left;
cursor: pointer;
}
.entry:hover { background: var(--color-btn-hover); }
.entry-name { flex: 1; }
.entry-key { color: var(--color-text-muted); font-family: var(--font-mono, monospace); }
.new-row { display: flex; gap: 6px; }
input {
flex: 1;
min-width: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 4px 6px;
font: inherit;
}
.key { flex: 0 0 6rem; }
.actions { display: flex; align-items: center; gap: 8px; padding-top: 6px; }
.spacer { flex: 1; }
button.small, .actions button {
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 4px 10px;
font: inherit;
cursor: pointer;
}
button:hover:not(:disabled) { background: var(--color-btn-hover); }
button:disabled { opacity: 0.5; cursor: default; }
.actions .primary {
border-color: var(--color-primary);
background: var(--color-primary);
color: #fff;
}
</style>
+7
View File
@@ -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
+3 -2
View File
@@ -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'}
<HomeTab {editor} {tick} bind:showFormattingMarks {onManageStyles} {onFind} onParagraphDialog={() => (paragraphDialogOpen = true)} />
{:else if tab === 'insert'}
<InsertTab {editor} {tick} {hfActive} {pageMargins} {pageOrientation} {pageFormat} {onEditZone} {onManageTableStyles} />
<InsertTab {editor} {tick} {hfActive} {pageMargins} {pageOrientation} {pageFormat} {onEditZone} {onManageTableStyles} {onAutoText} />
{:else if tab === 'layout'}
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat bind:extraHfSections bind:hyphenate bind:pageNumbering bind:pageDecor bind:lineNumbering onParagraphDialog={() => (paragraphDialogOpen = true)} />
{:else if tab === 'references'}
+2
View File
@@ -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 = {
@@ -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 @@
<div class="ribbon-sep"></div>
<RibbonGroup label={t().ribbon.groups.text}>
<RibbonButton
variant="big"
icon="quickParts"
label={t().autoText.title}
title={t().autoText.title}
disabled={!editor || !onAutoText}
onclick={() => onAutoText?.()}
/>
<div class="rb-captioned" use:captionClicks>
<DateTimePicker
bind:open={dateOpen}
+3
View File
@@ -67,6 +67,7 @@ import { Note, NoteRef, NoteSection, Notes } from './extensions/notes';
import { Shortcuts } from './extensions/shortcuts';
import { AutoCorrect } from './extensions/autoCorrect';
import { WordCompletion } from './extensions/wordCompletion';
import { AutoText } from './extensions/autoText';
import { HEADING_LEVELS, MAX_HEADING_LEVEL } from '../export/odt';
import { styleSheet } from '../styles/sheet.svelte';
import { noteSettings } from '../storage/notes.svelte';
@@ -222,6 +223,8 @@ export const extensions = [
TrackChanges.configure({ recording: recordChanges, author: () => 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).
+1
View File
@@ -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 `<span data-page-field>`; `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
+45
View File
@@ -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<ReturnType> {
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() };
},
});
+10
View File
@@ -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',
+10
View File
@@ -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',
+1
View File
@@ -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` `<draw:custom-shape>` 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.
+24
View File
@@ -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<AutoTextEntry[]>(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));
}
+38
View File
@@ -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;
}
+56
View File
@@ -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: '<p>Yours, <strong>Ada</strong></p>' };
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('<p>Bye. mfg</p>');
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('<p>Bye. mfx</p>');
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('<p>Bye.</p>');
e.commands.setTextSelection(e.state.doc.content.size - 1);
expect(e.commands.insertAutoText(entry)).toBe(true);
expect(e.state.doc.textContent).toBe('Bye.Yours, Ada');
e.destroy();
});
});