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 0000000..c7bfea0 Binary files /dev/null and b/src/lib/editor/extensions/autoCorrect.ts differ 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 '); + }); +});