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] **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
|
||||
- [ ] nspell browser-side (en-US), vendor dictionaries
|
||||
### Phase 7 — Spell check ✅
|
||||
- [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)
|
||||
- [ ] 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: **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 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).**
|
||||
|
||||
45
web/package-lock.json
generated
45
web/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"@tiptap/react": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"lottie-web": "^5.13.0",
|
||||
"nspell": "^2.1.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
@@ -24,6 +25,7 @@
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"dictionary-en": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0"
|
||||
@@ -2130,6 +2132,17 @@
|
||||
"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": {
|
||||
"version": "1.5.379",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz",
|
||||
@@ -2283,6 +2296,29 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
@@ -2701,6 +2737,15 @@
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@tiptap/react": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"lottie-web": "^5.13.0",
|
||||
"nspell": "^2.1.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
@@ -25,6 +26,7 @@
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"dictionary-en": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"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 { useAutoSave } from './hooks/useAutoSave'
|
||||
import { useCheckpoint } from './hooks/useCheckpoint'
|
||||
import { useSpellChecker } from './hooks/useSpellChecker'
|
||||
import { DocList } from './components/DocList/DocList'
|
||||
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
@@ -30,6 +31,8 @@ export default function App() {
|
||||
runVoice,
|
||||
removeSuggestion,
|
||||
} = 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).
|
||||
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
|
||||
@@ -222,6 +225,8 @@ export default function App() {
|
||||
onVoiceCheck={runVoice}
|
||||
voicing={voicing}
|
||||
onFocusMode={() => setFocusMode(true)}
|
||||
spellChecker={spellChecker}
|
||||
onAddWord={addWord}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Toolbar } from '../Toolbar/Toolbar'
|
||||
import { SuggestionCard } from './SuggestionCard'
|
||||
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
||||
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
||||
import { MisspellCard } from './MisspellCard'
|
||||
import type { Suggestion } from '../../api/client'
|
||||
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||
|
||||
export interface EditorChange {
|
||||
content: string // Tiptap JSON, stringified
|
||||
@@ -32,6 +35,19 @@ interface Props {
|
||||
voicing: boolean
|
||||
// Fired when the editor gains focus, so the app can enter distraction-free mode.
|
||||
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
|
||||
@@ -89,9 +105,13 @@ export function EditorCore({
|
||||
onVoiceCheck,
|
||||
voicing,
|
||||
onFocusMode,
|
||||
spellChecker,
|
||||
onAddWord,
|
||||
}: Props) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(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.
|
||||
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
|
||||
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
@@ -109,6 +129,7 @@ export function EditorCore({
|
||||
Placeholder.configure({ placeholder: 'Start writing…' }),
|
||||
CharacterCount,
|
||||
SuggestionHighlight,
|
||||
SpellCheck,
|
||||
],
|
||||
content: parseDoc(initialContent),
|
||||
editorProps: {
|
||||
@@ -116,6 +137,8 @@ export function EditorCore({
|
||||
},
|
||||
onFocus: () => onFocusMode?.(),
|
||||
onUpdate: ({ editor }) => {
|
||||
// Any edit shifts positions, stranding the spelling popover's anchor.
|
||||
setMisspell(null)
|
||||
onChange({
|
||||
content: JSON.stringify(editor.getJSON()),
|
||||
content_text: editor.getText(),
|
||||
@@ -130,9 +153,17 @@ export function EditorCore({
|
||||
if (!editor) return
|
||||
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
|
||||
setHover(null)
|
||||
setMisspell(null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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.
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
@@ -232,6 +263,60 @@ export function EditorCore({
|
||||
[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(() => () => {
|
||||
clearTimeout(closeTimer.current)
|
||||
clearTimeout(confettiTimer.current)
|
||||
@@ -256,9 +341,19 @@ export function EditorCore({
|
||||
className="relative flex-1"
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
onClick={handleSpellClick}
|
||||
>
|
||||
<EditorContent editor={editor} className="h-full" />
|
||||
{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 && (
|
||||
<SuggestionCard
|
||||
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
|
||||
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
|
||||
// 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 pmPos = blockPos + 1 // inline content starts just inside the block
|
||||
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;
|
||||
}
|
||||
|
||||
/* --- 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 --------------------------------------------------------
|
||||
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
|
||||
|
||||
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