Phase 7: browser-side spell check (nspell, en-US)
Vendored Hunspell en.aff/en.dic into web/public/dictionaries/en/ (served as a static asset + embedded in the binary, kept out of the JS bundle). dictionary-en moved to a devDep — only used to source the files. - useSpellChecker (App-level, loads once/session): fetches the dict, builds an nspell instance, replays a localStorage personal word list; addWord persists and bumps a version so consumers re-decorate. Ambient types in src/types/nspell.d.ts (the package ships none). - SpellCheck Tiptap extension: misspellings as ProseMirror decorations (no stored marks), recomputed on edit / caret move / checker swap. Latin-only tokenizer so CJK is never flagged; skips short tokens + all-caps acronyms; exempts the caret word to avoid mid-typing jitter. Reuses mapOffset (now exported from SuggestionHighlight); wordAt resolves the exact span on click. - MisspellCard: soft rose wavy underline, bilingual popover with up to 5 nspell corrections (click to replace) + add-to-dictionary. Closes on outside-pointer, edit, or doc switch. Chinese spell check intentionally omitted — nspell is dictionary-based and English-only; Chinese typos (homophone 别字) need an LLM, out of v1 scope. tsc/vite/go build+vet clean; live server serves both dict files; nspell behavior smoke-tested. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -67,8 +67,12 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
|||||||
- [x] Distraction-free mode — entered on editor focus (`EditorCore` `onFocus` → `App.setFocusMode`), the doc-list sidebar slides left + collapses to 0 width (`.petal-sidebar`/`.petal-sidebar-hidden`, 280ms), editor canvas re-centers full-width. Restored by Escape or a pointer-down outside the centered canvas (gutters, header, status bar via `handleChromeDown` + `canvasRef` containment check)
|
- [x] Distraction-free mode — entered on editor focus (`EditorCore` `onFocus` → `App.setFocusMode`), the doc-list sidebar slides left + collapses to 0 width (`.petal-sidebar`/`.petal-sidebar-hidden`, 280ms), editor canvas re-centers full-width. Restored by Escape or a pointer-down outside the centered canvas (gutters, header, status bar via `handleChromeDown` + `canvasRef` containment check)
|
||||||
- [x] **Companion mascot** (`web/src/components/Companion/`) — cozy corner mascot that reacts to the writing session. `useCompanion` is the library-agnostic behavior engine (cheers on accept/milestones, Mandarin-first writing tips, screen-break reminders after a long stretch, idle naps + welcome-back); `PetalCompanion` renders it + a CJK-first speech bubble (zh prominent, en subtitle — Note #17). Animation via `lottie-web/build/player/lottie_light` (offline, no eval/CDN) behind a `LottiePlayer` wrapper that **auto-crops the asset to its content bbox** (unions getBBox across 6 frames → square viewBox) so stock files with empty artboard padding fill the badge. **Selectable companions** (`companions.ts` roster): clicking the mascot opens a bilingual picker ("选个小伙伴 · Choose a companion") to switch between **瞌睡猫 Sleepy Cat** (`sleeping-cat.json`, `alwaysAsleep` → every mood maps to the sleeping loop, so she snoozes yet still mumbles tips/cheers — a deliberate gag) and **开心狗 Happy Dog** (`happy-dog.json`, awake/bouncy; naps via 😴 emoji). Choice persists in `localStorage` (`petal.companion`). Add a companion = drop a pure-vector Lottie JSON in `animations/` + append a `COMPANIONS` entry. `napping = companion.alwaysAsleep || mood === 'sleeping'` drives the sway/zzz. Each asset is auto-cropped to its content bbox by `LottiePlayer`. App feeds it `editTick`/`acceptTick` + `wordCount`/`saveStatus`. All copy bilingual in `tips.ts`. (`resolveJsonModule` enabled in tsconfig for the JSON import.)
|
- [x] **Companion mascot** (`web/src/components/Companion/`) — cozy corner mascot that reacts to the writing session. `useCompanion` is the library-agnostic behavior engine (cheers on accept/milestones, Mandarin-first writing tips, screen-break reminders after a long stretch, idle naps + welcome-back); `PetalCompanion` renders it + a CJK-first speech bubble (zh prominent, en subtitle — Note #17). Animation via `lottie-web/build/player/lottie_light` (offline, no eval/CDN) behind a `LottiePlayer` wrapper that **auto-crops the asset to its content bbox** (unions getBBox across 6 frames → square viewBox) so stock files with empty artboard padding fill the badge. **Selectable companions** (`companions.ts` roster): clicking the mascot opens a bilingual picker ("选个小伙伴 · Choose a companion") to switch between **瞌睡猫 Sleepy Cat** (`sleeping-cat.json`, `alwaysAsleep` → every mood maps to the sleeping loop, so she snoozes yet still mumbles tips/cheers — a deliberate gag) and **开心狗 Happy Dog** (`happy-dog.json`, awake/bouncy; naps via 😴 emoji). Choice persists in `localStorage` (`petal.companion`). Add a companion = drop a pure-vector Lottie JSON in `animations/` + append a `COMPANIONS` entry. `napping = companion.alwaysAsleep || mood === 'sleeping'` drives the sway/zzz. Each asset is auto-cropped to its content bbox by `LottiePlayer`. App feeds it `editTick`/`acceptTick` + `wordCount`/`saveStatus`. All copy bilingual in `tips.ts`. (`resolveJsonModule` enabled in tsconfig for the JSON import.)
|
||||||
|
|
||||||
### Phase 7 — Spell check
|
### Phase 7 — Spell check ✅
|
||||||
- [ ] nspell browser-side (en-US), vendor dictionaries
|
- [x] nspell browser-side (en-US), vendor dictionaries — Hunspell `en.aff`/`en.dic` (from `dictionary-en`, now a devDep) vendored into `web/public/dictionaries/en/` (+ upstream `LICENSE`); Vite copies them to `dist/`, the Go binary embeds them. ~550KB `.dic` stays out of the JS bundle, fetched as a static asset.
|
||||||
|
- [x] `useSpellChecker` hook (App-level, loads **once per session** not per doc) — `fetch`es aff+dic, builds an `nspell` instance, replays a personal word list from `localStorage` (`petal.spell.personal`); `addWord` persists + bumps a `version` so the checker's identity changes and consumers re-decorate. Exposes a minimal `SpellChecker` ({ `correct`, `suggest` }). Ambient types in `src/types/nspell.d.ts` (package ships none).
|
||||||
|
- [x] `SpellCheck` Tiptap extension — ProseMirror **decorations** (no stored marks, same as the AI-suggestion layer), recomputed on doc edit / caret move / checker swap. English-only tokenizer (`/[A-Za-z][A-Za-z']*/`) so **CJK is never tokenized → never flagged** (north-star: the user writes Mandarin + English); skips <2-char tokens and all-caps acronyms, trims edge apostrophes. Exempts the word under the caret (no jitter mid-typing). Reuses `mapOffset` (now exported from `SuggestionHighlight`) for atom-aware offset→PM-pos mapping. `wordAt(doc, pos)` resolves the exact span under a click (robust to duplicate misspellings).
|
||||||
|
- [x] `MisspellCard` popover + EditorCore wiring — soft **rose wavy underline** (`.petal-misspelling`, pastel take on the red squiggle, not classic red). Click a flagged word → `posAtCoords`→`wordAt` opens a bilingual card ("拼写 · Spelling") with up to 5 nspell corrections as pills (click to replace via `insertContentAt`) + "添加到词典 · Add to dictionary". Closes on outside-pointer-down, doc edit, or doc switch.
|
||||||
|
- Verified: tsc clean, vite build OK (dict in `dist/dictionaries/en/`), go build/vet clean; live server serves both dict files (200, 3086B aff / 551762B dic); nspell smoke (`helllo→hello`, `recieve→receive`, `写作` untokenized, `NASA` ok, `add()` persists).
|
||||||
|
|
||||||
### Deferred (post-v1-local)
|
### Deferred (post-v1-local)
|
||||||
- [ ] Authentik OIDC auth + session middleware
|
- [ ] Authentik OIDC auth + session middleware
|
||||||
@@ -84,4 +88,5 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
|||||||
- 2026-06-25: **Phase 5 complete.** Tier-1 voice-consistency pass. Backend: `internal/llm/voice.go` (`RunVoice` — whole document, no `TruncateDoc`, MaxTokens 2048, `VoiceInterval` 20s per-doc floor), standalone `voiceSystemPrompt`/`VoiceMessages` (not bundled with the grammar checkpoint). `internal/suggestions`: `POST /api/docs/:id/voice` route; `check`/`voice` collapsed into a shared `runPass(limiter, pass, scope)`; `pendingScope` makes `replacePending` family-aware (grammar deletes `type != 'voice'`, voice deletes `type = 'voice'`), so the two passes never clobber each other's pending flags; both endpoints now return the **unified** pending set (also fixed a latent throttle-returns-full-set vs success-returns-batch inconsistency). Frontend: `api.voiceDoc`, `useCheckpoint` → `voicing`/`runVoice` (shared run-token guard, reset on doc switch), honey "Check my voice 🍯" pill in `Toolbar` (→ "Reading…" while in flight), breathing honey dot + "Reading your voice…" in `StatusBar`. Voice flags' `replacement: null` round-trips to `""`; `SuggestionCard` already hides the diff row + Accept for those. Tests: `TestVoicePassCoexists` (coexistence both directions, unified response, null→"" replacement). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM: grammar check → grammar flag; voice pass → unified `[grammar@0, voice@62 (empty replacement)]`, grammar preserved. Known limitation: `findRange` is single-textblock, so a voice passage crossing a `\n\n` paragraph break won't decorate (deferred). Next: **Phase 6 (design system & polish).**
|
- 2026-06-25: **Phase 5 complete.** Tier-1 voice-consistency pass. Backend: `internal/llm/voice.go` (`RunVoice` — whole document, no `TruncateDoc`, MaxTokens 2048, `VoiceInterval` 20s per-doc floor), standalone `voiceSystemPrompt`/`VoiceMessages` (not bundled with the grammar checkpoint). `internal/suggestions`: `POST /api/docs/:id/voice` route; `check`/`voice` collapsed into a shared `runPass(limiter, pass, scope)`; `pendingScope` makes `replacePending` family-aware (grammar deletes `type != 'voice'`, voice deletes `type = 'voice'`), so the two passes never clobber each other's pending flags; both endpoints now return the **unified** pending set (also fixed a latent throttle-returns-full-set vs success-returns-batch inconsistency). Frontend: `api.voiceDoc`, `useCheckpoint` → `voicing`/`runVoice` (shared run-token guard, reset on doc switch), honey "Check my voice 🍯" pill in `Toolbar` (→ "Reading…" while in flight), breathing honey dot + "Reading your voice…" in `StatusBar`. Voice flags' `replacement: null` round-trips to `""`; `SuggestionCard` already hides the diff row + Accept for those. Tests: `TestVoicePassCoexists` (coexistence both directions, unified response, null→"" replacement). go build/vet/test clean, tsc clean, vite build OK. Live smoke vs a fake vLLM: grammar check → grammar flag; voice pass → unified `[grammar@0, voice@62 (empty replacement)]`, grammar preserved. Known limitation: `findRange` is single-textblock, so a voice passage crossing a `\n\n` paragraph break won't decorate (deferred). Next: **Phase 6 (design system & polish).**
|
||||||
- 2026-06-25: **Companion kitten added** (Phase 6 extra, per user request). A cozy corner mascot that gives feedback and gentle nudges. `web/src/components/Companion/`: `useCompanion` (behavior engine — cheer on accept/word-count milestones, Mandarin-first writing tips on a paced timer, screen-break reminder after ~25min continuous writing, idle nap after ~75s + welcome-back; priority/cooldown so it never nags), `tips.ts` (all copy bilingual, zh-first), `LottiePlayer` (wraps `lottie-web` **light** build — offline, no eval/CDN fetch, so it bundles into the Go binary), `PetalCompanion` (kitten + CJK-first speech bubble). **Ships working today with an emoji-kitten placeholder** (😺/😻/😴 per mood, CSS bob/nap/zzz); dropping a Lottie cat JSON into `animations/index.ts` is the only change to upgrade to real animation. Library decision: Lottie via `lottie-web` (not the React wrapper → no React 19 peer-dep friction; not dotLottie → no runtime CDN/wasm, stays offline-embeddable). App wires `editTick`/`acceptTick`/`wordCount`/`saveStatus`. tsc clean, vite build OK (light build trimmed ~34KB gzip vs full + removed eval warning), go build/vet/test clean. Verified headless: greeting bubble on load (“嗨~我在这儿陪你写作哦” + EN subtitle), heart-eyes celebrate + “我很喜欢这个改法 💕” on accept. **TODO (user):** source a Lottie cat asset to replace the emoji placeholder.
|
- 2026-06-25: **Companion kitten added** (Phase 6 extra, per user request). A cozy corner mascot that gives feedback and gentle nudges. `web/src/components/Companion/`: `useCompanion` (behavior engine — cheer on accept/word-count milestones, Mandarin-first writing tips on a paced timer, screen-break reminder after ~25min continuous writing, idle nap after ~75s + welcome-back; priority/cooldown so it never nags), `tips.ts` (all copy bilingual, zh-first), `LottiePlayer` (wraps `lottie-web` **light** build — offline, no eval/CDN fetch, so it bundles into the Go binary), `PetalCompanion` (kitten + CJK-first speech bubble). **Ships working today with an emoji-kitten placeholder** (😺/😻/😴 per mood, CSS bob/nap/zzz); dropping a Lottie cat JSON into `animations/index.ts` is the only change to upgrade to real animation. Library decision: Lottie via `lottie-web` (not the React wrapper → no React 19 peer-dep friction; not dotLottie → no runtime CDN/wasm, stays offline-embeddable). App wires `editTick`/`acceptTick`/`wordCount`/`saveStatus`. tsc clean, vite build OK (light build trimmed ~34KB gzip vs full + removed eval warning), go build/vet/test clean. Verified headless: greeting bubble on load (“嗨~我在这儿陪你写作哦” + EN subtitle), heart-eyes celebrate + “我很喜欢这个改法 💕” on accept. **TODO (user):** source a Lottie cat asset to replace the emoji placeholder.
|
||||||
- 2026-06-25: **Phase 6 complete.** Design system & polish. Tokens/fonts/shape/transitions were already in place from Phase 0; this phase added the two missing signature pieces. **Accept confetti**: CSS-only burst (`.petal-confetti-dot` + `@keyframes petal-confetti`, each dot's trajectory from inline `--dx`/`--dy`), a `Confetti` component in `EditorCore` spawned at the accepted card's position on `handleAccept` and cleared after 720ms (timer cleaned up on unmount). **Distraction-free mode**: `EditorCore` gains an `onFocus` → `App` `focusMode` state; the doc-list sidebar is wrapped in `.petal-sidebar` and collapses via `.petal-sidebar-hidden` (width→0 + translateX + fade, 280ms) while the centered editor canvas re-centers into the full pane; restored by Escape (window keydown) or a pointer-down outside the canvas (`handleChromeDown` checks `canvasRef` containment; wired on the header, the editor scroll-gutter, and the status bar). tsc clean, vite build OK, go build/vet/test clean; binary boots and serves the rebuilt SPA with the new CSS embedded (confetti + sidebar-collapse classes verified in the served bundle). Next: **Phase 7 (browser-side spell check, nspell en-US).**
|
- 2026-06-25: **Phase 6 complete.** Design system & polish. Tokens/fonts/shape/transitions were already in place from Phase 0; this phase added the two missing signature pieces. **Accept confetti**: CSS-only burst (`.petal-confetti-dot` + `@keyframes petal-confetti`, each dot's trajectory from inline `--dx`/`--dy`), a `Confetti` component in `EditorCore` spawned at the accepted card's position on `handleAccept` and cleared after 720ms (timer cleaned up on unmount). **Distraction-free mode**: `EditorCore` gains an `onFocus` → `App` `focusMode` state; the doc-list sidebar is wrapped in `.petal-sidebar` and collapses via `.petal-sidebar-hidden` (width→0 + translateX + fade, 280ms) while the centered editor canvas re-centers into the full pane; restored by Escape (window keydown) or a pointer-down outside the canvas (`handleChromeDown` checks `canvasRef` containment; wired on the header, the editor scroll-gutter, and the status bar). tsc clean, vite build OK, go build/vet/test clean; binary boots and serves the rebuilt SPA with the new CSS embedded (confetti + sidebar-collapse classes verified in the served bundle). Next: **Phase 7 (browser-side spell check, nspell en-US).**
|
||||||
|
- 2026-06-25: **Phase 7 complete.** Browser-side spell check (nspell, en-US). Vendored Hunspell `en.aff`/`en.dic` → `web/public/dictionaries/en/` (`dictionary-en` moved to devDep; dict served as a static asset + embedded in the binary, kept out of the JS bundle). `useSpellChecker` (App-level, loads once/session) builds the nspell instance, replays a `localStorage` personal word list, `addWord` persists + bumps a version to re-decorate; `src/types/nspell.d.ts` supplies the missing types. `SpellCheck` extension renders misspellings as ProseMirror decorations (Latin-only tokenizer ⇒ CJK never flagged; skips short tokens/acronyms; exempts the caret word; reuses exported `mapOffset`; `wordAt` for click→span). `MisspellCard`: rose wavy underline, bilingual card with correction pills + add-to-dictionary. tsc/vite/go all clean; live server serves both dict files; nspell behavior smoke-tested. **All v1 phases (0–7) done.** Remaining work is the deferred post-v1 bucket (auth, Copyleaks, deploy). Next: per user — Chinese spell check is out of scope for nspell (en-only); see discussion.
|
||||||
- 2026-06-25: **Phase 4 complete.** Backend: `internal/llm/chat.go` (`StreamAskPetal` — conversational sampling params, reuses `AskPetalSystemPrompt`/`TrimHistory`), `internal/suggestions/chat.go` (`POST /api/suggestions/:id/chat` — one user-scoped join loads the suggestion + parent `content_text`, `surroundingParagraph` extracts the `\n\n`-bounded paragraph at `from_pos` with whole-doc fallback, streams `event: token`/`event: done` SSE frames with JSON-encoded data, `X-Accel-Buffering: no`, real `http.Flusher` per chunk; LLM-down → 502 before SSE headers, unknown id → 404). Handler imports the interface only. Frontend: `streamSuggestionChat` (fetch + ReadableStream SSE parser, abortable), `AskPetal.tsx` (in-component history — no persistence, pre-seeded first bubble, rose/lavender bubbles, CJK font stack per Note #17, streaming caret), `SuggestionCard` "Ask Petal ✨" pill that pins the card open (hover-close suppressed, click-away closes) and widens it to 340px. Tests: `chat_test.go` (streamed-text concat + done event, server-side context injection asserted on the system message, sampling params, 404, `surroundingParagraph` unit). go build/vet/test clean, tsc clean, vite build OK. Live SSE smoke test against a fake streaming vLLM (fresh ports 8077/8088 — a pre-existing dev petal on :8099 left untouched): tokens flushed individually through the chi middleware stack, `done` terminator, 502 on LLM-down, 404 on unknown suggestion all verified. Next: **Phase 5 (voice consistency pass, Tier 1).**
|
- 2026-06-25: **Phase 4 complete.** Backend: `internal/llm/chat.go` (`StreamAskPetal` — conversational sampling params, reuses `AskPetalSystemPrompt`/`TrimHistory`), `internal/suggestions/chat.go` (`POST /api/suggestions/:id/chat` — one user-scoped join loads the suggestion + parent `content_text`, `surroundingParagraph` extracts the `\n\n`-bounded paragraph at `from_pos` with whole-doc fallback, streams `event: token`/`event: done` SSE frames with JSON-encoded data, `X-Accel-Buffering: no`, real `http.Flusher` per chunk; LLM-down → 502 before SSE headers, unknown id → 404). Handler imports the interface only. Frontend: `streamSuggestionChat` (fetch + ReadableStream SSE parser, abortable), `AskPetal.tsx` (in-component history — no persistence, pre-seeded first bubble, rose/lavender bubbles, CJK font stack per Note #17, streaming caret), `SuggestionCard` "Ask Petal ✨" pill that pins the card open (hover-close suppressed, click-away closes) and widens it to 340px. Tests: `chat_test.go` (streamed-text concat + done event, server-side context injection asserted on the system message, sampling params, 404, `surroundingParagraph` unit). go build/vet/test clean, tsc clean, vite build OK. Live SSE smoke test against a fake streaming vLLM (fresh ports 8077/8088 — a pre-existing dev petal on :8099 left untouched): tokens flushed individually through the chi middleware stack, `done` terminator, 502 on LLM-down, 404 on unknown suggestion all verified. Next: **Phase 5 (voice consistency pass, Tier 1).**
|
||||||
|
|||||||
45
web/package-lock.json
generated
45
web/package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"@tiptap/react": "^2.11.5",
|
"@tiptap/react": "^2.11.5",
|
||||||
"@tiptap/starter-kit": "^2.11.5",
|
"@tiptap/starter-kit": "^2.11.5",
|
||||||
"lottie-web": "^5.13.0",
|
"lottie-web": "^5.13.0",
|
||||||
|
"nspell": "^2.1.5",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"dictionary-en": "^4.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0"
|
||||||
@@ -2130,6 +2132,17 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.379",
|
"version": "1.5.379",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz",
|
||||||
@@ -2283,6 +2296,29 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"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/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -2701,6 +2737,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/orderedmap": {
|
"node_modules/orderedmap": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@tiptap/react": "^2.11.5",
|
"@tiptap/react": "^2.11.5",
|
||||||
"@tiptap/starter-kit": "^2.11.5",
|
"@tiptap/starter-kit": "^2.11.5",
|
||||||
"lottie-web": "^5.13.0",
|
"lottie-web": "^5.13.0",
|
||||||
|
"nspell": "^2.1.5",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"dictionary-en": "^4.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0"
|
||||||
|
|||||||
347
web/public/dictionaries/en/LICENSE
Normal file
347
web/public/dictionaries/en/LICENSE
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
en_US Hunspell Dictionary
|
||||||
|
Version 2020.12.07
|
||||||
|
Mon Dec 7 20:14:35 2020 -0500 [5ef55f9]
|
||||||
|
http://wordlist.sourceforge.net
|
||||||
|
|
||||||
|
README file for English Hunspell dictionaries derived from SCOWL.
|
||||||
|
|
||||||
|
These dictionaries are created using the speller/make-hunspell-dict
|
||||||
|
script in SCOWL.
|
||||||
|
|
||||||
|
The following dictionaries are available:
|
||||||
|
|
||||||
|
en_US (American)
|
||||||
|
en_CA (Canadian)
|
||||||
|
en_GB-ise (British with "ise" spelling)
|
||||||
|
en_GB-ize (British with "ize" spelling)
|
||||||
|
en_AU (Australian)
|
||||||
|
|
||||||
|
en_US-large
|
||||||
|
en_CA-large
|
||||||
|
en_GB-large (with both "ise" and "ize" spelling)
|
||||||
|
en_AU-large
|
||||||
|
|
||||||
|
The normal (non-large) dictionaries correspond to SCOWL size 60 and,
|
||||||
|
to encourage consistent spelling, generally only include one spelling
|
||||||
|
variant for a word. The large dictionaries correspond to SCOWL size
|
||||||
|
70 and may include multiple spelling for a word when both variants are
|
||||||
|
considered almost equal. The larger dictionaries however (1) have not
|
||||||
|
been as carefully checked for errors as the normal dictionaries and
|
||||||
|
thus may contain misspelled or invalid words; and (2) contain
|
||||||
|
uncommon, yet valid, words that might cause problems as they are
|
||||||
|
likely to be misspellings of more common words (for example, "ort" and
|
||||||
|
"calender").
|
||||||
|
|
||||||
|
To get an idea of the difference in size, here are 25 random words
|
||||||
|
only found in the large dictionary for American English:
|
||||||
|
|
||||||
|
Bermejo Freyr's Guenevere Hatshepsut Nottinghamshire arrestment
|
||||||
|
crassitudes crural dogwatches errorless fetial flaxseeds godroon
|
||||||
|
incretion jalapeño's kelpie kishkes neuroglias pietisms pullulation
|
||||||
|
stemwinder stenoses syce thalassic zees
|
||||||
|
|
||||||
|
The en_US, en_CA and en_AU are the official dictionaries for Hunspell.
|
||||||
|
The en_GB and large dictionaries are made available on an experimental
|
||||||
|
basis. If you find them useful please send me a quick email at
|
||||||
|
kevina@gnu.org.
|
||||||
|
|
||||||
|
If none of these dictionaries suite you (for example, maybe you want
|
||||||
|
the normal dictionary that also includes common variants) additional
|
||||||
|
dictionaries can be generated at http://app.aspell.net/create or by
|
||||||
|
modifying speller/make-hunspell-dict in SCOWL. Please do let me know
|
||||||
|
if you end up publishing a customized dictionary.
|
||||||
|
|
||||||
|
If a word is not found in the dictionary or a word is there you think
|
||||||
|
shouldn't be, you can lookup the word up at http://app.aspell.net/lookup
|
||||||
|
to help determine why that is.
|
||||||
|
|
||||||
|
General comments on these list can be sent directly to me at
|
||||||
|
kevina@gnu.org or to the wordlist-devel mailing lists
|
||||||
|
(https://lists.sourceforge.net/lists/listinfo/wordlist-devel). If you
|
||||||
|
have specific issues with any of these dictionaries please file a bug
|
||||||
|
report at https://github.com/kevina/wordlist/issues.
|
||||||
|
|
||||||
|
IMPORTANT CHANGES INTRODUCED In 2016.11.20:
|
||||||
|
|
||||||
|
New Australian dictionaries thanks to the work of Benjamin Titze
|
||||||
|
(btitze@protonmail.ch).
|
||||||
|
|
||||||
|
IMPORTANT CHANGES INTRODUCED IN 2016.04.24:
|
||||||
|
|
||||||
|
The dictionaries are now in UTF-8 format instead of ISO-8859-1. This
|
||||||
|
was required to handle smart quotes correctly.
|
||||||
|
|
||||||
|
IMPORTANT CHANGES INTRODUCED IN 2016.01.19:
|
||||||
|
|
||||||
|
"SET UTF8" was changes to "SET UTF-8" in the affix file as some
|
||||||
|
versions of Hunspell do not recognize "UTF8".
|
||||||
|
|
||||||
|
ADDITIONAL NOTES:
|
||||||
|
|
||||||
|
The NOSUGGEST flag was added to certain taboo words. While I made an
|
||||||
|
honest attempt to flag the strongest taboo words with the NOSUGGEST
|
||||||
|
flag, I MAKE NO GUARANTEE THAT I FLAGGED EVERY POSSIBLE TABOO WORD.
|
||||||
|
The list was originally derived from Németh László, however I removed
|
||||||
|
some words which, while being considered taboo by some dictionaries,
|
||||||
|
are not really considered swear words in today's society.
|
||||||
|
|
||||||
|
COPYRIGHT, SOURCES, and CREDITS:
|
||||||
|
|
||||||
|
The English dictionaries come directly from SCOWL
|
||||||
|
and is thus under the same copyright of SCOWL. The affix file is
|
||||||
|
a heavily modified version of the original english.aff file which was
|
||||||
|
released as part of Geoff Kuenning's Ispell and as such is covered by
|
||||||
|
his BSD license. Part of SCOWL is also based on Ispell thus the
|
||||||
|
Ispell copyright is included with the SCOWL copyright.
|
||||||
|
|
||||||
|
The collective work is Copyright 2000-2018 by Kevin Atkinson as well
|
||||||
|
as any of the copyrights mentioned below:
|
||||||
|
|
||||||
|
Copyright 2000-2018 by Kevin Atkinson
|
||||||
|
|
||||||
|
Permission to use, copy, modify, distribute and sell these word
|
||||||
|
lists, the associated scripts, the output created from the scripts,
|
||||||
|
and its documentation for any purpose is hereby granted without fee,
|
||||||
|
provided that the above copyright notice appears in all copies and
|
||||||
|
that both that copyright notice and this permission notice appear in
|
||||||
|
supporting documentation. Kevin Atkinson makes no representations
|
||||||
|
about the suitability of this array for any purpose. It is provided
|
||||||
|
"as is" without express or implied warranty.
|
||||||
|
|
||||||
|
Alan Beale <biljir@pobox.com> also deserves special credit as he has,
|
||||||
|
in addition to providing the 12Dicts package and being a major
|
||||||
|
contributor to the ENABLE word list, given me an incredible amount of
|
||||||
|
feedback and created a number of special lists (those found in the
|
||||||
|
Supplement) in order to help improve the overall quality of SCOWL.
|
||||||
|
|
||||||
|
The 10 level includes the 1000 most common English words (according to
|
||||||
|
the Moby (TM) Words II [MWords] package), a subset of the 1000 most
|
||||||
|
common words on the Internet (again, according to Moby Words II), and
|
||||||
|
frequently class 16 from Brian Kelk's "UK English Wordlist
|
||||||
|
with Frequency Classification".
|
||||||
|
|
||||||
|
The MWords package was explicitly placed in the public domain:
|
||||||
|
|
||||||
|
The Moby lexicon project is complete and has
|
||||||
|
been place into the public domain. Use, sell,
|
||||||
|
rework, excerpt and use in any way on any platform.
|
||||||
|
|
||||||
|
Placing this material on internal or public servers is
|
||||||
|
also encouraged. The compiler is not aware of any
|
||||||
|
export restrictions so freely distribute world-wide.
|
||||||
|
|
||||||
|
You can verify the public domain status by contacting
|
||||||
|
|
||||||
|
Grady Ward
|
||||||
|
3449 Martha Ct.
|
||||||
|
Arcata, CA 95521-4884
|
||||||
|
|
||||||
|
grady@netcom.com
|
||||||
|
grady@northcoast.com
|
||||||
|
|
||||||
|
The "UK English Wordlist With Frequency Classification" is also in the
|
||||||
|
Public Domain:
|
||||||
|
|
||||||
|
Date: Sat, 08 Jul 2000 20:27:21 +0100
|
||||||
|
From: Brian Kelk <Brian.Kelk@cl.cam.ac.uk>
|
||||||
|
|
||||||
|
> I was wondering what the copyright status of your "UK English
|
||||||
|
> Wordlist With Frequency Classification" word list as it seems to
|
||||||
|
> be lacking any copyright notice.
|
||||||
|
|
||||||
|
There were many many sources in total, but any text marked
|
||||||
|
"copyright" was avoided. Locally-written documentation was one
|
||||||
|
source. An earlier version of the list resided in a filespace called
|
||||||
|
PUBLIC on the University mainframe, because it was considered public
|
||||||
|
domain.
|
||||||
|
|
||||||
|
Date: Tue, 11 Jul 2000 19:31:34 +0100
|
||||||
|
|
||||||
|
> So are you saying your word list is also in the public domain?
|
||||||
|
|
||||||
|
That is the intention.
|
||||||
|
|
||||||
|
The 20 level includes frequency classes 7-15 from Brian's word list.
|
||||||
|
|
||||||
|
The 35 level includes frequency classes 2-6 and words appearing in at
|
||||||
|
least 11 of 12 dictionaries as indicated in the 12Dicts package. All
|
||||||
|
words from the 12Dicts package have had likely inflections added via
|
||||||
|
my inflection database.
|
||||||
|
|
||||||
|
The 12Dicts package and Supplement is in the Public Domain.
|
||||||
|
|
||||||
|
The WordNet database, which was used in the creation of the
|
||||||
|
Inflections database, is under the following copyright:
|
||||||
|
|
||||||
|
This software and database is being provided to you, the LICENSEE,
|
||||||
|
by Princeton University under the following license. By obtaining,
|
||||||
|
using and/or copying this software and database, you agree that you
|
||||||
|
have read, understood, and will comply with these terms and
|
||||||
|
conditions.:
|
||||||
|
|
||||||
|
Permission to use, copy, modify and distribute this software and
|
||||||
|
database and its documentation for any purpose and without fee or
|
||||||
|
royalty is hereby granted, provided that you agree to comply with
|
||||||
|
the following copyright notice and statements, including the
|
||||||
|
disclaimer, and that the same appear on ALL copies of the software,
|
||||||
|
database and documentation, including modifications that you make
|
||||||
|
for internal use or for distribution.
|
||||||
|
|
||||||
|
WordNet 1.6 Copyright 1997 by Princeton University. All rights
|
||||||
|
reserved.
|
||||||
|
|
||||||
|
THIS SOFTWARE AND DATABASE IS PROVIDED "AS IS" AND PRINCETON
|
||||||
|
UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||||
|
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PRINCETON
|
||||||
|
UNIVERSITY MAKES NO REPRESENTATIONS OR WARRANTIES OF MERCHANT-
|
||||||
|
ABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE
|
||||||
|
LICENSED SOFTWARE, DATABASE OR DOCUMENTATION WILL NOT INFRINGE ANY
|
||||||
|
THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
|
||||||
|
|
||||||
|
The name of Princeton University or Princeton may not be used in
|
||||||
|
advertising or publicity pertaining to distribution of the software
|
||||||
|
and/or database. Title to copyright in this software, database and
|
||||||
|
any associated documentation shall at all times remain with
|
||||||
|
Princeton University and LICENSEE agrees to preserve same.
|
||||||
|
|
||||||
|
The 40 level includes words from Alan's 3esl list found in version 4.0
|
||||||
|
of his 12dicts package. Like his other stuff the 3esl list is also in the
|
||||||
|
public domain.
|
||||||
|
|
||||||
|
The 50 level includes Brian's frequency class 1, words appearing
|
||||||
|
in at least 5 of 12 of the dictionaries as indicated in the 12Dicts
|
||||||
|
package, and uppercase words in at least 4 of the previous 12
|
||||||
|
dictionaries. A decent number of proper names is also included: The
|
||||||
|
top 1000 male, female, and Last names from the 1990 Census report; a
|
||||||
|
list of names sent to me by Alan Beale; and a few names that I added
|
||||||
|
myself. Finally a small list of abbreviations not commonly found in
|
||||||
|
other word lists is included.
|
||||||
|
|
||||||
|
The name files form the Census report is a government document which I
|
||||||
|
don't think can be copyrighted.
|
||||||
|
|
||||||
|
The file special-jargon.50 uses common.lst and word.lst from the
|
||||||
|
"Unofficial Jargon File Word Lists" which is derived from "The Jargon
|
||||||
|
File". All of which is in the Public Domain. This file also contain
|
||||||
|
a few extra UNIX terms which are found in the file "unix-terms" in the
|
||||||
|
special/ directory.
|
||||||
|
|
||||||
|
The 55 level includes words from Alan's 2of4brif list found in version
|
||||||
|
4.0 of his 12dicts package. Like his other stuff the 2of4brif is also
|
||||||
|
in the public domain.
|
||||||
|
|
||||||
|
The 60 level includes all words appearing in at least 2 of the 12
|
||||||
|
dictionaries as indicated by the 12Dicts package.
|
||||||
|
|
||||||
|
The 70 level includes Brian's frequency class 0 and the 74,550 common
|
||||||
|
dictionary words from the MWords package. The common dictionary words,
|
||||||
|
like those from the 12Dicts package, have had all likely inflections
|
||||||
|
added. The 70 level also included the 5desk list from version 4.0 of
|
||||||
|
the 12Dics package which is in the public domain.
|
||||||
|
|
||||||
|
The 80 level includes the ENABLE word list, all the lists in the
|
||||||
|
ENABLE supplement package (except for ABLE), the "UK Advanced Cryptics
|
||||||
|
Dictionary" (UKACD), the list of signature words from the YAWL package,
|
||||||
|
and the 10,196 places list from the MWords package.
|
||||||
|
|
||||||
|
The ENABLE package, mainted by M\Cooper <thegrendel@theriver.com>,
|
||||||
|
is in the Public Domain:
|
||||||
|
|
||||||
|
The ENABLE master word list, WORD.LST, is herewith formally released
|
||||||
|
into the Public Domain. Anyone is free to use it or distribute it in
|
||||||
|
any manner they see fit. No fee or registration is required for its
|
||||||
|
use nor are "contributions" solicited (if you feel you absolutely
|
||||||
|
must contribute something for your own peace of mind, the authors of
|
||||||
|
the ENABLE list ask that you make a donation on their behalf to your
|
||||||
|
favorite charity). This word list is our gift to the Scrabble
|
||||||
|
community, as an alternate to "official" word lists. Game designers
|
||||||
|
may feel free to incorporate the WORD.LST into their games. Please
|
||||||
|
mention the source and credit us as originators of the list. Note
|
||||||
|
that if you, as a game designer, use the WORD.LST in your product,
|
||||||
|
you may still copyright and protect your product, but you may *not*
|
||||||
|
legally copyright or in any way restrict redistribution of the
|
||||||
|
WORD.LST portion of your product. This *may* under law restrict your
|
||||||
|
rights to restrict your users' rights, but that is only fair.
|
||||||
|
|
||||||
|
UKACD, by J Ross Beresford <ross@bryson.demon.co.uk>, is under the
|
||||||
|
following copyright:
|
||||||
|
|
||||||
|
Copyright (c) J Ross Beresford 1993-1999. All Rights Reserved.
|
||||||
|
|
||||||
|
The following restriction is placed on the use of this publication:
|
||||||
|
if The UK Advanced Cryptics Dictionary is used in a software package
|
||||||
|
or redistributed in any form, the copyright notice must be
|
||||||
|
prominently displayed and the text of this document must be included
|
||||||
|
verbatim.
|
||||||
|
|
||||||
|
There are no other restrictions: I would like to see the list
|
||||||
|
distributed as widely as possible.
|
||||||
|
|
||||||
|
The 95 level includes the 354,984 single words, 256,772 compound
|
||||||
|
words, 4,946 female names and the 3,897 male names, and 21,986 names
|
||||||
|
from the MWords package, ABLE.LST from the ENABLE Supplement, and some
|
||||||
|
additional words found in my part-of-speech database that were not
|
||||||
|
found anywhere else.
|
||||||
|
|
||||||
|
Accent information was taken from UKACD.
|
||||||
|
|
||||||
|
The VarCon package was used to create the American, British, Canadian,
|
||||||
|
and Australian word list. It is under the following copyright:
|
||||||
|
|
||||||
|
Copyright 2000-2016 by Kevin Atkinson
|
||||||
|
|
||||||
|
Permission to use, copy, modify, distribute and sell this array, the
|
||||||
|
associated software, and its documentation for any purpose is hereby
|
||||||
|
granted without fee, provided that the above copyright notice appears
|
||||||
|
in all copies and that both that copyright notice and this permission
|
||||||
|
notice appear in supporting documentation. Kevin Atkinson makes no
|
||||||
|
representations about the suitability of this array for any
|
||||||
|
purpose. It is provided "as is" without express or implied warranty.
|
||||||
|
|
||||||
|
Copyright 2016 by Benjamin Titze
|
||||||
|
|
||||||
|
Permission to use, copy, modify, distribute and sell this array, the
|
||||||
|
associated software, and its documentation for any purpose is hereby
|
||||||
|
granted without fee, provided that the above copyright notice appears
|
||||||
|
in all copies and that both that copyright notice and this permission
|
||||||
|
notice appear in supporting documentation. Benjamin Titze makes no
|
||||||
|
representations about the suitability of this array for any
|
||||||
|
purpose. It is provided "as is" without express or implied warranty.
|
||||||
|
|
||||||
|
Since the original words lists come from the Ispell distribution:
|
||||||
|
|
||||||
|
Copyright 1993, Geoff Kuenning, Granada Hills, CA
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
3. All modifications to the source code must be clearly marked as
|
||||||
|
such. Binary redistributions based on modified source code
|
||||||
|
must be clearly marked as modified versions in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
(clause 4 removed with permission from Geoff Kuenning)
|
||||||
|
5. The name of Geoff Kuenning may not be used to endorse or promote
|
||||||
|
products derived from this software without specific prior
|
||||||
|
written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY GEOFF KUENNING AND CONTRIBUTORS ``AS IS'' AND
|
||||||
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||||
|
ARE DISCLAIMED. IN NO EVENT SHALL GEOFF KUENNING OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
||||||
|
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||||
|
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||||
|
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||||
|
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGE.
|
||||||
|
|
||||||
|
Build Date: Mon Dec 7 20:19:27 EST 2020
|
||||||
|
Wordlist Command: mk-list --accents=strip en_US 60
|
||||||
205
web/public/dictionaries/en/en.aff
Normal file
205
web/public/dictionaries/en/en.aff
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
SET UTF-8
|
||||||
|
TRY esianrtolcdugmphbyfvkwzESIANRTOLCDUGMPHBYFVKWZ'
|
||||||
|
ICONV 1
|
||||||
|
ICONV ’ '
|
||||||
|
NOSUGGEST !
|
||||||
|
|
||||||
|
# ordinal numbers
|
||||||
|
COMPOUNDMIN 1
|
||||||
|
# only in compounds: 1th, 2th, 3th
|
||||||
|
ONLYINCOMPOUND c
|
||||||
|
# compound rules:
|
||||||
|
# 1. [0-9]*1[0-9]th (10th, 11th, 12th, 56714th, etc.)
|
||||||
|
# 2. [0-9]*[02-9](1st|2nd|3rd|[4-9]th) (21st, 22nd, 123rd, 1234th, etc.)
|
||||||
|
COMPOUNDRULE 2
|
||||||
|
COMPOUNDRULE n*1t
|
||||||
|
COMPOUNDRULE n*mp
|
||||||
|
WORDCHARS 0123456789
|
||||||
|
|
||||||
|
PFX A Y 1
|
||||||
|
PFX A 0 re .
|
||||||
|
|
||||||
|
PFX I Y 1
|
||||||
|
PFX I 0 in .
|
||||||
|
|
||||||
|
PFX U Y 1
|
||||||
|
PFX U 0 un .
|
||||||
|
|
||||||
|
PFX C Y 1
|
||||||
|
PFX C 0 de .
|
||||||
|
|
||||||
|
PFX E Y 1
|
||||||
|
PFX E 0 dis .
|
||||||
|
|
||||||
|
PFX F Y 1
|
||||||
|
PFX F 0 con .
|
||||||
|
|
||||||
|
PFX K Y 1
|
||||||
|
PFX K 0 pro .
|
||||||
|
|
||||||
|
SFX V N 2
|
||||||
|
SFX V e ive e
|
||||||
|
SFX V 0 ive [^e]
|
||||||
|
|
||||||
|
SFX N Y 3
|
||||||
|
SFX N e ion e
|
||||||
|
SFX N y ication y
|
||||||
|
SFX N 0 en [^ey]
|
||||||
|
|
||||||
|
SFX X Y 3
|
||||||
|
SFX X e ions e
|
||||||
|
SFX X y ications y
|
||||||
|
SFX X 0 ens [^ey]
|
||||||
|
|
||||||
|
SFX H N 2
|
||||||
|
SFX H y ieth y
|
||||||
|
SFX H 0 th [^y]
|
||||||
|
|
||||||
|
SFX Y Y 1
|
||||||
|
SFX Y 0 ly .
|
||||||
|
|
||||||
|
SFX G Y 2
|
||||||
|
SFX G e ing e
|
||||||
|
SFX G 0 ing [^e]
|
||||||
|
|
||||||
|
SFX J Y 2
|
||||||
|
SFX J e ings e
|
||||||
|
SFX J 0 ings [^e]
|
||||||
|
|
||||||
|
SFX D Y 4
|
||||||
|
SFX D 0 d e
|
||||||
|
SFX D y ied [^aeiou]y
|
||||||
|
SFX D 0 ed [^ey]
|
||||||
|
SFX D 0 ed [aeiou]y
|
||||||
|
|
||||||
|
SFX T N 4
|
||||||
|
SFX T 0 st e
|
||||||
|
SFX T y iest [^aeiou]y
|
||||||
|
SFX T 0 est [aeiou]y
|
||||||
|
SFX T 0 est [^ey]
|
||||||
|
|
||||||
|
SFX R Y 4
|
||||||
|
SFX R 0 r e
|
||||||
|
SFX R y ier [^aeiou]y
|
||||||
|
SFX R 0 er [aeiou]y
|
||||||
|
SFX R 0 er [^ey]
|
||||||
|
|
||||||
|
SFX Z Y 4
|
||||||
|
SFX Z 0 rs e
|
||||||
|
SFX Z y iers [^aeiou]y
|
||||||
|
SFX Z 0 ers [aeiou]y
|
||||||
|
SFX Z 0 ers [^ey]
|
||||||
|
|
||||||
|
SFX S Y 4
|
||||||
|
SFX S y ies [^aeiou]y
|
||||||
|
SFX S 0 s [aeiou]y
|
||||||
|
SFX S 0 es [sxzh]
|
||||||
|
SFX S 0 s [^sxzhy]
|
||||||
|
|
||||||
|
SFX P Y 3
|
||||||
|
SFX P y iness [^aeiou]y
|
||||||
|
SFX P 0 ness [aeiou]y
|
||||||
|
SFX P 0 ness [^y]
|
||||||
|
|
||||||
|
SFX M Y 1
|
||||||
|
SFX M 0 's .
|
||||||
|
|
||||||
|
SFX B Y 3
|
||||||
|
SFX B 0 able [^aeiou]
|
||||||
|
SFX B 0 able ee
|
||||||
|
SFX B e able [^aeiou]e
|
||||||
|
|
||||||
|
SFX L Y 1
|
||||||
|
SFX L 0 ment .
|
||||||
|
|
||||||
|
REP 90
|
||||||
|
REP a ei
|
||||||
|
REP ei a
|
||||||
|
REP a ey
|
||||||
|
REP ey a
|
||||||
|
REP ai ie
|
||||||
|
REP ie ai
|
||||||
|
REP alot a_lot
|
||||||
|
REP are air
|
||||||
|
REP are ear
|
||||||
|
REP are eir
|
||||||
|
REP air are
|
||||||
|
REP air ere
|
||||||
|
REP ere air
|
||||||
|
REP ere ear
|
||||||
|
REP ere eir
|
||||||
|
REP ear are
|
||||||
|
REP ear air
|
||||||
|
REP ear ere
|
||||||
|
REP eir are
|
||||||
|
REP eir ere
|
||||||
|
REP ch te
|
||||||
|
REP te ch
|
||||||
|
REP ch ti
|
||||||
|
REP ti ch
|
||||||
|
REP ch tu
|
||||||
|
REP tu ch
|
||||||
|
REP ch s
|
||||||
|
REP s ch
|
||||||
|
REP ch k
|
||||||
|
REP k ch
|
||||||
|
REP f ph
|
||||||
|
REP ph f
|
||||||
|
REP gh f
|
||||||
|
REP f gh
|
||||||
|
REP i igh
|
||||||
|
REP igh i
|
||||||
|
REP i uy
|
||||||
|
REP uy i
|
||||||
|
REP i ee
|
||||||
|
REP ee i
|
||||||
|
REP j di
|
||||||
|
REP di j
|
||||||
|
REP j gg
|
||||||
|
REP gg j
|
||||||
|
REP j ge
|
||||||
|
REP ge j
|
||||||
|
REP s ti
|
||||||
|
REP ti s
|
||||||
|
REP s ci
|
||||||
|
REP ci s
|
||||||
|
REP k cc
|
||||||
|
REP cc k
|
||||||
|
REP k qu
|
||||||
|
REP qu k
|
||||||
|
REP kw qu
|
||||||
|
REP o eau
|
||||||
|
REP eau o
|
||||||
|
REP o ew
|
||||||
|
REP ew o
|
||||||
|
REP oo ew
|
||||||
|
REP ew oo
|
||||||
|
REP ew ui
|
||||||
|
REP ui ew
|
||||||
|
REP oo ui
|
||||||
|
REP ui oo
|
||||||
|
REP ew u
|
||||||
|
REP u ew
|
||||||
|
REP oo u
|
||||||
|
REP u oo
|
||||||
|
REP u oe
|
||||||
|
REP oe u
|
||||||
|
REP u ieu
|
||||||
|
REP ieu u
|
||||||
|
REP ue ew
|
||||||
|
REP ew ue
|
||||||
|
REP uff ough
|
||||||
|
REP oo ieu
|
||||||
|
REP ieu oo
|
||||||
|
REP ier ear
|
||||||
|
REP ear ier
|
||||||
|
REP ear air
|
||||||
|
REP air ear
|
||||||
|
REP w qu
|
||||||
|
REP qu w
|
||||||
|
REP z ss
|
||||||
|
REP ss z
|
||||||
|
REP shun tion
|
||||||
|
REP shun sion
|
||||||
|
REP shun cion
|
||||||
|
REP size cise
|
||||||
49569
web/public/dictionaries/en/en.dic
Normal file
49569
web/public/dictionaries/en/en.dic
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { api, type DocSummary, type Document, type Suggestion } from './api/client'
|
import { api, type DocSummary, type Document, type Suggestion } from './api/client'
|
||||||
import { useAutoSave } from './hooks/useAutoSave'
|
import { useAutoSave } from './hooks/useAutoSave'
|
||||||
import { useCheckpoint } from './hooks/useCheckpoint'
|
import { useCheckpoint } from './hooks/useCheckpoint'
|
||||||
|
import { useSpellChecker } from './hooks/useSpellChecker'
|
||||||
import { DocList } from './components/DocList/DocList'
|
import { DocList } from './components/DocList/DocList'
|
||||||
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||||
@@ -30,6 +31,8 @@ export default function App() {
|
|||||||
runVoice,
|
runVoice,
|
||||||
removeSuggestion,
|
removeSuggestion,
|
||||||
} = useCheckpoint(currentDoc?.id ?? null)
|
} = useCheckpoint(currentDoc?.id ?? null)
|
||||||
|
// Browser-side spell checker — loads the en-US dictionary once per session.
|
||||||
|
const { checker: spellChecker, addWord } = useSpellChecker()
|
||||||
|
|
||||||
// Patch a summary in the sidebar list (optimistic title / word-count updates).
|
// Patch a summary in the sidebar list (optimistic title / word-count updates).
|
||||||
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
||||||
@@ -222,6 +225,8 @@ export default function App() {
|
|||||||
onVoiceCheck={runVoice}
|
onVoiceCheck={runVoice}
|
||||||
voicing={voicing}
|
voicing={voicing}
|
||||||
onFocusMode={() => setFocusMode(true)}
|
onFocusMode={() => setFocusMode(true)}
|
||||||
|
spellChecker={spellChecker}
|
||||||
|
onAddWord={addWord}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { Toolbar } from '../Toolbar/Toolbar'
|
import { Toolbar } from '../Toolbar/Toolbar'
|
||||||
import { SuggestionCard } from './SuggestionCard'
|
import { SuggestionCard } from './SuggestionCard'
|
||||||
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
||||||
|
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
||||||
|
import { MisspellCard } from './MisspellCard'
|
||||||
import type { Suggestion } from '../../api/client'
|
import type { Suggestion } from '../../api/client'
|
||||||
|
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||||
|
|
||||||
export interface EditorChange {
|
export interface EditorChange {
|
||||||
content: string // Tiptap JSON, stringified
|
content: string // Tiptap JSON, stringified
|
||||||
@@ -32,6 +35,19 @@ interface Props {
|
|||||||
voicing: boolean
|
voicing: boolean
|
||||||
// Fired when the editor gains focus, so the app can enter distraction-free mode.
|
// Fired when the editor gains focus, so the app can enter distraction-free mode.
|
||||||
onFocusMode?: () => void
|
onFocusMode?: () => void
|
||||||
|
// Browser-side spell checker (null until the dictionary loads). Adding a word
|
||||||
|
// to the personal dictionary is bubbled up so it persists app-wide.
|
||||||
|
spellChecker: SpellChecker | null
|
||||||
|
onAddWord: (word: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MisspellState {
|
||||||
|
word: string
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
suggestions: string[]
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
|
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
|
||||||
@@ -89,9 +105,13 @@ export function EditorCore({
|
|||||||
onVoiceCheck,
|
onVoiceCheck,
|
||||||
voicing,
|
voicing,
|
||||||
onFocusMode,
|
onFocusMode,
|
||||||
|
spellChecker,
|
||||||
|
onAddWord,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
const [hover, setHover] = useState<HoverState | null>(null)
|
const [hover, setHover] = useState<HoverState | null>(null)
|
||||||
|
// The open spelling popover (click a red-underlined word), or null.
|
||||||
|
const [misspell, setMisspell] = useState<MisspellState | null>(null)
|
||||||
// Transient confetti burst played at the last accept location.
|
// Transient confetti burst played at the last accept location.
|
||||||
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
|
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
|
||||||
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
@@ -109,6 +129,7 @@ export function EditorCore({
|
|||||||
Placeholder.configure({ placeholder: 'Start writing…' }),
|
Placeholder.configure({ placeholder: 'Start writing…' }),
|
||||||
CharacterCount,
|
CharacterCount,
|
||||||
SuggestionHighlight,
|
SuggestionHighlight,
|
||||||
|
SpellCheck,
|
||||||
],
|
],
|
||||||
content: parseDoc(initialContent),
|
content: parseDoc(initialContent),
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -116,6 +137,8 @@ export function EditorCore({
|
|||||||
},
|
},
|
||||||
onFocus: () => onFocusMode?.(),
|
onFocus: () => onFocusMode?.(),
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
|
// Any edit shifts positions, stranding the spelling popover's anchor.
|
||||||
|
setMisspell(null)
|
||||||
onChange({
|
onChange({
|
||||||
content: JSON.stringify(editor.getJSON()),
|
content: JSON.stringify(editor.getJSON()),
|
||||||
content_text: editor.getText(),
|
content_text: editor.getText(),
|
||||||
@@ -130,9 +153,17 @@ export function EditorCore({
|
|||||||
if (!editor) return
|
if (!editor) return
|
||||||
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
|
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
|
||||||
setHover(null)
|
setHover(null)
|
||||||
|
setMisspell(null)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [docId, editor])
|
}, [docId, editor])
|
||||||
|
|
||||||
|
// Push the spell checker into its decoration plugin once the dictionary loads
|
||||||
|
// (and again whenever the personal dictionary changes its identity).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return
|
||||||
|
setSpellChecker(editor.state, editor.view.dispatch, spellChecker)
|
||||||
|
}, [editor, spellChecker])
|
||||||
|
|
||||||
// Push the current suggestion list into the decoration plugin.
|
// Push the current suggestion list into the decoration plugin.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
@@ -232,6 +263,60 @@ export function EditorCore({
|
|||||||
[onDismiss, closeCard],
|
[onDismiss, closeCard],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Click a red-underlined word to open its spelling popover, anchored under the
|
||||||
|
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same
|
||||||
|
// misspelling appearing elsewhere); nspell supplies the corrections.
|
||||||
|
const handleSpellClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!editor || !spellChecker) return
|
||||||
|
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
|
||||||
|
if (!target) return
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper) return
|
||||||
|
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||||
|
if (!coords) return
|
||||||
|
const range = wordAt(editor.state.doc, coords.pos)
|
||||||
|
if (!range) return
|
||||||
|
const elRect = target.getBoundingClientRect()
|
||||||
|
const wrapRect = wrapper.getBoundingClientRect()
|
||||||
|
const cardWidth = 240
|
||||||
|
const left = Math.max(0, Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth))
|
||||||
|
const top = elRect.bottom - wrapRect.top + 6
|
||||||
|
// Opening a spelling popover supersedes any AI-suggestion hover card.
|
||||||
|
closeCard()
|
||||||
|
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||||
|
},
|
||||||
|
[editor, spellChecker, closeCard],
|
||||||
|
)
|
||||||
|
|
||||||
|
const replaceMisspelling = useCallback(
|
||||||
|
(correction: string) => {
|
||||||
|
if (editor && misspell) {
|
||||||
|
editor.chain().focus().insertContentAt({ from: misspell.from, to: misspell.to }, correction).run()
|
||||||
|
}
|
||||||
|
setMisspell(null)
|
||||||
|
},
|
||||||
|
[editor, misspell],
|
||||||
|
)
|
||||||
|
|
||||||
|
const addMisspellingToDict = useCallback(() => {
|
||||||
|
if (misspell) onAddWord(misspell.word)
|
||||||
|
setMisspell(null)
|
||||||
|
}, [misspell, onAddWord])
|
||||||
|
|
||||||
|
// A pointer-down outside the popover (and not on another misspelling, which
|
||||||
|
// would reopen it) closes the spelling card.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!misspell) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (t.closest('.petal-misspell-card') || t.closest('.petal-misspelling')) return
|
||||||
|
setMisspell(null)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDown)
|
||||||
|
}, [misspell])
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
clearTimeout(closeTimer.current)
|
clearTimeout(closeTimer.current)
|
||||||
clearTimeout(confettiTimer.current)
|
clearTimeout(confettiTimer.current)
|
||||||
@@ -256,9 +341,19 @@ export function EditorCore({
|
|||||||
className="relative flex-1"
|
className="relative flex-1"
|
||||||
onMouseOver={handleMouseOver}
|
onMouseOver={handleMouseOver}
|
||||||
onMouseOut={handleMouseOut}
|
onMouseOut={handleMouseOut}
|
||||||
|
onClick={handleSpellClick}
|
||||||
>
|
>
|
||||||
<EditorContent editor={editor} className="h-full" />
|
<EditorContent editor={editor} className="h-full" />
|
||||||
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
||||||
|
{misspell && (
|
||||||
|
<MisspellCard
|
||||||
|
word={misspell.word}
|
||||||
|
suggestions={misspell.suggestions}
|
||||||
|
style={{ top: misspell.top, left: misspell.left }}
|
||||||
|
onReplace={replaceMisspelling}
|
||||||
|
onAdd={addMisspellingToDict}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{hover && (
|
{hover && (
|
||||||
<SuggestionCard
|
<SuggestionCard
|
||||||
suggestion={hover.suggestion}
|
suggestion={hover.suggestion}
|
||||||
|
|||||||
75
web/src/components/Editor/MisspellCard.tsx
Normal file
75
web/src/components/Editor/MisspellCard.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// MisspellCard is the little popover for a single misspelled word: the flagged
|
||||||
|
// word, up to a few nspell corrections as tappable pills, and an "add to my
|
||||||
|
// dictionary" action for names/terms Petal shouldn't nag about. Labels are
|
||||||
|
// bilingual (zh-first, en subtitle) to match the rest of Petal's chrome — the
|
||||||
|
// user writes in Mandarin and English (spec Note #17).
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
word: string
|
||||||
|
suggestions: string[]
|
||||||
|
style: React.CSSProperties
|
||||||
|
onReplace: (correction: string) => void
|
||||||
|
onAdd: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Props) {
|
||||||
|
const shown = suggestions.slice(0, 5)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={`Spelling suggestions for ${word}`}
|
||||||
|
className="petal-misspell-card absolute z-20 p-3 text-sm"
|
||||||
|
style={{
|
||||||
|
width: 240,
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-card)',
|
||||||
|
boxShadow: 'var(--shadow-soft)',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||||
|
style={{ background: 'var(--color-accent)', color: 'white' }}
|
||||||
|
>
|
||||||
|
拼写 · Spelling
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shown.length > 0 ? (
|
||||||
|
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||||
|
{shown.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onReplace(s)}
|
||||||
|
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2.5 leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||||
|
没有建议 · No suggestions
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAdd}
|
||||||
|
className="mt-3 rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
|
||||||
|
style={{ background: 'transparent', color: 'var(--color-accent-hover)' }}
|
||||||
|
>
|
||||||
|
添加到词典 · Add to dictionary
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
144
web/src/components/Editor/SpellCheck.ts
Normal file
144
web/src/components/Editor/SpellCheck.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
|
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||||
|
import { mapOffset } from './SuggestionHighlight'
|
||||||
|
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||||
|
|
||||||
|
// SpellCheck renders browser-side nspell misspellings as ProseMirror
|
||||||
|
// decorations (a wavy red underline), recomputed from the live document on every
|
||||||
|
// change. Like the AI-suggestion layer it stores no marks — the underline is a
|
||||||
|
// pure overlay, so it never travels into saved content. English only: the word
|
||||||
|
// tokenizer matches Latin-letter runs, so CJK text (the user writes in both
|
||||||
|
// Mandarin and English) is simply never tokenized and never flagged.
|
||||||
|
|
||||||
|
export const spellPluginKey = new PluginKey<PluginState>('petalSpellCheck')
|
||||||
|
|
||||||
|
interface PluginState {
|
||||||
|
checker: SpellChecker | null
|
||||||
|
decorations: DecorationSet
|
||||||
|
}
|
||||||
|
|
||||||
|
// A word is a run of Latin letters with optional internal/edge apostrophes
|
||||||
|
// (don't, O'Brien). Anything else — digits, punctuation, CJK — terminates a run.
|
||||||
|
const WORD_RE = /[A-Za-z][A-Za-z']*/g
|
||||||
|
|
||||||
|
// isCheckable filters tokens we shouldn't flag: single letters and all-caps
|
||||||
|
// acronyms (NASA, USA), which dictionaries reliably miss and which read as noise
|
||||||
|
// when underlined.
|
||||||
|
function isCheckable(word: string): boolean {
|
||||||
|
if (word.length < 2) return false
|
||||||
|
if (word === word.toUpperCase()) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// strip leading/trailing apostrophes (e.g. a quoted 'word') so the dictionary
|
||||||
|
// lookup sees the bare token; returns the core plus how many chars were trimmed
|
||||||
|
// off the front (to re-anchor the decoration).
|
||||||
|
function coreOf(word: string): { core: string; lead: number } {
|
||||||
|
const lead = word.match(/^'+/)?.[0].length ?? 0
|
||||||
|
const trail = word.match(/'+$/)?.[0].length ?? 0
|
||||||
|
return { core: word.slice(lead, word.length - trail), lead }
|
||||||
|
}
|
||||||
|
|
||||||
|
function eachMisspelling(
|
||||||
|
doc: PMNode,
|
||||||
|
checker: SpellChecker,
|
||||||
|
visit: (from: number, to: number, word: string) => void,
|
||||||
|
) {
|
||||||
|
doc.descendants((node, pos) => {
|
||||||
|
if (!node.isTextblock) return true
|
||||||
|
const text = node.textContent
|
||||||
|
WORD_RE.lastIndex = 0
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = WORD_RE.exec(text)) !== null) {
|
||||||
|
const { core, lead } = coreOf(m[0])
|
||||||
|
if (!isCheckable(core) || checker.correct(core)) continue
|
||||||
|
const from = mapOffset(node, pos, m.index + lead)
|
||||||
|
const to = mapOffset(node, pos, m.index + lead + core.length)
|
||||||
|
visit(from, to, core)
|
||||||
|
}
|
||||||
|
return false // never descend into a textblock's inline children
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDecorations(doc: PMNode, checker: SpellChecker, cursor: number): DecorationSet {
|
||||||
|
const decos: Decoration[] = []
|
||||||
|
eachMisspelling(doc, checker, (from, to, _word) => {
|
||||||
|
// Don't flag the word the caret currently sits in — it's mid-typing, and a
|
||||||
|
// red underline appearing under the cursor on every keystroke is jittery.
|
||||||
|
if (cursor >= from && cursor <= to) return
|
||||||
|
decos.push(Decoration.inline(from, to, { class: 'petal-misspelling', 'data-misspelling': '' }))
|
||||||
|
})
|
||||||
|
return DecorationSet.create(doc, decos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordAt resolves the misspelling token under a ProseMirror position (from a
|
||||||
|
// click), returning its range + text so the card can offer corrections and the
|
||||||
|
// replacement can target the exact span — robust to duplicate words anywhere
|
||||||
|
// else in the document. Returns null if the position isn't inside a Latin word.
|
||||||
|
export function wordAt(doc: PMNode, pos: number): { from: number; to: number; word: string } | null {
|
||||||
|
let found: { from: number; to: number; word: string } | null = null
|
||||||
|
doc.descendants((node, nodePos) => {
|
||||||
|
if (found) return false
|
||||||
|
if (!node.isTextblock) return true
|
||||||
|
if (pos <= nodePos || pos >= nodePos + node.nodeSize) return false
|
||||||
|
const text = node.textContent
|
||||||
|
WORD_RE.lastIndex = 0
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = WORD_RE.exec(text)) !== null) {
|
||||||
|
const { core, lead } = coreOf(m[0])
|
||||||
|
if (!core) continue
|
||||||
|
const from = mapOffset(node, nodePos, m.index + lead)
|
||||||
|
const to = mapOffset(node, nodePos, m.index + lead + core.length)
|
||||||
|
if (pos >= from && pos <= to) {
|
||||||
|
found = { from, to, word: core }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSpellChecker pushes the (possibly null) checker into the plugin, rebuilding
|
||||||
|
// decorations immediately against the current document.
|
||||||
|
export function setSpellChecker(
|
||||||
|
state: EditorState,
|
||||||
|
dispatch: (tr: Transaction) => void,
|
||||||
|
checker: SpellChecker | null,
|
||||||
|
) {
|
||||||
|
dispatch(state.tr.setMeta(spellPluginKey, checker ?? null))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SpellCheck = Extension.create({
|
||||||
|
name: 'spellCheck',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin<PluginState>({
|
||||||
|
key: spellPluginKey,
|
||||||
|
state: {
|
||||||
|
init: () => ({ checker: null, decorations: DecorationSet.empty }),
|
||||||
|
apply(tr, value, _oldState, newState) {
|
||||||
|
const meta = tr.getMeta(spellPluginKey) as SpellChecker | null | undefined
|
||||||
|
const checker = meta !== undefined ? meta : value.checker
|
||||||
|
if (!checker) return { checker: null, decorations: DecorationSet.empty }
|
||||||
|
// Rebuild on a checker swap, a doc edit, or a caret move (so the word
|
||||||
|
// you just left gets re-evaluated and the new caret word is exempt).
|
||||||
|
if (meta !== undefined || tr.docChanged || tr.selectionSet) {
|
||||||
|
return { checker, decorations: buildDecorations(newState.doc, checker, newState.selection.head) }
|
||||||
|
}
|
||||||
|
return { checker, decorations: value.decorations }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return spellPluginKey.getState(state)?.decorations
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -22,7 +22,7 @@ interface PluginState {
|
|||||||
// mapOffset converts a character offset within a textblock's flattened text into
|
// mapOffset converts a character offset within a textblock's flattened text into
|
||||||
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
|
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
|
||||||
// breaks) that occupy a position but contribute no text.
|
// breaks) that occupy a position but contribute no text.
|
||||||
function mapOffset(block: PMNode, blockPos: number, targetOffset: number): number {
|
export function mapOffset(block: PMNode, blockPos: number, targetOffset: number): number {
|
||||||
let textOffset = 0
|
let textOffset = 0
|
||||||
let pmPos = blockPos + 1 // inline content starts just inside the block
|
let pmPos = blockPos + 1 // inline content starts just inside the block
|
||||||
let result = pmPos
|
let result = pmPos
|
||||||
|
|||||||
88
web/src/hooks/useSpellChecker.ts
Normal file
88
web/src/hooks/useSpellChecker.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import nspell, { type NSpell } from 'nspell'
|
||||||
|
|
||||||
|
// useSpellChecker loads the vendored en-US Hunspell dictionary (served from
|
||||||
|
// /dictionaries/en, embedded in the Go binary via web/dist) and builds an
|
||||||
|
// in-browser nspell instance — zero backend round-trips, per spec. The
|
||||||
|
// dictionary is ~550KB, so it's fetched as a static asset (kept out of the JS
|
||||||
|
// bundle) once per app session, not per document. A personal word list lives in
|
||||||
|
// localStorage and is replayed into nspell on load; adding a word bumps a
|
||||||
|
// `version` so consumers re-run their decorations and the word stops flagging.
|
||||||
|
|
||||||
|
// SpellChecker is the minimal surface the editor decoration layer consumes.
|
||||||
|
export interface SpellChecker {
|
||||||
|
correct(word: string): boolean
|
||||||
|
suggest(word: string): string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERSONAL_KEY = 'petal.spell.personal'
|
||||||
|
|
||||||
|
function loadPersonal(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PERSONAL_KEY)
|
||||||
|
const parsed = raw ? JSON.parse(raw) : []
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((w): w is string => typeof w === 'string') : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePersonal(words: string[]) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PERSONAL_KEY, JSON.stringify(words))
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable — personal words just won't persist this session */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpellChecker() {
|
||||||
|
const spellRef = useRef<NSpell | null>(null)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
// Bumped whenever the personal dictionary changes, to force re-decoration.
|
||||||
|
const [version, setVersion] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
// Served from web/dist root (and embedded in the Go binary), same as /api.
|
||||||
|
const [aff, dic] = await Promise.all([
|
||||||
|
fetch('/dictionaries/en/en.aff').then((r) => r.text()),
|
||||||
|
fetch('/dictionaries/en/en.dic').then((r) => r.text()),
|
||||||
|
])
|
||||||
|
if (cancelled) return
|
||||||
|
const sp = nspell(aff, dic)
|
||||||
|
for (const w of loadPersonal()) sp.add(w)
|
||||||
|
spellRef.current = sp
|
||||||
|
setReady(true)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('spell checker failed to load', err)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Recreate the checker's identity on load and on every personal-dict change so
|
||||||
|
// the editor's effect re-pushes it and rebuilds decorations.
|
||||||
|
const checker = useMemo<SpellChecker | null>(() => {
|
||||||
|
if (!ready) return null
|
||||||
|
return {
|
||||||
|
correct: (w) => spellRef.current?.correct(w) ?? true,
|
||||||
|
suggest: (w) => spellRef.current?.suggest(w) ?? [],
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ready, version])
|
||||||
|
|
||||||
|
const addWord = useCallback((word: string) => {
|
||||||
|
const sp = spellRef.current
|
||||||
|
if (!sp) return
|
||||||
|
sp.add(word)
|
||||||
|
const next = Array.from(new Set([...loadPersonal(), word]))
|
||||||
|
savePersonal(next)
|
||||||
|
setVersion((v) => v + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { checker, ready, addWord }
|
||||||
|
}
|
||||||
@@ -136,6 +136,26 @@ button, a, input {
|
|||||||
animation: petal-suggestion-in 200ms ease both;
|
animation: petal-suggestion-in 200ms ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Spell check ------------------------------------------------------------
|
||||||
|
Browser-side nspell flags misspellings with a soft rose wavy underline (a
|
||||||
|
gentler take on the classic red squiggle, to fit the pastel palette). Like the
|
||||||
|
suggestion layer these are ProseMirror decorations, never stored marks.
|
||||||
|
Clicking a flagged word opens its MisspellCard with corrections. */
|
||||||
|
.petal-misspelling {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline wavy var(--color-accent);
|
||||||
|
text-decoration-skip-ink: none;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.petal-misspelling:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.petal-misspell-card {
|
||||||
|
animation: petal-suggestion-in 200ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Accept confetti --------------------------------------------------------
|
/* --- Accept confetti --------------------------------------------------------
|
||||||
A tiny CSS-only burst played where a suggestion is accepted: four colored
|
A tiny CSS-only burst played where a suggestion is accepted: four colored
|
||||||
dots spray up-and-out, then fade. No JS animation — each dot reads its
|
dots spray up-and-out, then fade. No JS animation — each dot reads its
|
||||||
|
|||||||
19
web/src/types/nspell.d.ts
vendored
Normal file
19
web/src/types/nspell.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Minimal ambient types for nspell (the package ships no declarations). We use
|
||||||
|
// only the handful of methods the spell checker needs: correctness lookup,
|
||||||
|
// correction suggestions, and adding words to the in-memory personal dictionary.
|
||||||
|
declare module 'nspell' {
|
||||||
|
export interface NSpell {
|
||||||
|
correct(word: string): boolean
|
||||||
|
suggest(word: string): string[]
|
||||||
|
add(word: string, model?: string): NSpell
|
||||||
|
remove(word: string): NSpell
|
||||||
|
}
|
||||||
|
export interface Dictionary {
|
||||||
|
aff: string | Buffer
|
||||||
|
dic: string | Buffer
|
||||||
|
}
|
||||||
|
export default function nspell(
|
||||||
|
aff: string | Buffer | Dictionary,
|
||||||
|
dic?: string | Buffer,
|
||||||
|
): NSpell
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user