From 405b175e49a9a148fc4c97cf8cadaa257c5a7825 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 15 Aug 2026 10:33:16 +0200 Subject: [PATCH] feat: AutoText keeps a block of text under a shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 3 +- src/App.svelte | 7 + src/lib/components/AutoTextDialog.svelte | 165 ++++++++++++++++++ src/lib/components/CLAUDE.md | 7 + src/lib/components/ribbon/Ribbon.svelte | 5 +- src/lib/components/ribbon/icons.ts | 2 + .../components/ribbon/tabs/InsertTab.svelte | 11 +- src/lib/editor/extensions.ts | 3 + src/lib/editor/extensions/CLAUDE.md | 1 + src/lib/editor/extensions/autoText.ts | 45 +++++ src/lib/i18n/locales/de.ts | 10 ++ src/lib/i18n/locales/en.ts | 10 ++ src/lib/storage/CLAUDE.md | 1 + src/lib/storage/autoText.svelte.ts | 24 +++ src/lib/storage/autoText.ts | 38 ++++ tests/unit/auto-text.test.ts | 56 ++++++ 16 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 src/lib/components/AutoTextDialog.svelte create mode 100644 src/lib/editor/extensions/autoText.ts create mode 100644 src/lib/storage/autoText.svelte.ts create mode 100644 src/lib/storage/autoText.ts create mode 100644 tests/unit/auto-text.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 90d82cc..a08bb65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/App.svelte b/src/App.svelte index fe7cce8..785139d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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 @@ +
{t().ribbon.chrome.title}
+ + + {/each} + +

{t().autoText.f3Hint}

+ {:else} +

{t().autoText.empty}

+ {/if} + +

{t().autoText.newEntry}

+
+ + + +
+ {#if !hasSelection}

{t().autoText.needsSelection}

{/if} + +
+ + +
+ + + + 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('

Bye.

'); + 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(); + }); +});