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.
150 lines
6.8 KiB
TypeScript
150 lines
6.8 KiB
TypeScript
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: ' |