feat: add spell-check

This commit is contained in:
Steffen Becker committed 2026-06-18 23:34:45 +02:00
1 parent 69a68525f3
commit 0a35a65c83
16 files changed
+684 -7

No files matched your search

+45
View File
@@ -34,11 +34,13 @@
"@tiptap/pm": "^3.23.6",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"nspell": "^2.1.5",
"odf-kit": "^0.13.5"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tsconfig/svelte": "^5.0.8",
"dictionary-en": "^4.0.0",
"svelte": "^5.55.9",
"typescript": "^6.0.3",
"vite": "^8.0.14"
@@ -968,6 +970,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/dictionary-en": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/dictionary-en/-/dictionary-en-4.0.0.tgz",
"integrity": "sha512-3NHnE1uq33ZE/CIwaZ6gqxa4BnglHnxeAcTM0GJ7cmtRGcvX9InMK/IqLtYcUMFCUpUNgybH+DzkqdgAo3F1zg==",
"dev": true,
"license": "(MIT AND BSD)",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
@@ -1072,6 +1085,29 @@
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/is-buffer": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -1408,6 +1444,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nspell": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/nspell/-/nspell-2.1.5.tgz",
"integrity": "sha512-PSStyugKMiD9mHmqI/CR5xXrSIGejUXPlo88FBRq5Og1kO5QwQ5Ilu8D8O5I/SHpoS+mibpw6uKA8rd3vXd2Sg==",
"license": "MIT",
"dependencies": {
"is-buffer": "^2.0.0"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+2
View File
@@ -18,6 +18,7 @@
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tsconfig/svelte": "^5.0.8",
"dictionary-en": "^4.0.0",
"svelte": "^5.55.9",
"typescript": "^6.0.3",
"vite": "^8.0.14"
@@ -48,6 +49,7 @@
"@tiptap/pm": "^3.23.6",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"nspell": "^2.1.5",
"odf-kit": "^0.13.5"
}
}
+41 -4
View File
@@ -15,6 +15,9 @@
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter';
import { loadDocName, saveDocName, stripOdtExtension, sanitizeNameForFile } from './lib/storage/documentName';
import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage';
import { spellController } from './lib/spell/controller';
import LanguagePicker from './lib/editor/LanguagePicker.svelte';
let editor: Editor | null = $state(null);
let tick: number = $state(0);
@@ -41,6 +44,10 @@
let pageMargins: PageMargins = $state(loadPageMargins());
let pageOrientation: Orientation = $state(loadOrientation());
// The document's spell-check language; round-trips through the .odt. The effect
// below persists it and switches the shared spell controller (loads the dict).
let documentLanguage: DocumentLanguage = $state(loadDocumentLanguage());
// The document name (without .odt). Source of truth for the save filename;
// set on open, editable in the header, blank → heading-derived fallback.
let documentName: string = $state(loadDocName());
@@ -71,6 +78,11 @@
saveOrientation(pageOrientation);
});
$effect(() => {
saveDocumentLanguage(documentLanguage);
void spellController.setLanguage(documentLanguage);
});
$effect(() => {
saveHfDoc('header', headerDoc);
});
@@ -169,6 +181,9 @@
// Editor.svelte re-paginates.
if (result.margins) pageMargins = result.margins;
if (result.orientation) pageOrientation = result.orientation;
// Adopt the document's spell-check language (the $effect switches the
// controller + loads its dictionary). null = file declared none; keep ours.
if (result.language) documentLanguage = result.language;
// Adopt header/footer (null clears the zone); end any active edit.
hfActive = null;
headerDoc = result.header;
@@ -219,7 +234,7 @@
exportMenuOpen = false;
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts());
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage));
fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle);
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
@@ -235,7 +250,7 @@
exportMenuOpen = false;
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts());
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage));
fileHandle = await saveAsOdt(bytes, suggestedFilename(json));
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
@@ -524,7 +539,11 @@
orientation={pageOrientation}
/>
<footer class="statusbar">
<span>Page {currentPage} of {numPages}</span>
<span class="sb-left">Page {currentPage} of {numPages}</span>
<div class="sb-center">
<LanguagePicker value={documentLanguage} onChange={(code) => (documentLanguage = code)} />
</div>
<div class="sb-right">
<div class="zoom-controls">
<button class="zoom-btn" onclick={() => setZoom(zoom - 10)} disabled={zoom <= 20} title="Zoom out">−</button>
<input
@@ -540,6 +559,7 @@
<button class="zoom-btn" onclick={() => setZoom(zoom + 10)} disabled={zoom >= 300} title="Zoom in">+</button>
<button class="zoom-pct" onclick={() => setZoom(100)} title="Reset to 100%">{zoom}%</button>
</div>
</div>
</footer>
</main>
@@ -809,11 +829,28 @@
z-index: 50;
}
/* Three zones: page count left, language picker centered, zoom right. The
equal-flex sides keep the center cell centered regardless of side widths. */
.sb-left {
flex: 1;
}
.sb-center {
flex: 0 0 auto;
display: flex;
align-items: center;
}
.sb-right {
flex: 1;
display: flex;
justify-content: flex-end;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.zoom-btn {
+74
View File
@@ -3,8 +3,12 @@
import { Editor } from '@tiptap/core';
import { Slice, Fragment } from 'prosemirror-model';
import type { Node as PmNode, MarkType } from 'prosemirror-model';
import type { EditorView } from '@tiptap/pm/view';
import { extensions } from './extensions';
import { spellErrorAt } from './spellCheck';
import { spellController } from '../spell/controller';
import TableToolbar from './TableToolbar.svelte';
import SpellContextMenu from './SpellContextMenu.svelte';
import HeaderFooterLayer from './HeaderFooterLayer.svelte';
import { saveDocument, loadDocument } from '../storage/autosave';
import { applyMarginVars, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
@@ -83,6 +87,59 @@
return (Number.isFinite(ph) ? ph : 1123) + 20;
}
// --- Spelling suggestion menu (right-click on a red-squiggled word) ---
let spellMenu = $state<{
visible: boolean; top: number; left: number;
from: number; to: number; word: string; suggestions: string[];
}>({ visible: false, top: 0, left: 0, from: 0, to: 0, word: '', suggestions: [] });
function openSpellMenu(view: EditorView, event: MouseEvent): boolean {
if (!spellController.isEnabled() || !editorContainer) return false;
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY });
if (!coords) return false;
const range = spellErrorAt(view.state, coords.pos);
if (!range) return false; // not on a misspelling → let the browser menu show
event.preventDefault();
const word = view.state.doc.textBetween(range.from, range.to);
const cRect = editorContainer.getBoundingClientRect();
spellMenu = {
visible: true,
top: event.clientY - cRect.top + editorContainer.scrollTop,
left: event.clientX - cRect.left + editorContainer.scrollLeft,
from: range.from, to: range.to, word,
suggestions: spellController.suggest(word).slice(0, 6),
};
return true;
}
function closeSpellMenu() {
if (spellMenu.visible) spellMenu = { ...spellMenu, visible: false };
}
// Replace the misspelled range, preserving the word's marks (font/bold/etc.).
function replaceSpellWord(replacement: string) {
const ed = editor;
if (!ed || !spellMenu.visible) return;
const { from, to } = spellMenu;
const { state } = ed.view;
const marks = state.doc.resolve(from).marksAcross(state.doc.resolve(to)) ?? state.doc.resolve(from).marks();
ed.view.dispatch(state.tr.replaceWith(from, to, state.schema.text(replacement, marks)).scrollIntoView());
ed.view.focus();
closeSpellMenu();
}
function addSpellWord() {
if (spellMenu.visible) spellController.addWord(spellMenu.word);
closeSpellMenu();
editor?.view.focus();
}
function ignoreSpellWord() {
if (spellMenu.visible) spellController.ignoreWord(spellMenu.word);
closeSpellMenu();
editor?.view.focus();
}
// --- Floating table-editing toolbar ---
// Shown when the selection is inside a table; positioned just above that table.
let tableUi = $state<{ visible: boolean; top: number; left: number }>({ visible: false, top: 0, left: 0 });
@@ -321,6 +378,12 @@
extensions,
content: saved || undefined,
editorProps: {
// Our SpellCheck extension draws squiggles; turn off the browser's so
// they don't double up.
attributes: { spellcheck: 'false' },
handleDOMEvents: {
contextmenu: (view, event) => openSpellMenu(view, event),
},
transformPasted(slice, view) {
const textStyleType = view.state.schema.marks.textStyle;
if (!textStyleType) return slice;
@@ -427,6 +490,17 @@
{#if tableUi.visible}
<TableToolbar {editor} top={tableUi.top} left={tableUi.left} />
{/if}
{#if spellMenu.visible}
<SpellContextMenu
top={spellMenu.top}
left={spellMenu.left}
suggestions={spellMenu.suggestions}
onReplace={replaceSpellWord}
onAdd={addSpellWord}
onIgnore={ignoreSpellWord}
onClose={closeSpellMenu}
/>
{/if}
</div>
<style>
+63
View File
@@ -0,0 +1,63 @@
<script lang="ts">
import { LANGUAGES, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
let {
value,
onChange,
}: {
value: DocumentLanguage;
onChange: (code: DocumentLanguage) => void;
} = $props();
</script>
<label class="lang-picker" title="Spell-check language">
<svg width="13" height="13" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path d="M2 13l3-8 3 8M3.2 10.5h3.6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 14.5l1.6-1.6 1.6 1.6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<select
aria-label="Spell-check language"
onchange={(e) => onChange((e.currentTarget as HTMLSelectElement).value)}
>
{#each LANGUAGES as l}
<option value={l.code} selected={l.code === value}>{l.label}</option>
{/each}
<option value={NO_LANGUAGE} selected={value === NO_LANGUAGE}>No spell check</option>
</select>
</label>
<style>
.lang-picker {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--color-text);
cursor: pointer;
}
.lang-picker svg {
flex: none;
opacity: 0.8;
}
select {
appearance: none;
border: none;
background: transparent;
color: var(--color-text);
font-family: var(--font-sans);
font-size: 0.75rem;
padding: 1px 2px;
border-radius: 2px;
cursor: pointer;
}
select:hover {
background: var(--color-btn-hover);
}
/* The option list is OS-drawn; keep its text legible in dark themes. */
option {
color: initial;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
let {
top,
left,
suggestions,
onReplace,
onAdd,
onIgnore,
onClose,
}: {
top: number;
left: number;
suggestions: string[];
onReplace: (word: string) => void;
onAdd: () => void;
onIgnore: () => void;
onClose: () => void;
} = $props();
let menuEl: HTMLDivElement | undefined = $state();
// Dismiss on outside click or Escape (standard context-menu behaviour).
$effect(() => {
const onPointerDown = (e: PointerEvent) => {
if (menuEl && !menuEl.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('pointerdown', onPointerDown, true);
window.addEventListener('keydown', onKey, true);
return () => {
window.removeEventListener('pointerdown', onPointerDown, true);
window.removeEventListener('keydown', onKey, true);
};
});
</script>
<div
bind:this={menuEl}
class="spell-menu"
style="top: {top}px; left: {left}px;"
role="menu"
tabindex="-1"
aria-label="Spelling suggestions"
>
{#if suggestions.length}
{#each suggestions as s}
<button class="sm-item sm-suggest" role="menuitem" onclick={() => onReplace(s)}>{s}</button>
{/each}
{:else}
<span class="sm-empty">No suggestions</span>
{/if}
<span class="sm-sep"></span>
<button class="sm-item" role="menuitem" onclick={onAdd}>Add to Dictionary</button>
<button class="sm-item" role="menuitem" onclick={onIgnore}>Ignore All</button>
</div>
<style>
.spell-menu {
position: absolute;
z-index: 200;
min-width: 11rem;
max-height: 18rem;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 4px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
font-family: var(--font-sans);
font-size: 0.8125rem;
/* Sibling of the zoomed .paper, so it renders constant-size regardless of zoom. */
}
.sm-item {
display: block;
width: 100%;
text-align: left;
padding: 4px 10px;
border: none;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--color-text);
cursor: pointer;
white-space: nowrap;
transition: background 0.12s;
}
.sm-item:hover {
background: var(--color-btn-hover);
}
.sm-suggest {
font-weight: 600;
}
.sm-empty {
padding: 4px 10px;
color: var(--color-text-muted, #888);
font-style: italic;
}
.sm-sep {
height: 1px;
margin: 4px 2px;
background: var(--color-border);
}
</style>
+2
View File
@@ -26,6 +26,7 @@ import { LineHeight } from './lineHeight';
import { ParagraphSpacing } from './paragraphSpacing';
import { Indent } from './indent';
import { FormattingMarks } from './formattingMarks';
import { SpellCheck } from './spellCheck';
import { TableView } from './tableView';
import { TableColumnResize } from './tableColumnResize';
import { TableRowResize } from './tableRowResize';
@@ -81,4 +82,5 @@ export const extensions = [
}),
PageBreaks,
FormattingMarks,
SpellCheck,
];
+98
View File
@@ -0,0 +1,98 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { EditorState, Transaction } from '@tiptap/pm/state';
import type { Node as PmNode } from '@tiptap/pm/model';
import { spellController } from '../spell/controller';
const spellCheckKey = new PluginKey<DecorationSet>('spellCheck');
const FORCE_RECHECK_META = 'spellCheck/forceRecheck';
// Re-check after the user pauses, so large docs stay responsive while typing.
const DEBOUNCE_MS = 400;
// A word is a letter run with internal apostrophes/hyphens only (don't,
// well-known) — never leading/trailing separators, so the checked token is
// exactly the highlighted range.
const WORD_RE = /[\p{L}\p{M}]+(?:['’\-][\p{L}\p{M}]+)*/gu;
function buildDecorations(doc: PmNode): DecorationSet {
if (!spellController.isEnabled()) return DecorationSet.empty;
const decos: Decoration[] = [];
doc.descendants((node, pos) => {
if (!node.isText) return;
const text = node.text ?? '';
WORD_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = WORD_RE.exec(text)) !== null) {
const word = m[0];
if (word.length < 2 || spellController.check(word)) continue;
const from = pos + m.index;
decos.push(Decoration.inline(from, from + word.length, { class: 'pm-spell-error' }));
}
});
return decos.length ? DecorationSet.create(doc, decos) : DecorationSet.empty;
}
// The misspelled-word range covering `pos`, if any — used by the context menu.
export function spellErrorAt(state: EditorState, pos: number): { from: number; to: number } | null {
const set = spellCheckKey.getState(state);
if (!set) return null;
const found = set.find(pos, pos);
return found.length ? { from: found[0].from, to: found[0].to } : null;
}
export const SpellCheck = Extension.create({
name: 'spellCheck',
addProseMirrorPlugins() {
return [
new Plugin<DecorationSet>({
key: spellCheckKey,
state: {
init: () => DecorationSet.empty,
apply(tr: Transaction, old: DecorationSet, _oldState: EditorState, newState: EditorState) {
// Full recompute on demand; otherwise keep squiggles glued to text by
// mapping them through the change (a debounced recheck follows).
if (tr.getMeta(FORCE_RECHECK_META) === true) return buildDecorations(newState.doc);
return tr.docChanged ? old.map(tr.mapping, tr.doc) : old;
},
},
props: {
decorations(state) {
return spellCheckKey.getState(state);
},
},
view(editorView) {
let timer: ReturnType<typeof setTimeout> | undefined;
const recheck = () => {
editorView.dispatch(editorView.state.tr.setMeta(FORCE_RECHECK_META, true));
};
const scheduleRecheck = () => {
if (timer !== undefined) clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
recheck();
}, DEBOUNCE_MS);
};
// Language / personal-dictionary / ignore changes re-check immediately.
const unsubscribe = spellController.subscribe(recheck);
// Initial pass in case the checker is already loaded at mount.
queueMicrotask(recheck);
return {
update(view, prevState) {
if (!view.state.doc.eq(prevState.doc)) scheduleRecheck();
},
destroy() {
if (timer !== undefined) clearTimeout(timer);
unsubscribe();
},
};
},
}),
];
},
});
+13 -3
View File
@@ -638,13 +638,23 @@ function rezipOdt(files: Record<string, Uint8Array>): Uint8Array {
// Rewrite styles.xml to match the editor's preview: default font Liberation Serif →
// Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes &
// margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES).
function rewriteStylesXml(odtBytes: Uint8Array): Uint8Array {
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null): Uint8Array {
const files = unzipSync(odtBytes);
const stylesBytes = files['styles.xml'];
if (!stylesBytes) return odtBytes;
let styles = strFromU8(stylesBytes);
// Document spell-check language: set fo:language/fo:country on the base
// Standard paragraph style, which every paragraph inherits from. LibreOffice
// and Word read this as the document default language.
if (lang) {
styles = styles.replace(
/(<style:style style:name="Standard"[\s\S]*?<style:text-properties\b[^>]*?)\/>/,
`$1 fo:language="${lang.language}" fo:country="${lang.country}"/>`,
);
}
// The only occurrences of "Liberation Serif" in styles.xml are the default
// font-face declaration and the Standard style's font-name attributes.
styles = styles.split(ODFKIT_DEFAULT_FONT).join(EXPORT_FONT);
@@ -925,7 +935,7 @@ export type HfExport = {
};
// The full document → .odt pipeline, DOM-free; returns the .odt bytes.
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport): Promise<Uint8Array> {
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null): Promise<Uint8Array> {
const raw = replaceHardBreaks(docJson);
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
@@ -1039,7 +1049,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const cleaned = collapseRunWhitespace(styledRows);
const withBreaks = applyLineBreaks(cleaned);
const withStyles = rewriteStylesXml(withBreaks);
const withStyles = rewriteStylesXml(withBreaks, language ?? null);
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist);
}
+19
View File
@@ -4,6 +4,7 @@ import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { DEFAULT_ORDERED_TYPE, orderedTypeFromFormat } from '../editor/orderedListTypes';
import { PX_PER_CM, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
// .odt → TipTap JSON, inverting export/odt.ts. Anything the editor can express
// becomes its native node/mark/attr; values matching the editor's defaults are
@@ -29,6 +30,9 @@ export interface OdtImportResult {
// Edge→zone distance (cm): header from top, footer from bottom. null = no zone.
headerDistanceCm: number | null;
footerDistanceCm: number | null;
// Document spell-check language; NO_LANGUAGE when the file's language has no
// bundled dictionary; null when the file declares none.
language: DocumentLanguage | null;
warnings: string[];
}
@@ -96,6 +100,20 @@ export function importOdt(bytes: Uint8Array): OdtImportResult {
const geometry = resolver.pageGeometry();
const edge = resolver.edgeDistancesCm();
const odfLang = resolver.documentLanguage();
let language: DocumentLanguage | null = null;
if (odfLang) {
const code = languageFromOdf(odfLang.language, odfLang.country || undefined);
if (code) {
language = code;
} else {
language = NO_LANGUAGE;
const tag = odfLang.country ? `${odfLang.language}-${odfLang.country}` : odfLang.language;
warnings.add(`Spell-check language "${tag}" has no bundled dictionary — spell check was turned off`);
}
}
return {
content: { type: 'doc', content: blocks },
margins: geometry?.margins ?? null,
@@ -104,6 +122,7 @@ export function importOdt(bytes: Uint8Array): OdtImportResult {
footer: hf.footer ? convertHfZone(hf.footer, ctx) : null,
headerDistanceCm: hf.header ? edge?.top ?? null : null,
footerDistanceCm: hf.footer ? edge?.bottom ?? null : null,
language,
warnings: [...warnings],
};
}
+9
View File
@@ -197,6 +197,15 @@ export class StyleResolver {
return styleName ? this.merged('text', styleName).text : {};
}
// The document's default spell-check language, read from the base Standard
// paragraph style (falls back to the paragraph default-style). null when unset.
documentLanguage(): { language: string; country: string } | null {
const props = this.merged('paragraph', 'Standard').text;
const language = props['fo:language'];
if (!language || language === 'none') return null;
return { language, country: props['fo:country'] ?? '' };
}
// Resolve a text-props map's font: fo:font-family wins, else style:font-name
// through the font-face declarations.
fontFamilyOf(props: PropMap): string | null {
+86
View File
@@ -0,0 +1,86 @@
import { loadChecker, type Checker } from './dictionary';
import { loadDocumentLanguage, type DocumentLanguage } from '../storage/documentLanguage';
const PERSONAL_KEY = 'odf-editor-user-dictionary';
function loadPersonal(): string[] {
try {
const raw = localStorage.getItem(PERSONAL_KEY);
const arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr.filter((w): w is string => typeof w === 'string') : [];
} catch {
return [];
}
}
// Singleton bridging the static TipTap extension to the live spell state. The
// extension subscribes and re-decorates whenever the active checker, personal
// dictionary, or ignore set changes.
class SpellController {
private code: DocumentLanguage = '';
private checker: Checker | null = null;
private token = 0;
private readonly personal = new Set<string>(loadPersonal());
private readonly ignored = new Set<string>(); // session-only "Ignore all"
private readonly subs = new Set<() => void>();
constructor() {
if (typeof localStorage !== 'undefined') {
void this.setLanguage(loadDocumentLanguage());
}
}
getLanguage(): DocumentLanguage {
return this.code;
}
isEnabled(): boolean {
return this.checker !== null;
}
async setLanguage(code: DocumentLanguage): Promise<void> {
if (code === this.code) return;
this.code = code;
const token = ++this.token;
const checker = await loadChecker(code);
if (token !== this.token) return; // a newer setLanguage superseded this one
if (checker) for (const w of this.personal) checker.add(w);
this.checker = checker;
this.notify();
}
check(word: string): boolean {
if (!this.checker) return true;
if (this.ignored.has(word)) return true;
return this.checker.correct(word);
}
suggest(word: string): string[] {
return this.checker ? this.checker.suggest(word) : [];
}
addWord(word: string): void {
if (!word || this.personal.has(word)) return;
this.personal.add(word);
localStorage.setItem(PERSONAL_KEY, JSON.stringify([...this.personal]));
this.checker?.add(word);
this.notify();
}
ignoreWord(word: string): void {
if (!word || this.ignored.has(word)) return;
this.ignored.add(word);
this.notify();
}
subscribe(cb: () => void): () => void {
this.subs.add(cb);
return () => this.subs.delete(cb);
}
private notify(): void {
for (const cb of this.subs) cb();
}
}
export const spellController = new SpellController();
+38
View File
@@ -0,0 +1,38 @@
import nspell, { type NSpell } from 'nspell';
import { NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
export type Checker = NSpell;
// One in-flight/resolved load per code, so switching back to a language is
// instant and concurrent callers share a single fetch.
const cache = new Map<string, Promise<Checker | null>>();
async function fetchText(url: string): Promise<string> {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
return res.text();
}
async function build(code: string): Promise<Checker> {
// Vendored assets: public/dictionaries/<code>/<code>.{aff,dic}. BASE_URL keeps
// this correct under a non-root deploy base.
const base = `${import.meta.env.BASE_URL}dictionaries/${code}/${code}`;
const [aff, dic] = await Promise.all([fetchText(`${base}.aff`), fetchText(`${base}.dic`)]);
return nspell(aff, dic);
}
// Lazily load (and cache) the Hunspell checker for a language. Resolves to null
// for NO_LANGUAGE or when the dictionary can't be fetched.
export function loadChecker(code: DocumentLanguage): Promise<Checker | null> {
if (code === NO_LANGUAGE) return Promise.resolve(null);
let pending = cache.get(code);
if (!pending) {
pending = build(code).catch((err) => {
cache.delete(code); // allow a retry after a transient failure
console.error(`[spell] failed to load dictionary "${code}":`, err);
return null;
});
cache.set(code, pending);
}
return pending;
}
+11
View File
@@ -0,0 +1,11 @@
declare module 'nspell' {
export interface NSpell {
correct(word: string): boolean;
suggest(word: string): string[];
spell(word: string): { correct: boolean; forbidden: boolean; warn: boolean };
add(word: string, model?: string): NSpell;
remove(word: string): NSpell;
wordCharacters(): string[] | undefined;
}
export default function nspell(aff: string, dic: string): NSpell;
}
+60
View File
@@ -0,0 +1,60 @@
// The document's spell-check language. One value per document, persisted to
// localStorage and round-tripped through the .odt (fo:language/fo:country on
// the default paragraph style). 'none' disables checking.
export const NO_LANGUAGE = 'none';
export type DocumentLanguage = string;
export interface LanguageDef {
code: string;
label: string;
// Dictionary assets live at public/dictionaries/<code>/<code>.{aff,dic}.
odf: { language: string; country: string };
}
// Languages with a bundled Hunspell dictionary. Add one by dropping a folder in
// public/dictionaries/<code>/ and appending an entry here.
export const LANGUAGES: LanguageDef[] = [
{ code: 'en', label: 'English (US)', odf: { language: 'en', country: 'US' } },
];
export const DEFAULT_LANGUAGE: DocumentLanguage = 'en';
const KEY = 'odf-editor-doc-language';
export function findLanguage(code: DocumentLanguage): LanguageDef | undefined {
return LANGUAGES.find((l) => l.code === code);
}
function isValid(code: string): boolean {
return code === NO_LANGUAGE || !!findLanguage(code);
}
export function loadDocumentLanguage(): DocumentLanguage {
const code = localStorage.getItem(KEY);
return code && isValid(code) ? code : DEFAULT_LANGUAGE;
}
export function saveDocumentLanguage(code: DocumentLanguage): void {
localStorage.setItem(KEY, code);
}
// → ODF fo:language/fo:country for export; null when checking is off.
export function odfFromLanguage(code: DocumentLanguage): { language: string; country: string } | null {
return findLanguage(code)?.odf ?? null;
}
// ODF fo:language(/country) → a known code, else null (caller maps to 'none').
// Matches on language first, preferring an exact country match when present.
export function languageFromOdf(language: string, country?: string): DocumentLanguage | null {
const lang = language.toLowerCase();
const ctry = country?.toUpperCase();
const byLang = LANGUAGES.filter((l) => l.odf.language.toLowerCase() === lang);
if (!byLang.length) return null;
if (ctry) {
const exact = byLang.find((l) => l.odf.country.toUpperCase() === ctry);
if (exact) return exact.code;
}
return byLang[0].code;
}
+12
View File
@@ -378,6 +378,13 @@
user-select: none;
}
/* Spell-check: red wavy underline under misspelled words (drawn by spellCheck.ts).
skip-ink:none keeps the wave continuous under descenders. */
.tiptap .pm-spell-error {
text-decoration: red wavy underline;
text-decoration-skip-ink: none;
}
/* Print styles: proper A4 pagination */
@media print {
.editor {
@@ -405,6 +412,11 @@
min-height: auto;
}
/* Squiggles are an editing aid, never printed. */
.tiptap .pm-spell-error {
text-decoration: none;
}
.paper .tiptap > *[data-page-break] {
margin-top: 0 !important;
page-break-before: always;