From 8d8bb858debb25497181d239c0d094a9d5d4908c Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 14 Aug 2026 00:53:57 +0200 Subject: [PATCH] feat: AutoCorrect while typing LibreOffice's rule set: typographic quotes per document language, its dash matrix, its replacement table, sentence capitalization with an abbreviation exception list, and TWo INitials. One handleTextInput plugin listed last, so every input rule still fires first; the rule table itself is a pure function under test. Auto-URL and auto-list were already TipTap's and now answer to the same checkboxes. --- CHANGELOG.md | 2 +- src/App.svelte | 8 ++ src/lib/components/AutoCorrectDialog.svelte | 101 ++++++++++++++++++ src/lib/components/ribbon/Ribbon.svelte | 5 +- src/lib/components/ribbon/icons.ts | 2 + .../components/ribbon/tabs/ReviewTab.svelte | 11 +- src/lib/editor/extensions.ts | 3 + src/lib/editor/extensions/CLAUDE.md | 1 + src/lib/editor/extensions/autoCorrect.ts | Bin 0 -> 6943 bytes src/lib/editor/extensions/link.ts | 3 + src/lib/i18n/locales/de.ts | 19 ++++ src/lib/i18n/locales/en.ts | 19 ++++ src/lib/storage/CLAUDE.md | 1 + src/lib/storage/autoCorrect.svelte.ts | 15 +++ src/lib/storage/autoCorrect.ts | 37 +++++++ tests/unit/autocorrect.test.ts | 73 +++++++++++++ 16 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 src/lib/components/AutoCorrectDialog.svelte create mode 100644 src/lib/editor/extensions/autoCorrect.ts create mode 100644 src/lib/storage/autoCorrect.svelte.ts create mode 100644 src/lib/storage/autoCorrect.ts create mode 100644 tests/unit/autocorrect.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea07d8d..57fed11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ - Customizable bullet symbols per list level (picker on the bullet-list split button: • ◦ ▪ ❖ ➢ ⇨ ✓ – >); round-trips to ODF `text:bullet-char` and DOCX `w:lvlText`, and the DOCX/ODT import maps Wingdings/Symbol bullets (arrows, diamonds, checkmarks …) to their Unicode equivalents instead of flattening them to plain dots. Symbols Liberation Serif lacks render from a bundled 2 KB DejaVu Sans subset (`EdenSymbols.woff2`), so markers look compact and identical on every platform instead of a stretched OS fallback - Tab stops per paragraph: left, centre, right and decimal, honoured in the rendered text (CSS only has a fixed tab grid, so each tab is measured and placed); a hanging indent implies a stop at the text position. Set them on the ruler; round-trips to ODF `style:tab-stops` and DOCX `w:tabs` - 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 - Manual line breaks (Shift+Enter) **Insert** @@ -125,7 +126,6 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08. - Text boxes in the DOCX export: lists inside a box are flattened to literal-marker paragraphs (`•` / `1.`) and images inside a box are dropped — the box XML is injected by a post-pack string pass (`export/docx.ts` `applyTextBoxesDocx`) that mints no numbering.xml or media/rels entries. The ODT export has neither limitation **Missing while writing** -- AutoCorrect while typing: only the `---` rule line exists. Missing are typographic quotes (`"…"` / `„…“` per document language), en/em dash, `--> → →`, capitalize the first letter, auto-URL, auto-list - Automatic hyphenation: `fo:hyphenate` / `w:hyphenationZone` are ignored and the editor sets no `hyphens`, so a justified paragraph breaks its lines differently than LibreOffice — a systematic source of page-count drift in render parity - Captions with numbered figures/tables ("Abbildung 1: …") and the lists built from them (list of figures / tables). The TOC machinery exists, the other index families do not - Page numbering options: the page field is decimal-only — no start value, no roman/alpha format, no restart per section diff --git a/src/App.svelte b/src/App.svelte index b6430d6..da4635d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -42,6 +42,7 @@ import UiLanguagePicker from './lib/components/UiLanguagePicker.svelte'; import AboutDialog from './lib/components/AboutDialog.svelte'; import DocPropertiesDialog from './lib/components/DocPropertiesDialog.svelte'; + import AutoCorrectDialog from './lib/components/AutoCorrectDialog.svelte'; import StyleManagerDialog from './lib/components/StyleManagerDialog.svelte'; import NoteOptionsDialog from './lib/components/NoteOptionsDialog.svelte'; import { t } from './lib/i18n/i18n.svelte'; @@ -151,6 +152,7 @@ let documentName: string = $state(loadDocName()); let docProps: DocProperties = $state(loadDocProperties()); let docPropsOpen = $state(false); + let autoCorrectOpen = $state(false); // Width of the hidden mirror span (below), so the title input grows/shrinks // with its text instead of sitting in a fixed-width box. @@ -897,6 +899,7 @@ onPrint={handlePrint} onAbout={() => (aboutOpen = true)} onDocProperties={() => (docPropsOpen = true)} + onAutoCorrect={() => (autoCorrectOpen = true)} /> {:else} @@ -1062,6 +1065,10 @@ {/if} {/each} +
{t().autoCorrect.title}
+
{t().ribbon.chrome.title}
+ + + + + + + diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index 1af798c..1c2859f 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -55,7 +55,7 @@ onSelectTheme, docxBusy = false, pdfBusy = false, - onNew, onOpen, onSave, onSaveAs, onSaveDocx, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, + onNew, onOpen, onSave, onSaveAs, onSaveDocx, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onAutoCorrect, }: { editor: Editor | null; tick: number; @@ -93,6 +93,7 @@ onPrint?: () => void; onAbout?: () => void; onDocProperties?: () => void; + onAutoCorrect?: () => void; } = $props(); const TABS = ['home', 'insert', 'layout', 'references', 'review', 'view'] as const; @@ -292,7 +293,7 @@ {:else if tab === 'references'} {:else if tab === 'review'} - + {:else if tab === 'view'} {:else if tab === 'tableDesign' || tab === 'tableLayout'} diff --git a/src/lib/components/ribbon/icons.ts b/src/lib/components/ribbon/icons.ts index 692a9f4..1befb64 100644 --- a/src/lib/components/ribbon/icons.ts +++ b/src/lib/components/ribbon/icons.ts @@ -78,6 +78,8 @@ export const STROKED = { toc: ['M2 3h6M2 6.5h5M2 10h6M2 13.5h4', 'M11 3h3M10 6.5h4M11 10h3M10 13.5h4'], tocLevels: ['M2.5 3.5h11M4.5 7h9M6.5 10.5h7M8.5 14h5'], wordCount: ['M2.5 2.5h11v11h-11z', 'M5 6h6M5 9h4'], + // An A with a tick beside it: the letter AutoCorrect fixes, corrected. + autoCorrect: ['M1.5 12 5 3.5 8.5 12', 'M2.9 9.2h4.2', 'M10 8.5l2 2 3.5-4.5'], ruler: ['M1.5 5.5h13v5h-13z', 'M4 5.5v2.2M6.5 5.5v3M9 5.5v2.2M11.5 5.5v3'], zoomIn: ['M7.25 12.5a5.25 5.25 0 1 0 0-10.5 5.25 5.25 0 0 0 0 10.5z', 'M11.2 11.2 14 14', 'M5.25 7.25h4M7.25 5.25v4'], zoomOut: ['M7.25 12.5a5.25 5.25 0 1 0 0-10.5 5.25 5.25 0 0 0 0 10.5z', 'M11.2 11.2 14 14', 'M5.25 7.25h4'], diff --git a/src/lib/components/ribbon/tabs/ReviewTab.svelte b/src/lib/components/ribbon/tabs/ReviewTab.svelte index 01634a3..5d6a662 100644 --- a/src/lib/components/ribbon/tabs/ReviewTab.svelte +++ b/src/lib/components/ribbon/tabs/ReviewTab.svelte @@ -8,11 +8,12 @@ import type { DocumentLanguage } from '../../../storage/documentLanguage'; import { t } from '../../../i18n/i18n.svelte'; - let { editor, tick, documentLanguage, onLanguage }: { + let { editor, tick, documentLanguage, onLanguage, onAutoCorrect }: { editor: Editor | null; tick: number; documentLanguage: DocumentLanguage; onLanguage: (code: DocumentLanguage) => void; + onAutoCorrect?: () => void; } = $props(); let stats = $derived.by(() => { @@ -60,6 +61,14 @@ {/if} + onAutoCorrect?.()} + />
diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 0f0e96e..9992276 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -57,6 +57,7 @@ import { ColumnsFlow } from './extensions/columnsFlow'; import { TableOfContents } from './extensions/tableOfContents'; import { Note, NoteRef, NoteSection, Notes } from './extensions/notes'; import { Shortcuts } from './extensions/shortcuts'; +import { AutoCorrect } from './extensions/autoCorrect'; import { HEADING_LEVELS } from '../export/odt'; import { styleSheet } from '../styles/sheet.svelte'; import { noteSettings } from '../storage/notes.svelte'; @@ -182,6 +183,8 @@ export const extensions = [ PageBreaks, FormattingMarks, SpellCheck, + // Last of the typing extensions: its handleTextInput runs after every input rule. + AutoCorrect, SearchReplace, // 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 16ad0fe..b64015e 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -35,6 +35,7 @@ Five topics are large enough to have their own deep-dive — read the file befor - **`trailingNode.ts`** — keeps an empty paragraph after a trailing table, text box or columns section (they are isolating, so the caret would otherwise have nowhere to go). - **`headerFooter.ts`** (`hfExtensions`) — the header/footer schema: `Document` constrained to one paragraph, the body's marks, `HardBreak`, `TextAlign`, and the `pageField.ts` atoms. No image, no date field, no table. - **`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). - **`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/autoCorrect.ts b/src/lib/editor/extensions/autoCorrect.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7bfea0cc81f57d73b3ceac021c12b0c070eab7a GIT binary patch literal 6943 zcmcgx&2HP+6`r-9;>0k9RBTi6OjmXk*GZJZKoX~pGg(NQh@!3}PAHP)y|m;w0gY&YM!d>jItbCLrr}ZgniYwsx%|TG<#7J zymd4gH8qr{WD@e@K80}*<$cg5?4w}hXMSM7=Xbd_e5>kjkxr;vQPq=h+QltQG|e+5 zWV%Laqw9Gb8qq4u#(MYowP$pff&?7mc3d(UB`lHOid44LH(ZUUsUf zKMrFE!c6Z{H&XG?j6kepQFaz$E;f%Un3fXaetSN)v!5D_wWowydH(Yhyp(~tdU`s zu!bS)0CyI`={>r~##yLf?3-ZZfq?9vGk_OMEW9Vz9hdhtrz$weGXJbbjR$0ztSvE- zh?|n0grF|&K}pQF*o9qstIm=^)yE3Q-dkf~Axy=?;4pv46IhW_I7Q^g!yJLLOTSau z*pERHVM|dm*`j7Vj6yx4Q8rtIue_nzgTfMh>?)}_7a#uWY?Z|D^cOtwMRF3)e=b+c7oQxOZ9y^2ON9!b zD_Fv*UskAmDpyM?KMNIB-5`$xhKNFCPm;`Kjg^ws#Cb9W7l{kiw0Paq5n|cBw?$vo zI<&uEta&PiMT!6sBNXcP2OOYrVnzZ900`J8Mfd<7tV!5$dkEyW%21M#Pkmp{jqf*zvc02#--p<$d>o?n-8o)_B9U-UU zK0=Qsuqq93gJ6fBhQ^P=cfRO=u}GJW@cQWZ@tfxJ$9y5*`Q8Gyxl;K9R($IWjJ4^9q`p1;KH zv6|3JW!x6b?kH~vV>+Ry_)D39zfLuO1_S;aXZBNGjpeg1uhJ}$4ROL72BFY7O(KWg z$$97X_;ueK@mKHNgueoxKXWY)!=W5krv^vJiG^rSr?MEvZ{_XjP`+|cPWn9%UF#s$ z*$OAX7SZq)jT7hvX(kAf5&$?E9xp!pRKO2EnGsATV1qcG0AEY(9KU)h;R#c&Vico=ez*U`u#@Z!A2d4 zrNBLSz6Ctz;)kE0j}oYyN?OoGtS*RbZ(m2`LWdY-{xxB zoTbSy^HbDVMQZf~06Doc${+)ck&aM!F&rTyF$PN6>c?l2B}qjZ)h*W<0KWwkaXPSd zoZHlqzqSGD4cyN%MP68;v+O1qMT@@9;yVaxY{)me{1E5W>%ir z-TzjcS1C&KOX8pvvDFxyz)AzJzlN2Thgz(~YX4c({tH;@boNoFZT`QR>@3O~QD)$0 z*%`+;Up&(5PlCTrLJoUmhAxm3p|uvU~QLr3HoG6%4V}yj&Y5xXp3t-a2phK7Hx#6Xp?qo z(inyVa_=AjipFDr5?bwnB(!n>6~u7hB2eA+1CO3TJy27P6Hlzrv{xKn%4-$E78$#C zK~ciWHqq2*G-!w3zc1J<3U3b;yL&nMz#jKEckiyzT>w6jsN$>FVJJO5}O93iW_wIaYuoUqLLd#~Uw zLYOgLSmJG~1c0Pc?&CPy7NQ6@+yRLDIHf)v+FvX&gzqjM7kBVEPYkPH!!upy3#V2q zFR*F3Y{Y9VyII=pof;3*>@4J1dUBK*>VCn}{NRI%I2GMmPAecwUw&yTw<{tXJ=$DP z_d0i4_d4Kk*)*)JxdtpQ6!FX|#0j7S|6}L!(IN^lz>qN*;G{fJ4z`Muw7po8yD+mr zo?8SmN0WqPF*&miS8-UFtXWQKy&zK(-w@ku#x|`9I#u2rhSPT&BYF^VT~5<4GY zgE5Nn1QB>*2agdZiWn6#mM>G~Eyy-S)es5kWtP>kA)NFcz_|IQxF~32Uf;_n^H(1WBOTfifSYsg{Dl<_kcI9}f{2avsFq9|c z4mma26n$_^E&eQNo&qu7wG}Mw)lmC3(}Pz47{N-Pm8guYZLl{x~K-kZ_)x> z;RGJInnuOXa2rPZR`9MkJv-}C?(~P5QhUUOz}b>yplboANV##5T{mr}1Zn)Vz&{S) zSsdOs_+2MR@)(Jjs}5U7(8NDm_A{$8lPz1qWPnhuGQ*}9s4UH3k`;3dOZF*(!DM9ywx*Mz R^U6PGMe8Ez1{-F$`rk|(z+(Ua literal 0 HcmV?d00001 diff --git a/src/lib/editor/extensions/link.ts b/src/lib/editor/extensions/link.ts index 6cf834f..1643875 100644 --- a/src/lib/editor/extensions/link.ts +++ b/src/lib/editor/extensions/link.ts @@ -1,5 +1,6 @@ import LinkBase from '@tiptap/extension-link'; import { DEFAULT_SHORTCUTS } from '../shortcuts'; +import { autoCorrect } from '../../storage/autoCorrect.svelte'; // Event the toolbar listens for to open the link dialog (Ctrl/Cmd+K). The extension // can't reach Svelte state directly, so it dispatches this on window instead. @@ -11,6 +12,8 @@ export const OPEN_LINK_DIALOG_EVENT = 'odf-open-link-dialog'; export const Link = LinkBase.configure({ openOnClick: false, autolink: true, + // LibreOffice's AutoCorrect ▸ URL recognition, switchable in its dialog. + shouldAutoLink: () => autoCorrect().urls, linkOnPaste: true, defaultProtocol: 'https', HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' }, diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index e913fe6..a36b63f 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -22,6 +22,24 @@ const de: Messages = { description: 'Kommentare', clear: 'Alle löschen', }, + autoCorrect: { + title: 'AutoKorrektur w\u00E4hrend der Eingabe', + quotes: 'Typografische Anf\u00FChrungszeichen', + dashes: 'Gedankenstriche ersetzen', + replacements: 'Ersetzungstabelle', + capitalize: 'Jeden Satz mit einem Gro\u00DFbuchstaben beginnen', + twoInitials: 'ZWei GRo\u00DFbuchstaben am Wortanfang korrigieren', + urls: 'URL-Erkennung', + reset: 'Standard wiederherstellen', + examples: { + quotes: '"a" \u2192 \u201Ea\u201C', + dashes: 'A - B \u2192 A \u2013 B', + replacements: '--> \u2192 \u2192, (C) \u2192 \u00A9', + capitalize: '', + twoInitials: 'WOrt \u2192 Wort', + urls: '', + }, + }, appearance: { language: 'Sprache', title: 'Darstellung', @@ -602,6 +620,7 @@ const de: Messages = { footnote: 'Fußnote', endnote: 'Endnote', noteOptions: 'Optionen', + autoCorrect: 'AutoKorrektur', debugDump: 'Dump', cellMargins: 'Zellenränder', altText: 'Alternativtext', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 2ccc5c1..474755c 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -23,6 +23,24 @@ const en = { description: 'Comments', clear: 'Clear all', }, + autoCorrect: { + title: 'AutoCorrect while typing', + quotes: 'Typographic quotes', + dashes: 'Replace dashes', + replacements: 'Replacement table', + capitalize: 'Capitalize first letter of every sentence', + twoInitials: 'Correct TWo INitial CApitals', + urls: 'URL recognition', + reset: 'Restore defaults', + examples: { + quotes: '"a" \u2192 \u201Ca\u201D', + dashes: 'A - B \u2192 A \u2013 B', + replacements: '--> \u2192 \u2192, (C) \u2192 \u00A9', + capitalize: '', + twoInitials: 'WOrd \u2192 Word', + urls: '', + }, + }, appearance: { language: 'Language', title: 'Appearance', @@ -606,6 +624,7 @@ const en = { footnote: 'Footnote', endnote: 'Endnote', noteOptions: 'Options', + autoCorrect: 'AutoCorrect', debugDump: 'Dump', cellMargins: 'Cell margins', altText: 'Alt text', diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index b6d4ae6..056529c 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -35,6 +35,7 @@ All keys share the `edentext-` prefix — a namespace, not a description: on Git - **Header/footer:** `edentext-header` / `-footer` (HfDoc), `edentext-hf-distances`. - **Styles:** `edentext-styles` — the style registry (`styles/sheet.svelte.ts`). - **Document properties:** `edentext-doc-properties` — title/subject/author/keywords/comments (`docProperties.ts`); the key is removed when every field is empty, so a fresh document writes nothing into `meta.xml`. +- **AutoCorrect:** `edentext-autocorrect` — one flag per rule (`autoCorrect.ts`, reactive singleton in `autoCorrect.svelte.ts`), LibreOffice's defaults (all on). - **Notes:** `edentext-notes` — footnote/endnote numbering and the separator (`noteSettings.ts`, reactive singleton in `notes.svelte.ts`); `applyNoteVars` puts the separator on `:root`, where `editor.css` draws it and `pageBreaks.ts` reserves its band. ## Themes (`theme.ts`, `styles/global.css`) diff --git a/src/lib/storage/autoCorrect.svelte.ts b/src/lib/storage/autoCorrect.svelte.ts new file mode 100644 index 0000000..630371c --- /dev/null +++ b/src/lib/storage/autoCorrect.svelte.ts @@ -0,0 +1,15 @@ +// The AutoCorrect options as a reactive singleton (same shape as notes.svelte.ts): +// the extension reads autoCorrect() on every keystroke, the dialog replaces it. + +import { loadAutoCorrect, saveAutoCorrect, type AutoCorrectOptions } from './autoCorrect'; + +let current = $state(loadAutoCorrect()); + +export function autoCorrect(): AutoCorrectOptions { + return current; +} + +export function setAutoCorrect(next: AutoCorrectOptions): void { + current = next; + saveAutoCorrect(current); +} diff --git a/src/lib/storage/autoCorrect.ts b/src/lib/storage/autoCorrect.ts new file mode 100644 index 0000000..9d03c8c --- /dev/null +++ b/src/lib/storage/autoCorrect.ts @@ -0,0 +1,37 @@ +// LibreOffice's Tools ▸ AutoCorrect Options ▸ Options / Localized Options, on by +// default as they are there. `urls` gates the Link extension's autolink, the rest +// the rules in editor/extensions/autoCorrect.ts. + +export type AutoCorrectOptions = { + quotes: boolean; + dashes: boolean; + replacements: boolean; + capitalize: boolean; + twoInitials: boolean; + urls: boolean; +}; + +const KEY = 'edentext-autocorrect'; + +export const DEFAULT_AUTOCORRECT: AutoCorrectOptions = { + quotes: true, dashes: true, replacements: true, capitalize: true, twoInitials: true, urls: true, +}; + +export const AUTOCORRECT_KEYS = Object.keys(DEFAULT_AUTOCORRECT) as (keyof AutoCorrectOptions)[]; + +export function loadAutoCorrect(): AutoCorrectOptions { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return { ...DEFAULT_AUTOCORRECT }; + const data = JSON.parse(raw) as Partial; + const out = { ...DEFAULT_AUTOCORRECT }; + for (const key of AUTOCORRECT_KEYS) if (typeof data[key] === 'boolean') out[key] = data[key]; + return out; + } catch { + return { ...DEFAULT_AUTOCORRECT }; + } +} + +export function saveAutoCorrect(opts: AutoCorrectOptions): void { + localStorage.setItem(KEY, JSON.stringify(opts)); +} diff --git a/tests/unit/autocorrect.test.ts b/tests/unit/autocorrect.test.ts new file mode 100644 index 0000000..8f8df33 --- /dev/null +++ b/tests/unit/autocorrect.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { autoCorrectFix } from '../../src/lib/editor/extensions/autoCorrect'; +import { DEFAULT_AUTOCORRECT, type AutoCorrectOptions } from '../../src/lib/storage/autoCorrect'; + +// `before` is the block text up to and including the character just typed; the fix is +// applied to it, so a case reads as "type this, get that". +function typed(before: string, lang = 'en', opts: AutoCorrectOptions = DEFAULT_AUTOCORRECT): string { + const fix = autoCorrectFix(before, opts, lang); + if (!fix) return before; + return before.slice(0, fix.offset) + fix.text + before.slice(fix.offset + fix.length); +} + +describe('autoCorrectFix', () => { + it('replaces LibreOffice\'s default table entries', () => { + expect(typed('a --> ')).toBe('a --> '); // only on the closing character + expect(typed('a -->')).toBe('a →'); + expect(typed('a <--')).toBe('a ←'); + expect(typed('a <-->')).toBe('a ↔'); + expect(typed('a ==>')).toBe('a ⇒'); + expect(typed('a <==>')).toBe('a ⇔'); + expect(typed('(C)')).toBe('©'); + expect(typed('x (tm)')).toBe('x ™'); + expect(typed('and...')).toBe('and…'); + expect(typed('+/-')).toBe('±'); + }); + + it('replaces dashes the way LibreOffice does', () => { + expect(typed('A - B ')).toBe('A – B '); + expect(typed('A -- B ')).toBe('A – B '); + expect(typed('A--B ')).toBe('A—B '); + expect(typed('A-B ')).toBe('A-B '); + expect(typed('A - B')).toBe('A - B'); // not until the word is finished + }); + + it('opens and closes quotes per language', () => { + expect(typed('"')).toBe('“'); + expect(typed('say "')).toBe('say “'); + expect(typed('say “word"')).toBe('say “word”'); + expect(typed('"', 'de')).toBe('„'); + expect(typed('sag „Wort"', 'de')).toBe('sag „Wort“'); + expect(typed("don'")).toBe('don’'); // an apostrophe mid-word closes, as in LibreOffice + expect(typed("'")).toBe('‘'); + }); + + it('capitalizes the first letter of a sentence', () => { + expect(typed('hello ')).toBe('Hello '); + expect(typed('Done. now ')).toBe('Done. Now '); + expect(typed('Really? yes ')).toBe('Really? Yes '); + expect(typed('Hello ')).toBe('Hello '); + }); + + it('leaves an abbreviation and an initial alone', () => { + expect(typed('z.B. der ', 'de')).toBe('z.B. der '); + expect(typed('etc. and ')).toBe('etc. and '); + expect(typed('A. muster ')).toBe('A. muster '); + }); + + it('fixes TWo INitial CApitals', () => { + expect(typed('WOrd ')).toBe('Word '); + expect(typed('the WOrd.')).toBe('the Word.'); + expect(typed('ABC ')).toBe('ABC '); + expect(typed('Word ')).toBe('Word '); + }); + + it('honours the switches', () => { + const off = { ...DEFAULT_AUTOCORRECT, quotes: false, dashes: false, replacements: false, capitalize: false, twoInitials: false }; + expect(typed('"', 'en', off)).toBe('"'); + expect(typed('A - B ', 'en', off)).toBe('A - B '); + expect(typed('a -->', 'en', off)).toBe('a -->'); + expect(typed('hello ', 'en', off)).toBe('hello '); + expect(typed('WOrd ', 'en', off)).toBe('WOrd '); + }); +});