diff --git a/package-lock.json b/package-lock.json index 4e9e693..3647ba4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3b013a3..75618f9 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/App.svelte b/src/App.svelte index 5335a66..c9de978 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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[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[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} />
- Page {currentPage} of {numPages} + Page {currentPage} of {numPages} +
+ (documentLanguage = code)} /> +
+
setZoom(zoom + 10)} disabled={zoom >= 300} title="Zoom in">+
+
@@ -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 { diff --git a/src/lib/editor/Editor.svelte b/src/lib/editor/Editor.svelte index e6cc8e0..4fbb261 100644 --- a/src/lib/editor/Editor.svelte +++ b/src/lib/editor/Editor.svelte @@ -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} {/if} + {#if spellMenu.visible} + + {/if} diff --git a/src/lib/editor/SpellContextMenu.svelte b/src/lib/editor/SpellContextMenu.svelte new file mode 100644 index 0000000..f34114d --- /dev/null +++ b/src/lib/editor/SpellContextMenu.svelte @@ -0,0 +1,111 @@ + + + + + diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 2a38f6b..3076244 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -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, ]; diff --git a/src/lib/editor/spellCheck.ts b/src/lib/editor/spellCheck.ts new file mode 100644 index 0000000..86fe118 --- /dev/null +++ b/src/lib/editor/spellCheck.ts @@ -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('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({ + 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 | 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(); + }, + }; + }, + }), + ]; + }, +}); diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 9f11f90..f409c40 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -638,13 +638,23 @@ function rezipOdt(files: Record): 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( + /(]*?)\/>/, + `$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 { +export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null): Promise { 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); } diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index e64753c..c147982 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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], }; } diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 68e8b19..3b4a0a8 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -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 { diff --git a/src/lib/spell/controller.ts b/src/lib/spell/controller.ts new file mode 100644 index 0000000..236973a --- /dev/null +++ b/src/lib/spell/controller.ts @@ -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(loadPersonal()); + private readonly ignored = new Set(); // 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 { + 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(); diff --git a/src/lib/spell/dictionary.ts b/src/lib/spell/dictionary.ts new file mode 100644 index 0000000..319f939 --- /dev/null +++ b/src/lib/spell/dictionary.ts @@ -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>(); + +async function fetchText(url: string): Promise { + 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 { + // Vendored assets: public/dictionaries//.{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 { + 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; +} diff --git a/src/lib/spell/nspell.d.ts b/src/lib/spell/nspell.d.ts new file mode 100644 index 0000000..a791bfe --- /dev/null +++ b/src/lib/spell/nspell.d.ts @@ -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; +} diff --git a/src/lib/storage/documentLanguage.ts b/src/lib/storage/documentLanguage.ts new file mode 100644 index 0000000..37f8bed --- /dev/null +++ b/src/lib/storage/documentLanguage.ts @@ -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//.{aff,dic}. + odf: { language: string; country: string }; +} + +// Languages with a bundled Hunspell dictionary. Add one by dropping a folder in +// public/dictionaries// 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; +} diff --git a/src/styles/editor.css b/src/styles/editor.css index 954b115..fdc9507 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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;