Files
edentext/src/lib/editor/extensions/autoCorrect.ts
T
Steffen Becker 8d8bb858de 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.
2026-08-14 00:53:57 +02:00

150 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Extension } from '@tiptap/core';
import { Plugin } from '@tiptap/pm/state';
import { autoCorrect } from '../../storage/autoCorrect.svelte';
import type { AutoCorrectOptions } from '../../storage/autoCorrect';
import { spellController } from '../../spell/controller';
// AutoCorrect while typing, as LibreOffice does it. Every rule is decided from the
// block's text up to and including the character just typed, so `autoCorrectFix` is
// pure and `tests/unit/autocorrect.test.ts` covers the whole table.
// A replacement inside the examined text: `length` characters at `offset` become `text`.
export type AutoFix = { offset: number; length: number; text: string };
type Rule = {
find: RegExp;
// Which capture group the fix replaces; 0 is the whole match.
group: number;
text: string | ((m: RegExpExecArray) => string);
// Which option switches it on.
option: keyof AutoCorrectOptions;
};
// LibreOffice's quotes per language: German opens low, English high.
const QUOTES: Record<string, { open: string; close: string; openOne: string; closeOne: string }> = {
de: { open: '„', close: '“', openOne: '‚', closeOne: '‘' },
en: { open: '“', close: '”', openOne: '‘', closeOne: '’' },
};
function quotesFor(lang: string) {
return QUOTES[lang.slice(0, 2)] ?? QUOTES.en;
}
// A quote opens after nothing, whitespace, an opening bracket or a dash.
const BEFORE_OPEN = '(?:^|[\\s([{<„“‚‘\\u2013\\u2014-])';
// What ends a word for the capitalization rules.
const WORD_END = '[\\s.,;:!?]';
// Abbreviations LibreOffice's exception list also has: the sentence does not end here.
const ABBREVIATIONS = new Set([
'e.g', 'i.e', 'etc', 'vs', 'cf', 'mr', 'mrs', 'ms', 'dr', 'prof', 'no', 'fig', 'vol',
'z.b', 'd.h', 'bzw', 'ca', 'usw', 'ggf', 'evtl', 'nr', 'bspw', 'inkl', 'vgl', 'u.a', 'abb',
]);
// The rules, most specific first — the first that matches wins.
const RULES: Rule[] = [
// Replacement table (LibreOffice's default entries), applied on the closing character.
{ find: /<==>$/d, group: 0, text: '⇔', option: 'replacements' },
{ find: /<-->$/d, group: 0, text: '↔', option: 'replacements' },
{ find: /-->$/d, group: 0, text: '→', option: 'replacements' },
{ find: /<--$/d, group: 0, text: '←', option: 'replacements' },
{ find: /==>$/d, group: 0, text: '⇒', option: 'replacements' },
{ find: /<==$/d, group: 0, text: '⇐', option: 'replacements' },
{ find: /\(C\)$/di, group: 0, text: '©', option: 'replacements' },
{ find: /\(R\)$/di, group: 0, text: '®', option: 'replacements' },
{ find: /\(TM\)$/di, group: 0, text: '™', option: 'replacements' },
{ find: /\+\/-$/d, group: 0, text: '±', option: 'replacements' },
{ find: /(?<![.…])\.\.\.$/d, group: 0, text: '…', option: 'replacements' },
// Dashes, applied once the word after the dash is finished (LibreOffice's matrix).
{ find: /\S\s(--)\s\S+\s$/d, group: 1, text: '–', option: 'dashes' },
{ find: /\S\s(-)\s\S+\s$/d, group: 1, text: '–', option: 'dashes' },
{ find: /\S(--)\S+\s$/d, group: 1, text: '—', option: 'dashes' },
// Typographic quotes. The opening forms are tried first, so anything else closes.
{ find: new RegExp(`${BEFORE_OPEN}(")$`, 'd'), group: 1, text: 'open', option: 'quotes' },
{ find: /(")$/d, group: 1, text: 'close', option: 'quotes' },
{ find: new RegExp(`${BEFORE_OPEN}(')$`, 'd'), group: 1, text: 'openOne', option: 'quotes' },
{ find: /(')$/d, group: 1, text: 'closeOne', option: 'quotes' },
// TWo INitial CApitals → Two Initial Capitals.
{
find: new RegExp(`(?:^|[\\s([{"'„“‚‘])\\p{Lu}(\\p{Lu})\\p{Ll}+${WORD_END}$`, 'du'),
group: 1, text: (m) => m[1].toLowerCase(), option: 'twoInitials',
},
// First letter of a sentence, once its first word is finished.
{
find: new RegExp(`^(\\p{Ll})\\p{L}*${WORD_END}$`, 'du'),
group: 1, text: (m) => m[1].toUpperCase(), option: 'capitalize',
},
{
find: new RegExp(`[.!?]["'’“”)\\]]?\\s+(\\p{Ll})\\p{L}*${WORD_END}$`, 'du'),
group: 1, text: (m) => m[1].toUpperCase(), option: 'capitalize',
},
];
// The word carrying the full stop an "after a sentence end" match relied on —
// "z.B. der" must stay lowercase. `stop` indexes that punctuation in `before`.
const ABBREV_BEFORE = /([\p{L}.]+)[.!?]$/u;
function afterAbbreviation(before: string, stop: number): boolean {
const m = ABBREV_BEFORE.exec(before.slice(0, stop + 1));
if (!m) return false;
// A lone initial ("A. Muster") is never a sentence end either.
return m[1].length === 1 || ABBREVIATIONS.has(m[1].toLowerCase());
}
/**
* The correction the just-typed character triggers, or null.
* `before` is the block's text from its start up to and including that character;
* `offset` is an index into it.
*/
export function autoCorrectFix(before: string, opts: AutoCorrectOptions, lang = 'en'): AutoFix | null {
for (const rule of RULES) {
if (!opts[rule.option]) continue;
const m = rule.find.exec(before);
if (!m?.indices) continue;
const span = m.indices[rule.group];
if (!span) continue;
let text = typeof rule.text === 'function' ? rule.text(m) : rule.text;
if (text.startsWith('')) text = quotesFor(lang)[text.slice(1) as keyof ReturnType<typeof quotesFor>];
if (rule.option === 'capitalize' && afterAbbreviation(before, m.indices[0]![0])) continue;
if (text === before.slice(span[0], span[1])) continue;
return { offset: span[0], length: span[1] - span[0], text };
}
return null;
}
export const AutoCorrect = Extension.create({
name: 'autoCorrect',
addProseMirrorPlugins() {
return [
new Plugin({
props: {
// handleTextInput, not an input rule: the rules need the whole line before
// the caret, and consuming the keystroke here keeps this last in the chain
// (the list and rule-line input rules still fire first).
handleTextInput(view, from, to, text) {
// One typed character only — a paste or an IME commit is left alone.
if (from !== to || text.length !== 1) return false;
const $from = view.state.doc.resolve(from);
if (!$from.parent.isTextblock) return false;
const start = $from.start();
const before = view.state.doc.textBetween(start, from, undefined, ' ') + text;
const fix = autoCorrectFix(before, autoCorrect(), spellController.getLanguage());
if (!fix) return false;
// The typed character first, so `before` and the block's text agree; every
// offset is then start + index, since an inline atom counts one either way.
const tr = view.state.tr.insertText(text, from, to);
tr.insertText(fix.text, start + fix.offset, start + fix.offset + fix.length);
view.dispatch(tr.scrollIntoView());
return true;
},
},
}),
];
},
});