feat: add spell-check
This commit is contained in:
1 parent
69a68525f3
commit
0a35a65c83
16 files changed
+684
-7
No files matched your search
Generated
+45
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+11
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user