feat: words typed once are offered back

LibreOffice's Word Completion, its defaults included: words from eight
letters up are collected as they are finished, and the first three
letters offer the rest. The offer is a widget decoration after the caret,
so it is outside the document until Enter takes it — nothing to undo when
it is not taken.

Only while typing: a bare caret move drops the offer, or an Enter meant to
split the paragraph would complete a word instead. The settings share the
AutoCorrect dialog, as they share LibreOffice's.
This commit is contained in:
Steffen Becker committed 2026-08-15 10:27:18 +02:00
1 parent 2f087bc880
commit ee801967f6
13 files changed
+404 -2

No files matched your search

+2 -1
View File
@@ -56,6 +56,7 @@
- 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
- Word completion (the same dialog's own section, as LibreOffice keeps it): every word of at least eight letters is remembered as it is typed, and typing its first three letters offers the rest in grey after the caret — Enter takes the offer, Esc drops it. It is offered while typing only, so an Enter meant to split a paragraph never completes a word instead, and the offer stays outside the document until it is accepted. Minimum length, an appended space and clearing the collected words are all settable
- Automatic hyphenation for the whole document (Layout ▸ Hyphenation): the browser hyphenates in the document's own language, which shortens a justified paragraph the way LibreOffice does. Round-trips as ODF `fo:hyphenate` on the base style — where LibreOffice keeps it, in its *text* properties — and Word's `w:autoHyphenation`
- Manual line breaks (Shift+Enter)
@@ -160,7 +161,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14.
- Linked / chained text frames
- Find & Replace by font, size or colour — the weight/slant/underline and paragraph-style half is implemented, but LibreOffice's Format dialog is the whole character dialog
- Multi-document management: one document is open at a time, so there is no window list and no side-by-side compare
- Thesaurus, grammar check, word completion, AutoText / building blocks
- Thesaurus, grammar check, AutoText / building blocks
- Vertical writing modes (ODF `tb-rl`) and Asian typography (ruby)
- Password-protected ODT/DOCX; digital signatures
- Split view: two views of one document, scrolled independently
+69 -1
View File
@@ -1,9 +1,12 @@
<script lang="ts">
import { AUTOCORRECT_KEYS, DEFAULT_AUTOCORRECT, type AutoCorrectOptions } from '../storage/autoCorrect';
import { autoCorrect, setAutoCorrect } from '../storage/autoCorrect.svelte';
import { DEFAULT_WORD_COMPLETION } from '../storage/wordCompletion';
import { wordCompletion, setWordCompletion } from '../storage/wordCompletion.svelte';
import { t } from '../i18n/i18n.svelte';
// LibreOffice's Tools ▸ AutoCorrect Options: one checkbox per rule, applied while typing.
// Word Completion is its own tab there and its own section here.
let { open = $bindable(false) }: { open?: boolean } = $props();
let dialogEl = $state<HTMLDialogElement | null>(null);
@@ -39,8 +42,51 @@
</label>
{/each}
<h3>{t().wordCompletion.title}</h3>
<label class="row">
<input
type="checkbox"
checked={wordCompletion().enabled}
onchange={(e) => setWordCompletion({ ...wordCompletion(), enabled: e.currentTarget.checked })}
/>
<span>{t().wordCompletion.enabled}<em>{t().wordCompletion.hint}</em></span>
</label>
<label class="row">
<input
type="checkbox"
checked={wordCompletion().appendSpace}
onchange={(e) => setWordCompletion({ ...wordCompletion(), appendSpace: e.currentTarget.checked })}
/>
<span>{t().wordCompletion.appendSpace}</span>
</label>
<label class="row">
<input
class="num"
type="number"
min="5"
max="20"
value={wordCompletion().minLength}
onchange={(e) => setWordCompletion({ ...wordCompletion(), minLength: Number(e.currentTarget.value) })}
/>
<span>{t().wordCompletion.minLength}</span>
</label>
<div class="row">
<button
class="link"
disabled={!wordCompletion().words.length}
onclick={() => setWordCompletion({ ...wordCompletion(), words: [] })}
>{t().wordCompletion.clear}</button>
<em>{t().wordCompletion.collected(wordCompletion().words.length)}</em>
</div>
<div class="actions">
<button class="reset" onclick={() => setAutoCorrect({ ...DEFAULT_AUTOCORRECT })}>{t().autoCorrect.reset}</button>
<button
class="reset"
onclick={() => {
setAutoCorrect({ ...DEFAULT_AUTOCORRECT });
setWordCompletion({ ...DEFAULT_WORD_COMPLETION, words: wordCompletion().words });
}}
>{t().autoCorrect.reset}</button>
<span class="spacer"></span>
<button class="primary" onclick={() => (open = false)}>{t().common.close}</button>
</div>
@@ -75,10 +121,32 @@
}
h2 { font-size: 1rem; }
h3 { font-size: 0.85rem; padding-top: 8px; border-top: 1px solid var(--color-border); }
.row { display: flex; align-items: baseline; gap: 8px; cursor: pointer; }
.row em { color: var(--color-text-muted); font-style: normal; }
.num {
width: 3.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 2px 4px;
font: inherit;
}
.link {
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: transparent;
color: var(--color-text);
padding: 2px 8px;
font: inherit;
cursor: pointer;
}
.link:disabled { opacity: 0.5; cursor: default; }
.actions { display: flex; align-items: center; gap: 8px; padding-top: 6px; }
.spacer { flex: 1; }
+2
View File
@@ -66,6 +66,7 @@ import { Outline } from './extensions/outline';
import { Note, NoteRef, NoteSection, Notes } from './extensions/notes';
import { Shortcuts } from './extensions/shortcuts';
import { AutoCorrect } from './extensions/autoCorrect';
import { WordCompletion } from './extensions/wordCompletion';
import { HEADING_LEVELS, MAX_HEADING_LEVEL } from '../export/odt';
import { styleSheet } from '../styles/sheet.svelte';
import { noteSettings } from '../storage/notes.svelte';
@@ -220,6 +221,7 @@ export const extensions = [
Deletion,
TrackChanges.configure({ recording: recordChanges, author: () => loadDocProperties().author.trim() }),
AutoCorrect,
WordCompletion,
SearchReplace.configure({ sheet: styleSheet }),
// 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).
+1
View File
@@ -43,6 +43,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`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).
- **`wordCompletion.ts`** — LibreOffice's Word Completion (its AutoCorrect dialog's own tab): every word of at least `minLength` letters is collected as it is finished, and typing its first three letters offers the rest. Drawn as a **widget decoration** after the caret, not LibreOffice's tooltip and not its selected-text insert — the offer is outside the document until Enter takes it, so nothing has to be undone when it is not. Only while **typing**: the offer is dropped on a bare caret move, or an Enter meant to split the paragraph would complete a word instead. `priority: 1000`, so its `handleKeyDown` sees Enter ahead of the keymaps. The list and its settings live in `storage/wordCompletion.svelte.ts`, the collection and matching rules in `utils/wordCompletion.ts`.
- **`shortcuts.ts`** — see `src/lib/editor/CLAUDE.md`.
`extensions.ts` deliberately keeps TipTap's `resizable: false` (its columnResizing plugin stays
@@ -0,0 +1,99 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey, type EditorState } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { isWordChar, suggestCompletion, wordBefore } from '../../utils/wordCompletion';
import { rememberWord, wordCompletion } from '../../storage/wordCompletion.svelte';
// LibreOffice's Word Completion: the words this document has used are offered back
// while typing, Enter takes the offer. It is drawn as grey text after the caret
// rather than LibreOffice's tooltip — the completion is not in the document until
// it is accepted, so nothing has to be undone when it is not.
type Suggestion = { pos: number; text: string } | null;
const key = new PluginKey<Suggestion>('wordCompletion');
const DISMISS = 'wordCompletion/dismiss';
/** What is being offered at the caret, if anything. */
export const currentCompletion = (state: EditorState): string | null => key.getState(state)?.text ?? null;
function suggestionAt(state: EditorState): Suggestion {
const opts = wordCompletion();
if (!opts.enabled || !opts.words.length) return null;
const { empty, $from } = state.selection;
if (!empty || !$from.parent.isTextblock) return null;
// Only at the end of a word: completing in the middle of one would guess at text
// that is already there.
const after = $from.parent.textBetween($from.parentOffset, $from.parent.content.size, undefined, ' ');
if (after && isWordChar(after[0])) return null;
const prefix = wordBefore($from.parent.textBetween(0, $from.parentOffset, undefined, ' '));
const tail = suggestCompletion(opts.words, prefix);
return tail ? { pos: $from.pos, text: tail } : null;
}
/** The word just finished, remembered. */
function collectAt(state: EditorState, pos: number): void {
const $pos = state.doc.resolve(pos);
if (!$pos.parent.isTextblock) return;
rememberWord(wordBefore($pos.parent.textBetween(0, $pos.parentOffset, undefined, ' ')));
}
export const WordCompletion = Extension.create({
name: 'wordCompletion',
// Ahead of the keymaps, or Enter would split the block before it can be taken as
// "accept the completion".
priority: 1000,
addProseMirrorPlugins() {
return [
new Plugin<Suggestion>({
key,
state: {
init: () => null,
// Only while typing: moving the caret to the end of a word offers nothing,
// or an Enter meant to split the paragraph would complete a word instead.
apply(tr, old, _oldState, newState) {
if (tr.getMeta(DISMISS)) return null;
if (tr.docChanged) return suggestionAt(newState);
return tr.selectionSet ? null : old;
},
},
props: {
decorations(state) {
const s = key.getState(state);
if (!s) return null;
const span = document.createElement('span');
span.className = 'word-completion';
span.textContent = s.text;
return DecorationSet.create(state.doc, [Decoration.widget(s.pos, span, { side: 1 })]);
},
// A word ends at the first character that is not part of one, which is where
// both the list and the typed word are updated.
handleTextInput(view, from, to, text) {
if (from === to && text.length === 1 && !isWordChar(text)) collectAt(view.state, from);
return false;
},
handleKeyDown(view, event) {
const s = key.getState(view.state);
if (event.key === 'Escape' && s) {
view.dispatch(view.state.tr.setMeta(DISMISS, true));
return true;
}
if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey || view.composing) {
return false;
}
if (!s) { collectAt(view.state, view.state.selection.from); return false; }
const $pos = view.state.doc.resolve(s.pos);
const word = wordBefore($pos.parent.textBetween(0, $pos.parentOffset, undefined, ' ')) + s.text;
const space = wordCompletion().appendSpace ? ' ' : '';
view.dispatch(view.state.tr.insertText(s.text + space, s.pos).scrollIntoView());
rememberWord(word);
return true;
},
},
}),
];
},
});
+9
View File
@@ -40,6 +40,15 @@ const de: Messages = {
urls: '',
},
},
wordCompletion: {
title: 'Wortergänzung',
enabled: 'Wörter während der Eingabe ergänzen',
hint: 'Eingabetaste übernimmt, Esc verwirft',
appendSpace: 'Leerzeichen anhängen',
minLength: 'Mindestwortlänge',
clear: 'Gesammelte Wörter löschen',
collected: (n) => `${n} ${n === 1 ? 'Wort' : 'Wörter'} gesammelt`,
},
navigator: {
title: 'Navigator',
empty: 'Noch keine Überschriften. Gib einem Absatz eine Überschriftenvorlage, dann erscheint er hier.',
+9
View File
@@ -41,6 +41,15 @@ const en = {
urls: '',
},
},
wordCompletion: {
title: 'Word completion',
enabled: 'Complete words while typing',
hint: 'Enter accepts, Esc dismisses',
appendSpace: 'Append a space',
minLength: 'Minimum word length',
clear: 'Delete collected words',
collected: (n: number) => `${n} word${n === 1 ? '' : 's'} collected`,
},
navigator: {
title: 'Navigator',
empty: 'No headings yet. Give a paragraph a heading style and it shows up here.',
+1
View File
@@ -13,6 +13,7 @@
- **`writingMode.ts`** — the page's text direction (`loadPageRtl`/`savePageRtl`, `edentext-page-rtl`), from ODF `style:writing-mode="rl-tb"` or Word's section-level `w:bidi`. `Editor.svelte` puts it on the body host as `dir`, which is what makes a multi-column page fill from the right; the header/footer band is left alone (LibreOffice lays those out unchanged — measured). The vertical modes are not read.
- **`pageNumbering.ts`** — the page-number field's format and start value (`edentext-page-numbering`, absent at the 1/1 default). `HeaderFooterLayer.svelte` formats each page through `formatOrdinal`; the page **count** stays decimal, as the field both word processors write does. ODF keeps the format on the page layout and the start on the first paragraph's own style (`style:page-number` — probed, ODF has no document-level start); Word keeps both in the first section's `w:pgNumType`. A **later section** can restart the count (`HfSet.pageNumberStart`): ODF writes `style:page-number` on the same paragraph that switches master page, Word a `w:pgNumType w:start` — and that section is then exported as a page-starting break, since both word processors ignore a start on a continuous one (probed: LibreOffice drops it).
- **`hyphenation.ts`** — automatic hyphenation for the whole document (`edentext-hyphenation`, off by default as in both word processors). `Editor.svelte` puts `hyphens: auto` and the document `lang` on the body host — the browser needs the language to pick its patterns. ODF keeps it as `fo:hyphenate` in the **text** properties of the base paragraph style (probed: in paragraph-properties LibreOffice ignores it and drops it on the next save), Word as `w:autoHyphenation` in settings.xml. A **paragraph** can only opt *out* (`noHyphenation`, pageBreak.ts): Word has no per-paragraph "on", and both importers drop the flag under a document that does not hyphenate, where it says what is already true.
- **`wordCompletion.ts`** / **`.svelte.ts`** — the word-completion settings *and* the collected words (`edentext-word-completion`), LibreOffice's defaults: on, no appended space, words from eight letters, 1000 of them. The list outlives the document that filled it, as LibreOffice's profile copy does; a reactive singleton, since the extension both reads it and adds to it on every keystroke while the AutoCorrect dialog edits it.
- **`tableOptions.svelte.ts`** — table number recognition (`edentext-number-recognition`, the key removed when off, as it is by default in LibreOffice). A reactive singleton like `trackChanges.svelte.ts`: the extension reads it on every transaction, both table surfaces flip it. It also holds the **number locale** — the document's language, which is what LibreOffice reads and writes a cell's number in — pushed by `App.svelte`'s language effect; the exporters take that locale from their own `language` argument instead, so they stay store-free. See `docs/architecture/tables.md`.
- **`pageDecor.ts`** — the page's own decoration (`edentext-page-decor`, the key removed when all three are off): background colour, a border around the text area at `paddingCm` from it, and a text watermark (LibreOffice's Format ▸ Watermark, field for field). All three ride the ODF page layout — the watermark as a `fontwork-plain-text` `<draw:custom-shape>` named `PowerPlusWaterMarkObject` in the **master page's header**, the only place LibreOffice reads one from (probed: hung off the master page directly it is dropped, and an undeclared `xmlns:draw` in styles.xml drops it too). Word keeps the background on `w:document` (plus `w:displayBackgroundShape`), the border in `w:sectPr` **after `w:pgMar`** — the child order is fixed, and ahead of the header references Word's reader loses those — and the watermark as the VML `_x0000_t136` shape, whose definition has to travel or LibreOffice cannot resolve it. `PageDecorLayer.svelte` draws border and watermark per page; the background is a `--color-page-bg` override on `.paper`.
- **`trackChanges.svelte.ts`** — whether the editor records revisions (`edentext-record-changes`, the key removed when off, as it is by default in both word processors). A reactive singleton because the extension reads it on every transaction while the Review tab flips it; the recorded changes themselves live in the document, on the marks.
+23
View File
@@ -0,0 +1,23 @@
// The word-completion options as a reactive singleton (like autoCorrect.svelte.ts):
// the extension reads them on every keystroke and adds each word it collects, the
// dialog replaces them.
import { collectWord } from '../utils/wordCompletion';
import { loadWordCompletion, saveWordCompletion, type WordCompletionOptions } from './wordCompletion';
let current = $state<WordCompletionOptions>(loadWordCompletion());
export function wordCompletion(): WordCompletionOptions {
return current;
}
export function setWordCompletion(next: WordCompletionOptions): void {
current = next;
saveWordCompletion(current);
}
/** One typed word remembered; a no-op where it is too short or already the newest. */
export function rememberWord(word: string): void {
const words = collectWord(current.words, word, current.minLength, current.maxEntries);
if (words !== current.words) setWordCompletion({ ...current, words });
}
+41
View File
@@ -0,0 +1,41 @@
// LibreOffice's Word Completion tab, defaults and all: on, no appended space, words
// from eight letters up, 1000 of them. The collected list lives beside the settings —
// LibreOffice keeps it in its profile, so it outlives the document that filled it.
export type WordCompletionOptions = {
enabled: boolean;
appendSpace: boolean;
minLength: number;
maxEntries: number;
words: string[];
};
const KEY = 'edentext-word-completion';
export const DEFAULT_WORD_COMPLETION: WordCompletionOptions = {
enabled: true, appendSpace: false, minLength: 8, maxEntries: 1000, words: [],
};
export function loadWordCompletion(): WordCompletionOptions {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return { ...DEFAULT_WORD_COMPLETION };
const data = JSON.parse(raw) as Partial<WordCompletionOptions>;
return {
enabled: typeof data.enabled === 'boolean' ? data.enabled : true,
appendSpace: data.appendSpace === true,
minLength: clamp(data.minLength, 5, 20, 8),
maxEntries: clamp(data.maxEntries, 50, 5000, 1000),
words: Array.isArray(data.words) ? data.words.filter((w) => typeof w === 'string') : [],
};
} catch {
return { ...DEFAULT_WORD_COMPLETION };
}
}
const clamp = (v: unknown, lo: number, hi: number, fallback: number): number =>
typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, Math.round(v))) : fallback;
export function saveWordCompletion(opts: WordCompletionOptions): void {
localStorage.setItem(KEY, JSON.stringify(opts));
}
+43
View File
@@ -0,0 +1,43 @@
// LibreOffice's Tools ▸ AutoCorrect Options ▸ Word Completion: the words a document
// has used, offered back while typing. Framework-free — the extension and the storage
// singleton both build on these.
// A word is letters, digits and the marks that hold one together. Kept deliberately
// wide (\p{L}) so an accented or Greek word collects like an ASCII one.
const WORD_CHARS = /[\p{L}\p{N}_'’-]/u;
export const isWordChar = (ch: string): boolean => WORD_CHARS.test(ch);
/** The word ending at the end of `text` — what is being typed at the cursor. */
export function wordBefore(text: string): string {
let i = text.length;
while (i > 0 && WORD_CHARS.test(text[i - 1])) i--;
return text.slice(i);
}
/**
* `word` added to the front of the list, unchanged where it is already there. Newest
* first, so the list drops the word used longest ago once it is full — LibreOffice's
* own rule for its 1000 entries.
*/
export function collectWord(list: string[], word: string, minLength: number, max: number): string[] {
if (word.length < minLength || !/^\p{L}/u.test(word)) return list;
if (list[0] === word) return list;
return [word, ...list.filter((w) => w !== word)].slice(0, max);
}
/**
* What the typed prefix completes to, or null. Case-insensitive, the typed characters
* stay as they were typed, and the shortest match wins — a longer word is the more
* surprising guess. A word the prefix already spells completes to nothing.
*/
export function suggestCompletion(list: string[], prefix: string, minPrefix = 3): string | null {
if (prefix.length < minPrefix) return null;
const lower = prefix.toLowerCase();
let best: string | null = null;
for (const word of list) {
if (word.length <= prefix.length || !word.toLowerCase().startsWith(lower)) continue;
if (!best || word.length < best.length) best = word;
}
return best ? best.slice(prefix.length) : null;
}
+7
View File
@@ -184,6 +184,13 @@
background: rgba(255, 145, 0, 0.8);
}
/* The word being offered (wordCompletion.ts): after the caret, in the text's own
font, greyed — it is not in the document until Enter takes it. */
.paper .tiptap .word-completion {
opacity: 0.45;
user-select: none;
}
/* Generated table of contents (tableOfContents.ts). A non-editable block listing
headings with dot leaders to their live page number; entries are clickable. */
.paper .tiptap .toc {
+98
View File
@@ -0,0 +1,98 @@
// LibreOffice's Word Completion: the words typed once are offered back. The list rules
// and the suggestion are pure; the plugin leg checks that the offer is only made at the
// end of a word being typed, and that Enter takes it.
import { describe, it, expect, beforeEach } from 'vitest';
import { Editor } from '@tiptap/core';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import { collectWord, suggestCompletion, wordBefore } from '../../src/lib/utils/wordCompletion';
import { WordCompletion, currentCompletion } from '../../src/lib/editor/extensions/wordCompletion';
import { setWordCompletion } from '../../src/lib/storage/wordCompletion.svelte';
import { DEFAULT_WORD_COMPLETION } from '../../src/lib/storage/wordCompletion';
describe('the collected list', () => {
it('keeps only words at least as long as the minimum', () => {
expect(collectWord([], 'short', 8, 100)).toEqual([]);
expect(collectWord([], 'Bibliothek', 8, 100)).toEqual(['Bibliothek']);
// A number or a stray mark is not a word to complete.
expect(collectWord([], '12345678', 8, 100)).toEqual([]);
});
it('moves a word already in it back to the front', () => {
expect(collectWord(['alpha', 'bravo'], 'bravo', 5, 100)).toEqual(['bravo', 'alpha']);
// Already newest: the same array back, so nothing is written.
const list = ['bravo', 'alpha'];
expect(collectWord(list, 'bravo', 5, 100)).toBe(list);
});
it('drops the word used longest ago once it is full', () => {
expect(collectWord(['alpha', 'bravo'], 'delta', 5, 2)).toEqual(['delta', 'alpha']);
});
});
describe('the suggestion', () => {
const list = ['Bibliotheksverwaltung', 'Bibliothek', 'Bahnhof'];
it('completes case-insensitively, shortest match first', () => {
expect(suggestCompletion(list, 'Bibl')).toBe('iothek');
expect(suggestCompletion(list, 'bibl')).toBe('iothek');
expect(suggestCompletion(list, 'Bibliotheksv')).toBe('erwaltung');
});
it('says nothing before three letters, or for a word already whole', () => {
expect(suggestCompletion(list, 'Bi')).toBeNull();
expect(suggestCompletion(list, 'Bibliothek')).toBe('sverwaltung');
expect(suggestCompletion(list, 'Bahnhof')).toBeNull();
expect(suggestCompletion(list, 'Zeppelin')).toBeNull();
});
it('reads the word at the caret out of the line before it', () => {
expect(wordBefore('one two thr')).toBe('thr');
expect(wordBefore('one two ')).toBe('');
expect(wordBefore('l’Étoile')).toBe('l’Étoile');
});
});
describe('the offer in the editor', () => {
beforeEach(() => setWordCompletion({ ...DEFAULT_WORD_COMPLETION, words: ['Bibliothek'] }));
function makeEditor(content = '<p></p>') {
const el = document.createElement('div');
document.body.appendChild(el);
return new Editor({ element: el, extensions: [Document, Paragraph, Text, WordCompletion], content });
}
it('offers the rest of the word while it is being typed', () => {
const e = makeEditor();
e.commands.insertContent('Bibl');
expect(currentCompletion(e.state)).toBe('iothek');
e.destroy();
});
it('offers nothing in the middle of a word', () => {
const e = makeEditor('<p>Bibend</p>');
e.commands.setTextSelection(4); // between "Bib" and "end"
e.commands.insertContent('l');
expect(e.state.doc.textContent).toBe('Biblend');
expect(currentCompletion(e.state)).toBeNull();
e.destroy();
});
it('offers nothing where the caret only moved', () => {
const e = makeEditor('<p>Bibl</p>');
e.commands.setTextSelection(5);
expect(currentCompletion(e.state)).toBeNull();
e.destroy();
});
it('takes the offer on Enter, and leaves the paragraph whole', () => {
const e = makeEditor();
e.commands.insertContent('Bibl');
e.view.dom.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
expect(e.state.doc.textContent).toBe('Bibliothek');
expect(e.state.doc.childCount).toBe(1);
expect(currentCompletion(e.state)).toBeNull();
e.destroy();
});
});