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.
This commit is contained in:
1 parent
98d0d5c878
commit
8d8bb858de
16 files changed
+296
-4
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -1062,6 +1065,10 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="theme-heading">{t().autoCorrect.title}</div>
|
||||
<button class="theme-option" onclick={() => { themeOpen = false; autoCorrectOpen = true; }} role="menuitem">
|
||||
<span>{t().ribbon.autoCorrect}</span>
|
||||
</button>
|
||||
<div class="theme-heading">{t().ribbon.chrome.title}</div>
|
||||
<button
|
||||
class="theme-option"
|
||||
@@ -1233,6 +1240,7 @@
|
||||
</footer>
|
||||
|
||||
<AboutDialog bind:open={aboutOpen} />
|
||||
<AutoCorrectDialog bind:open={autoCorrectOpen} />
|
||||
<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. -->
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { AUTOCORRECT_KEYS, DEFAULT_AUTOCORRECT, type AutoCorrectOptions } from '../storage/autoCorrect';
|
||||
import { autoCorrect, setAutoCorrect } from '../storage/autoCorrect.svelte';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
|
||||
// LibreOffice's Tools ▸ AutoCorrect Options: one checkbox per rule, applied while typing.
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
let dialogEl = $state<HTMLDialogElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const el = dialogEl;
|
||||
if (!el) return;
|
||||
if (open && !el.open) el.showModal();
|
||||
else if (!open && el.open) el.close();
|
||||
});
|
||||
|
||||
function toggle(key: keyof AutoCorrectOptions, on: boolean) {
|
||||
setAutoCorrect({ ...autoCorrect(), [key]: on });
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
onclose={() => (open = false)}
|
||||
onclick={(e) => e.target === dialogEl && (open = false)}
|
||||
aria-label={t().autoCorrect.title}
|
||||
>
|
||||
<div class="body">
|
||||
<h2>{t().autoCorrect.title}</h2>
|
||||
|
||||
{#each AUTOCORRECT_KEYS as key}
|
||||
<label class="row">
|
||||
<input type="checkbox" checked={autoCorrect()[key]} onchange={(e) => toggle(key, e.currentTarget.checked)} />
|
||||
<span>
|
||||
{t().autoCorrect[key]}
|
||||
<em>{t().autoCorrect.examples[key]}</em>
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
|
||||
<div class="actions">
|
||||
<button class="reset" onclick={() => setAutoCorrect({ ...DEFAULT_AUTOCORRECT })}>{t().autoCorrect.reset}</button>
|
||||
<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; }
|
||||
|
||||
.row { display: flex; align-items: baseline; gap: 8px; cursor: pointer; }
|
||||
.row em { color: var(--color-text-muted); font-style: normal; }
|
||||
|
||||
.actions { display: flex; align-items: center; gap: 8px; padding-top: 6px; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.actions button {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
padding: 5px 14px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.actions button:hover { background: var(--color-btn-hover); }
|
||||
.actions .primary {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.actions .reset { color: var(--color-text-muted); }
|
||||
</style>
|
||||
@@ -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'}
|
||||
<ReferencesTab {editor} {tick} {hfActive} {onNoteOptions} />
|
||||
{:else if tab === 'review'}
|
||||
<ReviewTab {editor} {tick} {documentLanguage} {onLanguage} />
|
||||
<ReviewTab {editor} {tick} {documentLanguage} {onLanguage} {onAutoCorrect} />
|
||||
{:else if tab === 'view'}
|
||||
<ViewTab bind:showRuler bind:showFormattingMarks {zoom} {onZoom} {onDebugDump} />
|
||||
{:else if tab === 'tableDesign' || tab === 'tableLayout'}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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<TextStats>(() => {
|
||||
@@ -60,6 +61,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<RibbonButton
|
||||
variant="big"
|
||||
icon="autoCorrect"
|
||||
label={t().ribbon.autoCorrect}
|
||||
title={t().autoCorrect.title}
|
||||
disabled={!onAutoCorrect}
|
||||
onclick={() => onAutoCorrect?.()}
|
||||
/>
|
||||
</RibbonGroup>
|
||||
|
||||
<div class="ribbon-sep"></div>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 `<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).
|
||||
- **`shortcuts.ts`** — see `src/lib/editor/CLAUDE.md`.
|
||||
|
||||
`extensions.ts` deliberately keeps TipTap's `resizable: false` (its columnResizing plugin stays
|
||||
|
||||
Binary file not shown.
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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<AutoCorrectOptions>(loadAutoCorrect());
|
||||
|
||||
export function autoCorrect(): AutoCorrectOptions {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setAutoCorrect(next: AutoCorrectOptions): void {
|
||||
current = next;
|
||||
saveAutoCorrect(current);
|
||||
}
|
||||
@@ -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<AutoCorrectOptions>;
|
||||
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));
|
||||
}
|
||||
@@ -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 ');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user