The keystroke that isn't one: IME composition guards
Phase 26 scoped these and left them unbuilt, naming them as the likeliest thing to be wrong the first time anyone types Chinese into Petal for real. A composition is not a keystroke: the pinyin goes into the document as it is typed, a candidate window sits over it, and all three decoration layers recompute from the live document on every change — rewriting the DOM around the node the browser is composing in, which is what eats half-typed input. The layers now hold their redraws rather than skip them: a rebuild that falls due mid-composition marks itself stale and its decorations are mapped through the transaction, so they travel with the text and land correct the moment the composition ends. The flag is read from the state before the transaction, so the answer doesn't depend on plugin ordering; the end transaction is the one deliberate exception, or nothing would ever release. The release is a macrotask late because a custom handleDOMEvents handler runs before ProseMirror's own and ProseMirror flushes the composition's last changes in a microtask — so the held rebuild sees the committed hanzi, not the pinyin it replaced. Input rules needed no guard (Tiptap already returns early while composing), which was checked rather than assumed: pinyin uses an apostrophe as a syllable separator and Typography rewrites every ' into a curly one. The save is deliberately not gated and the analysis is. A tablet keyboard can hold one composition open for a whole sentence, and Petal never makes writing wait for anything — so EditorChange carries the flag, auto-save ignores it, and the checkpoint, rule pack and companion wait for the word to commit. One more change is emitted the instant it does, so nothing is skipped. Four places were taking keys that belong to the IME: the Find bar, the tag picker, Ask Petal's chat box, and distraction-free mode's global Escape. vitest 296/296, tsc, vite, go build/vet/test clean. Not verified with a real IME — no browser or IME here, and that is the half the tests cannot reach.
This commit is contained in:
+20
-2
@@ -393,14 +393,31 @@ Scope agreed with the user 2026-07-28: **a `direction` column, segmentation + ho
|
|||||||
6. [x] **The direction picker names each option in the language of the person who would choose it** — 英文 for the writer who is native in Chinese, "Chinese 中文" for the one who is native in English. The same self-naming principle the pair buttons follow, for the same reason: someone on the wrong side of this switch cannot read the side they are trying to reach. It renders only when the pack carries a `learner` block, which is the frontend's half of `auth.learnerPairs`.
|
6. [x] **The direction picker names each option in the language of the person who would choose it** — 英文 for the writer who is native in Chinese, "Chinese 中文" for the one who is native in English. The same self-naming principle the pair buttons follow, for the same reason: someone on the wrong side of this switch cannot read the side they are trying to reach. It renders only when the pack carries a `learner` block, which is the frontend's half of `auth.learnerPairs`.
|
||||||
- **`@types/node` added as a devDependency**, which is a small thing with a real consequence: vitest can now read the *shipped* assets. Phases 21–25 all verified their dictionaries with throwaway scripts because the suite could not; `segment.test.ts` and `hanzi.test.ts` assert against the real files.
|
- **`@types/node` added as a devDependency**, which is a small thing with a real consequence: vitest can now read the *shipped* assets. Phases 21–25 all verified their dictionaries with throwaway scripts because the suite could not; `segment.test.ts` and `hanzi.test.ts` assert against the real files.
|
||||||
- Verified: go build/vet, `go test ./...`, tsc, vite build, **vitest 284/284**; live smoke on a throwaway DB (:8071, LLM pointed at a dead port) — 公园 → gōngyuán, 得 → both readings, 猫书 → the character fallback, the word list served at 965,266 B, `PATCH {"direction":"learning_pair"}` accepted, `{"pair_lang":"fr"}` refused 400 while the account stayed put. The smoke also caught a cosmetic build bug: stripping CC-CEDICT's `CL:` field left "cat (" with an unbalanced paren, now fixed and the asset rebuilt.
|
- Verified: go build/vet, `go test ./...`, tsc, vite build, **vitest 284/284**; live smoke on a throwaway DB (:8071, LLM pointed at a dead port) — 公园 → gōngyuán, 得 → both readings, 猫书 → the character fallback, the word list served at 965,266 B, `PATCH {"direction":"learning_pair"}` accepted, `{"pair_lang":"fr"}` refused 400 while the account stayed put. The smoke also caught a cosmetic build bug: stripping CC-CEDICT's `CL:` field left "cat (" with an unbalanced paren, now fixed and the asset rebuilt.
|
||||||
- ⚠️ **The IME guards were scoped into this phase and are NOT done.** No composition handling exists anywhere in the app — verified, not assumed. The risk is concrete and known: the decoration plugins (`SpellCheck`, `SuggestionHighlight`, `SearchHighlight`) recompute on every doc change, and a rebuild mid-composition is the classic bug that eats half-typed pinyin. This is the single most likely thing to be wrong the first time someone types Chinese into Petal with a real IME, and it is untouched rather than half-built.
|
- ⚠️ **The IME guards were scoped into this phase and were NOT done** — no composition handling existed anywhere in the app. **Built 2026-07-28 as Phase 27 below.**
|
||||||
- ⚠️ **Not deployed** (this one carries a migration, so it is a deploy and not a rebuild), **not seen in a browser**, and **no account has ever been in the learner direction** — every claim above about how this feels to use is inference from unit tests. The 错别字 pack has not been read by a native speaker either; unlike the Latin packs it is 24 mechanically-screened pairs rather than prose, which lowers the stakes without removing them.
|
- ⚠️ **Not deployed** (this one carries a migration, so it is a deploy and not a rebuild), **not seen in a browser**, and **no account has ever been in the learner direction** — every claim above about how this feels to use is inference from unit tests. The 错别字 pack has not been read by a native speaker either; unlike the Latin packs it is 24 mechanically-screened pairs rather than prose, which lowers the stakes without removing them.
|
||||||
|
|
||||||
|
### Phase 27 — IME composition guards (2026-07-28, code half) — the keystroke that isn't one
|
||||||
|
Phase 26's own outstanding item, and the thing it named as most likely to be wrong the first time anyone types Chinese into Petal for real. Code only; no migration, no server change at all, so it is a rebuild whenever the user wants it.
|
||||||
|
|
||||||
|
1. [x] **One tracker, asked by everything** (`web/src/components/Editor/Composition.ts`). A composition is not a keystroke: the pinyin she types goes *into the document* as she types it, a candidate window sits over it, and only on choosing a candidate is the run replaced with hanzi. All three decoration layers recompute from the live document on every change, and recomputing rewrites the DOM around the node the browser is composing in — which is the classic bug that eats half-typed input.
|
||||||
|
- **The layers hold their redraws, they do not skip them.** A rebuild that falls due mid-composition marks itself `stale` and the existing decorations are **mapped through the transaction**, so they travel with the text growing under them; the moment the composition ends, the held rebuild happens. Skipping instead of holding would leave every highlight a character behind for as long as she kept typing — the same wrongness, arriving quietly instead of loudly.
|
||||||
|
- **The question is asked of the state *before* the transaction**, which is what makes the answer independent of plugin ordering: the flag was set by an earlier transaction (compositionstart), not by the one being applied. The end-of-composition transaction is the single exception — it releases rather than holds, or nothing ever would.
|
||||||
|
- **The release is a macrotask late, and that is the one piece of timing that matters.** A custom `handleDOMEvents` handler runs *before* ProseMirror's own, and ProseMirror's `compositionend` queues the composition's final DOM changes as a **microtask**. Ending on `setTimeout(…, 0)` puts the release after both, so the rebuild sees the committed 公园 rather than the *gongyuan* it replaced. If a transaction from that flush lands first, it rebuilds anyway — by then the flag is already false. Both orders land, which is why it is not a race.
|
||||||
|
- `blur` ends the composition too: clicking away mid-candidate doesn't always produce a `compositionend`, and without it the layers would stay held silently until she typed again.
|
||||||
|
- **Clearing is never held.** Closing the Find bar removes decorations rather than adding them, so it goes through immediately.
|
||||||
|
2. [x] **Input rules needed no guard, and this was checked rather than assumed** — Tiptap's own input-rule plugin returns early while `view.composing`. It matters more here than in an English app: pinyin uses an apostrophe as a syllable separator (`xi'an` → 西安) and `Typography.ts` rewrites every `'` into a curly `’`, so an unguarded rule would corrupt the IME's buffer mid-word.
|
||||||
|
3. [x] **The save is deliberately *not* gated; the analysis is.** A tablet keyboard can hold one composition open for a whole sentence, and Petal never makes writing wait for anything — so `EditorChange` carries a `composing` flag and the auto-save ignores it. What it gates is the checkpoint, the rule pack and the companion: asking them to read half-typed pinyin can only produce advice about text that is about to stop existing. The editor emits **one more change the moment the composition commits**, so nothing is skipped, only deferred by the length of a word. The flag stays out of the document patch itself — it describes the keyboard, not the document, and the draft a signed-out save stashes should be the document alone.
|
||||||
|
4. [x] **Enter and Escape belong to the IME while a candidate window is open** (`web/src/lib/ime.ts`), and four places were taking them: the Find bar (Enter steps to the next match, Escape closes), the tag picker (Enter creates the tag — a field whose whole purpose is a name typed in Chinese), **Ask Petal's chat box** (the field she types Mandarin into, where Enter sends), and distraction-free mode's global Escape (pressed to fix a wrong candidate, it popped the sidebar back). `isComposing` plus the older `keyCode === 229`, which some Safari/IME combinations are still the only signal from.
|
||||||
|
- Tests: `Composition.test.ts` (12) drives real plugin state through a real composition — pinyin typed in, candidate committed, composition ended — and asserts each layer holds, maps and then releases: no underline under half-typed pinyin, a suggestion list arriving mid-composition applied on end, matches held at two rather than briefly three, a checker that arrives mid-composition applied when it can be, and the release working on a transaction carrying **no document change at all**, which is what the timer-dispatched end signal usually is.
|
||||||
|
- Verified: go build/vet/test (untouched, but the standing rule), tsc, vite build, **vitest 296/296**.
|
||||||
|
- ⚠️ **Not verified with a real IME.** The tests exercise the layer where the decision is made — plugin state — and cannot exercise the layer where the bug bites, which is a browser deciding whether to abandon a composition after the DOM under it moved. This laptop has no Chrome extension and no IME. What is proven is that the redraws are held and released correctly; what is unproven is the browser's half of it.
|
||||||
|
- **Not deployed.** No migration; a rebuild whenever the user wants it, along with Phases 24–26.
|
||||||
|
|
||||||
### Later / explicitly not now
|
### Later / explicitly not now
|
||||||
- Learner-facing Chinese writing (the zh pair's second direction) — own phase with its own spec (SUGGESTIONS §4); only after Phases 19–21 prove the pair model
|
- Learner-facing Chinese writing (the zh pair's second direction) — own phase with its own spec (SUGGESTIONS §4); only after Phases 19–21 prove the pair model
|
||||||
- ~~Spanish pair — gated on DreamDict growing an es dataset~~ **ungated 2026-07-26**, **shipped (code) 2026-07-28** — see Phase 25. What it still owes: a deploy, a native reader, and a writer who actually uses it.
|
- ~~Spanish pair — gated on DreamDict growing an es dataset~~ **ungated 2026-07-26**, **shipped (code) 2026-07-28** — see Phase 25. What it still owes: a deploy, a native reader, and a writer who actually uses it.
|
||||||
- ~~Voseo for the es pair~~ **resolved 2026-07-28 before shipping** — the fix was not to generate the paradigm but to stop using Debian's package, which is peninsular. RLA's generic build has it. See Phase 25 item 2.
|
- ~~Voseo for the es pair~~ **resolved 2026-07-28 before shipping** — the fix was not to generate the paradigm but to stop using Debian's package, which is peninsular. RLA's generic build has it. See Phase 25 item 2.
|
||||||
- **IME composition guards** — scoped into Phase 26 and not built; see that entry. The decoration plugins recompute on every doc change, and doing so mid-composition is what eats half-typed pinyin. First thing to do before anyone types Chinese into Petal in earnest.
|
- ~~**IME composition guards** — scoped into Phase 26 and not built~~ **built 2026-07-28, see Phase 27.** What it still owes is a real IME in a real browser.
|
||||||
- **`restoring` is untranslated in the fr and pt-PT packs** — surfaced by the es review, fixed only in es. One line each, whenever those packs are next touched.
|
- **`restoring` is untranslated in the fr and pt-PT packs** — surfaced by the es review, fixed only in es. One line each, whenever those packs are next touched.
|
||||||
- Reactive-animation puppy companion — wishlist, low priority; `companions.ts` roster + mood engine is the drop-in point
|
- Reactive-animation puppy companion — wishlist, low priority; `companions.ts` roster + mood engine is the drop-in point
|
||||||
- Copyleaks Tier-2 — revisit once Phase 15 provides a public webhook endpoint
|
- Copyleaks Tier-2 — revisit once Phase 15 provides a public webhook endpoint
|
||||||
@@ -413,6 +430,7 @@ Scope agreed with the user 2026-07-28: **a `direction` column, segmentation + ho
|
|||||||
- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
|
- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
|
||||||
|
|
||||||
## Session log
|
## Session log
|
||||||
|
- 2026-07-28: **Phase 27 — the keystroke that isn't one** (user asked to continue the build plan; the plan's own next item was Phase 26's unbuilt IME guards, named there as the likeliest thing to be wrong the first time anyone types Chinese into Petal for real). **The bug is that a composition is not a keystroke**: the pinyin goes into the document as it is typed, a candidate window sits over it, and all three decoration layers recompute from the live document on every change — rewriting the DOM around the node the browser is composing in, which is what eats half-typed input. **The fix is to hold the redraws, not skip them**: a rebuild that falls due mid-composition marks itself stale and its decorations are *mapped through the transaction*, so they travel with the text growing under them and land correct the moment the composition ends. Skipping would have left every highlight a character behind for as long as she kept typing — the same wrongness, arriving quietly. **The composition flag is read from the state *before* the transaction**, which makes it independent of plugin ordering: the flag was set by compositionstart, not by the transaction being applied, and the end transaction is the one deliberate exception or nothing would ever release. **One piece of timing genuinely matters and is written down where it happens**: a custom `handleDOMEvents` handler runs *before* ProseMirror's own, and ProseMirror's compositionend queues the composition's last DOM changes as a microtask — so the release is a macrotask late, and the held rebuild sees the committed 公园 rather than the *gongyuan* it replaced. If the flush's transaction arrives first it rebuilds anyway; both orders land, which is what makes it not a race. **One thing was checked rather than assumed and turned out already handled**: Tiptap's input-rule plugin returns early while composing — which matters more here than in an English app, because pinyin uses an apostrophe as a syllable separator (xi'an → 西安) and Typography rewrites every `'` into a curly `’`. **The save is deliberately not gated and the analysis is**: a tablet keyboard can hold one composition open for a whole sentence, and Petal never makes writing wait for anything, so `EditorChange` carries the flag, auto-save ignores it, and the checkpoint/rule pack/companion wait — with one more change emitted the instant the composition commits, so nothing is skipped, only deferred by the length of a word. **And four places were quietly stealing keys from the IME**: the Find bar (Enter = next match, Escape = close), the tag picker (Enter = create, in a field whose purpose is a name typed in Chinese), Ask Petal's chat box (the field she types Mandarin into, where Enter sends), and distraction-free mode's global Escape — pressed to fix a wrong candidate, it popped the sidebar back. tsc, vite, go build/vet/test, **vitest 296/296** (12 new, driving real plugin state through a real composition: pinyin in, candidate committed, composition ended). ⚠️ **Not verified with a real IME** — the tests exercise the layer where the decision is made and cannot exercise the layer where the bug bites, which is a browser deciding whether to abandon a composition after the DOM under it moved. Held-and-released is proven; the browser's half is not. **Not deployed** (no migration — a rebuild, along with Phases 24–26).
|
||||||
- 2026-07-28: **Phase 26 — the zh pair's other direction, and a rule pack whose best feature is what it refuses** (user asked to continue the build plan, then chose a new phase over deploying fr/es; scope chosen with the user: **direction column, segmentation + hover pinyin/gloss, 错别字, IME guards**, code only). SUGGESTIONS §4 had called this its own epic, and the reason turned out to be one sentence: **`pair_lang` had always been answering a second question nobody asked.** It says which two languages; every surface built on it assumed English was the one being *learned*, which is why CJK is deliberately never tokenized, never spell-checked and never glossed. All correct for the writer this app was built for, all backwards for the other one. A `direction` column rather than a `zh-learner` pair code, because the two are genuinely separate questions and the column is what lets fr/es/pt inherit the capacity later. **The phase has three decisions in it and they are all about coverage.** The browser gets a word list and the server keeps the dictionary, and their gates come out *opposite*: the client list is frequency-gated at 5 because segmentation by the full union and by the gated list is **identical** on ordinary prose (measured, including 研究生命的起源 and 乒乓球拍卖完了 — the long tail is rare proper nouns and the max-probability walk never picks one), while the dictionary is gated by **nothing**, because its only power is to *explain* and the word a learner stops on is precisely the rare one. That is the es dictionary decision arrived at from both sides in one phase. **Writing the segmenter tests found the boundary bug**: a position names a gap and a word covers characters, so a hover on a boundary was resolving to the word that ended there rather than the one that starts. **The 错别字 pack is the part worth reading.** Chinese has no misspellings — every character an IME offers is real — so the unit of error is a substituted character inside a correct-looking word, and the pack is 24 confusable pairs held by two mechanical gates. Gate one admits a pair only if the wrong form is *not* a dictionary word and the right form is, which is what makes it refuse **自已 for 自己** — one of the commonest slips in written Chinese, whose wrong form is itself a headword — exactly as Phase 22 refused `married with`. Gate two is the one that matters: **自己经常 contains 己经, 睡觉的时候 contains 觉的, 不知到底 contains 知到**, so a substring match would corrupt correct sentences silently, into text still made of real characters. The segmenter settles it — two adjacent single-character tokens is what the walk produces when it has nothing better, which is what a mistyped compound looks like — and where the gate costs a real catch (我不知到他在哪里 *is* 知到 for 知道, but 不知 is a word) it declines rather than risk the identical-looking correct sentence beside it. **`@types/node` went in as a devDependency and quietly fixes something older**: phases 21–25 each verified their shipped dictionary with a throwaway script because vitest could not read files; the suite now asserts against the real assets. go build/vet/test, tsc, vite, **vitest 284/284**, live smoke on a throwaway DB which itself caught a cosmetic build bug ("cat (" left by stripping CC-CEDICT's CL: field). ⚠️ **The IME guards were in scope and are not done** — no composition handling exists anywhere in the app, and a decoration rebuild mid-composition eating half-typed pinyin is the likeliest thing to be wrong the first time anyone types Chinese into Petal for real. Left untouched rather than half-built, and named here rather than buried. ⚠️ **Not deployed** (it carries a migration), **not seen in a browser**, and **no account has ever been in the learner direction**, so everything above about how it feels to use is inference from tests.
|
- 2026-07-28: **Phase 26 — the zh pair's other direction, and a rule pack whose best feature is what it refuses** (user asked to continue the build plan, then chose a new phase over deploying fr/es; scope chosen with the user: **direction column, segmentation + hover pinyin/gloss, 错别字, IME guards**, code only). SUGGESTIONS §4 had called this its own epic, and the reason turned out to be one sentence: **`pair_lang` had always been answering a second question nobody asked.** It says which two languages; every surface built on it assumed English was the one being *learned*, which is why CJK is deliberately never tokenized, never spell-checked and never glossed. All correct for the writer this app was built for, all backwards for the other one. A `direction` column rather than a `zh-learner` pair code, because the two are genuinely separate questions and the column is what lets fr/es/pt inherit the capacity later. **The phase has three decisions in it and they are all about coverage.** The browser gets a word list and the server keeps the dictionary, and their gates come out *opposite*: the client list is frequency-gated at 5 because segmentation by the full union and by the gated list is **identical** on ordinary prose (measured, including 研究生命的起源 and 乒乓球拍卖完了 — the long tail is rare proper nouns and the max-probability walk never picks one), while the dictionary is gated by **nothing**, because its only power is to *explain* and the word a learner stops on is precisely the rare one. That is the es dictionary decision arrived at from both sides in one phase. **Writing the segmenter tests found the boundary bug**: a position names a gap and a word covers characters, so a hover on a boundary was resolving to the word that ended there rather than the one that starts. **The 错别字 pack is the part worth reading.** Chinese has no misspellings — every character an IME offers is real — so the unit of error is a substituted character inside a correct-looking word, and the pack is 24 confusable pairs held by two mechanical gates. Gate one admits a pair only if the wrong form is *not* a dictionary word and the right form is, which is what makes it refuse **自已 for 自己** — one of the commonest slips in written Chinese, whose wrong form is itself a headword — exactly as Phase 22 refused `married with`. Gate two is the one that matters: **自己经常 contains 己经, 睡觉的时候 contains 觉的, 不知到底 contains 知到**, so a substring match would corrupt correct sentences silently, into text still made of real characters. The segmenter settles it — two adjacent single-character tokens is what the walk produces when it has nothing better, which is what a mistyped compound looks like — and where the gate costs a real catch (我不知到他在哪里 *is* 知到 for 知道, but 不知 is a word) it declines rather than risk the identical-looking correct sentence beside it. **`@types/node` went in as a devDependency and quietly fixes something older**: phases 21–25 each verified their shipped dictionary with a throwaway script because vitest could not read files; the suite now asserts against the real assets. go build/vet/test, tsc, vite, **vitest 284/284**, live smoke on a throwaway DB which itself caught a cosmetic build bug ("cat (" left by stripping CC-CEDICT's CL: field). ⚠️ **The IME guards were in scope and are not done** — no composition handling exists anywhere in the app, and a decoration rebuild mid-composition eating half-typed pinyin is the likeliest thing to be wrong the first time anyone types Chinese into Petal for real. Left untouched rather than half-built, and named here rather than buried. ⚠️ **Not deployed** (it carries a migration), **not seen in a browser**, and **no account has ever been in the learner direction**, so everything above about how it feels to use is inference from tests.
|
||||||
- 2026-07-28: **Phase 25 — the es pair, and a plan that had quietly chosen the wrong Spanish** (user asked where Spanish support had gone, then "yes" to starting the phase; scope chosen with the user: **Latin American neutral**, quorum review, code only). The starting point was a misreading worth recording: the plan *reads* as though Spanish shipped, because the DreamDict rebuild, the LLM language entry, the L1 rule gating and the TTS env-discovery are all `[x]` — every piece of groundwork was done and the pair itself had never been built. `shippedPairs` was the honest answer all along: the server had been refusing `es` on purpose. **The regional question was the phase.** pt-PT's was forced by packaging and fr's turned out not to exist; es had a real choice with no default, and once the user chose Latin American, the plan's own two concrete decisions were both wrong. It warned that `hunspell-es` is "packaged per country — check what `es_ES` actually is": it ships twenty country codes and **every one is a symlink to one pan-Hispanic file**, so the trap was not there. And it named **`es_ES-davefx-medium`** for the voice, which *is* the trap — six of Piper's nine Spanish voices are peninsular, so the obvious pick would have read Latin American copy in a Castilian accent, the pt-PT mistake arriving through a different door. `es_MX-ald-medium` instead. **Then the user asked "should we pick a different Spanish dictionary?" and the answer was yes** — the phase had shipped the wrong one and written a confident justification for it. Debian's `hunspell-es` symlinks twenty country codes to one file, which reads as pan-Hispanic; RLA actually publishes twenty-four builds per release, one per country **plus a generic `es` that is the union**, and Debian ships **peninsular `es_ES`**. The 58,622-form difference is essentially **voseo**: under the first build, *vení* and *tenés* — the ordinary present tense of Argentina, Uruguay, Paraguay and much of Central America — were underlined as misspellings, and this document called that a known gap handled on principle. **What makes it worth writing down is that the MUST_ACCEPT list was designed to catch exactly this and could not**: it asserted the pan-Hispanic *vocabulary*, and every RLA variant carries the full pan-Hispanic vocabulary — only the paradigms are localised — so it was satisfiable by all twenty-four. The `REP` table cited as the corroborating witness (yeísmo, seseo) is likewise shared by every build. Two independent-looking proofs, neither of which could distinguish anything, agreeing with each other. The profile now demands **voseo** (rejects es_ES and Debian), **vosotros** (rejects es_MX) and **another region's everyday words** — *arepa*, *chévere*, *bacán* (rejects es_AR, which has both paradigms and would otherwise pass); all four neighbours were run through it and confirmed refused. Shipping the union is the same call fr made between *coût* and *cout*: the dictionary's only power is to underline, so it holds every variety, while the *copy* picks a register because speaking requires one. 717,640 forms, 1.74 MB gzipped, **762 ms / 97 MB** in a real nspell, and **fr and pt-PT rebuild byte-identical** from their own upstream debs. **The quorum review earned its place twice**: four models, ≥2-of-4, 5 of 27 findings applied — one of which caught the pack's bedtime proverb being *Qui dort dîne* calqued into Spanish, English gloss and all, which is exactly the "a pack is not a translation of another pack" rule the fr header states and I had broken while writing it. And one below-threshold finding (a missing `¡` on an exclamative, seen by 1 of 4 because an absent *opening* mark has no closing `!` to look wrong against) was applied anyway and **turned into an assertion**: the suite now rejects any native line that closes `?`/`!` without opening one. That is Phase 24's lesson one level up — what a review finds once, a test should find every time. go build/vet/test, tsc, vite, **vitest 251/251**. ⚠️ **Not deployed, not seen in a browser, not read by a native speaker, and no es account exists** — all four accounts' worth of Spanish experience is still hypothetical, and the pack says so in its own header.
|
- 2026-07-28: **Phase 25 — the es pair, and a plan that had quietly chosen the wrong Spanish** (user asked where Spanish support had gone, then "yes" to starting the phase; scope chosen with the user: **Latin American neutral**, quorum review, code only). The starting point was a misreading worth recording: the plan *reads* as though Spanish shipped, because the DreamDict rebuild, the LLM language entry, the L1 rule gating and the TTS env-discovery are all `[x]` — every piece of groundwork was done and the pair itself had never been built. `shippedPairs` was the honest answer all along: the server had been refusing `es` on purpose. **The regional question was the phase.** pt-PT's was forced by packaging and fr's turned out not to exist; es had a real choice with no default, and once the user chose Latin American, the plan's own two concrete decisions were both wrong. It warned that `hunspell-es` is "packaged per country — check what `es_ES` actually is": it ships twenty country codes and **every one is a symlink to one pan-Hispanic file**, so the trap was not there. And it named **`es_ES-davefx-medium`** for the voice, which *is* the trap — six of Piper's nine Spanish voices are peninsular, so the obvious pick would have read Latin American copy in a Castilian accent, the pt-PT mistake arriving through a different door. `es_MX-ald-medium` instead. **Then the user asked "should we pick a different Spanish dictionary?" and the answer was yes** — the phase had shipped the wrong one and written a confident justification for it. Debian's `hunspell-es` symlinks twenty country codes to one file, which reads as pan-Hispanic; RLA actually publishes twenty-four builds per release, one per country **plus a generic `es` that is the union**, and Debian ships **peninsular `es_ES`**. The 58,622-form difference is essentially **voseo**: under the first build, *vení* and *tenés* — the ordinary present tense of Argentina, Uruguay, Paraguay and much of Central America — were underlined as misspellings, and this document called that a known gap handled on principle. **What makes it worth writing down is that the MUST_ACCEPT list was designed to catch exactly this and could not**: it asserted the pan-Hispanic *vocabulary*, and every RLA variant carries the full pan-Hispanic vocabulary — only the paradigms are localised — so it was satisfiable by all twenty-four. The `REP` table cited as the corroborating witness (yeísmo, seseo) is likewise shared by every build. Two independent-looking proofs, neither of which could distinguish anything, agreeing with each other. The profile now demands **voseo** (rejects es_ES and Debian), **vosotros** (rejects es_MX) and **another region's everyday words** — *arepa*, *chévere*, *bacán* (rejects es_AR, which has both paradigms and would otherwise pass); all four neighbours were run through it and confirmed refused. Shipping the union is the same call fr made between *coût* and *cout*: the dictionary's only power is to underline, so it holds every variety, while the *copy* picks a register because speaking requires one. 717,640 forms, 1.74 MB gzipped, **762 ms / 97 MB** in a real nspell, and **fr and pt-PT rebuild byte-identical** from their own upstream debs. **The quorum review earned its place twice**: four models, ≥2-of-4, 5 of 27 findings applied — one of which caught the pack's bedtime proverb being *Qui dort dîne* calqued into Spanish, English gloss and all, which is exactly the "a pack is not a translation of another pack" rule the fr header states and I had broken while writing it. And one below-threshold finding (a missing `¡` on an exclamative, seen by 1 of 4 because an absent *opening* mark has no closing `!` to look wrong against) was applied anyway and **turned into an assertion**: the suite now rejects any native line that closes `?`/`!` without opening one. That is Phase 24's lesson one level up — what a review finds once, a test should find every time. go build/vet/test, tsc, vite, **vitest 251/251**. ⚠️ **Not deployed, not seen in a browser, not read by a native speaker, and no es account exists** — all four accounts' worth of Spanish experience is still hypothetical, and the pack says so in its own header.
|
||||||
- 2026-07-27: **Phase 24 — the fr pair, and a "generalizes" that did not** (user: "resume the build plan"; scope chosen with the user: French end to end, code only, deploy its own step). The plan's five items were meant to be mechanical, and four of them were — the Piper voice is a compose service and two env lines because Phase 21 made a language configuration; the lexicon needed nothing at all, fr having been measured at 63.1% during Phase 20's rebuild, better than the pair that already shipped; the sidebar picker grew a third entry without a line of UI because it derives itself from the shipped packs. **Item 3 was the one that had been recorded as done and wasn't.** `build_ptpt_dictionary.py` was said to generalize; it handled single-character flags and plain PFX/SFX and stopped on everything else, and `fr.aff` uses four of the things it stopped on. `FLAG long` is the dangerous one: French flags are two characters, so the pt-PT reader's `set(flagstr)` yields a bag of unrelated letters and expands every entry through the wrong paradigm without erroring. Plus continuation flags (French really does affix an affixed form), NEEDAFFIX on 68,075 of 84,140 stems, and FULLSTRIP. The rewritten `build_hunspell_dictionary.py` carries a per-language profile and asserts that CIRCUMFIX and FORBIDDENWORD are still unused — and **rebuilds pt-PT byte-identical to the shipped asset**, which is the only thing that makes "generalized" a claim rather than a hope. **The second decision was elision, and it was made by measuring both halves**: keeping `l'arbre` and its thirty-three siblings costs 3,159,832 forms and 8.25 MB gzipped; dropping them costs 473,326 and 1.19 MB. They are not new words, but the tokenizer keeps internal apostrophes, so they really would have been underlined — so they moved out of the dictionary and into `withElision`, which splits at a known clitic and still requires the remainder to be a word (`l'zzzz` stays flagged). Real nspell: 369 ms and 74 MB for the larger language, against pt-PT's 842 ms and 139 MB. **Where the regional trap lives is the mirror image of Portuguese's**: every `fr_*` Piper voice is fr_FR and every Debian fr dictionary is one shared word list, so nothing can be quietly wrong about the country — the whole decision is in the copy, which is why the pack is greped for *courriel* and *magasiner* the way pt-PT is greped for *arquivo*. What French does have instead is the 1990 reform, packaged three ways; Petal ships comprehensive, because Petal never corrects her French and *coût* and *cout* are both correct. go build/vet/test, tsc, vite, vitest 190/190. **Two things owed and both said plainly**: no native speaker has read the pack (SUGGESTIONS §3's bar, unmet for pt-PT too), and nothing here has been seen in a browser. **Then, same session, an interim answer to the first of those** (user: "perhaps for now, we could leverage multiple LLMs to act as reviewers?"): four models reviewed each Latin pack independently, and only findings ≥2 of them reached on their own were applied — five per pack. It earned its keep on the pack that was *already shipped*: pt-PT had **pre-Acordo spellings in a file whose own header commits to post-Acordo**, because the Phase 21 greps checked for Brazilian vocabulary and never checked the pack against its own spelling policy. That grep now exists and was confirmed to fail on the old text. Where reviewers agreed a line was wrong but split on the fix, the wording is mine and the reasoning is in the phase entry rather than averaged away. Still not a native speaker, and both packs now say so precisely.
|
- 2026-07-27: **Phase 24 — the fr pair, and a "generalizes" that did not** (user: "resume the build plan"; scope chosen with the user: French end to end, code only, deploy its own step). The plan's five items were meant to be mechanical, and four of them were — the Piper voice is a compose service and two env lines because Phase 21 made a language configuration; the lexicon needed nothing at all, fr having been measured at 63.1% during Phase 20's rebuild, better than the pair that already shipped; the sidebar picker grew a third entry without a line of UI because it derives itself from the shipped packs. **Item 3 was the one that had been recorded as done and wasn't.** `build_ptpt_dictionary.py` was said to generalize; it handled single-character flags and plain PFX/SFX and stopped on everything else, and `fr.aff` uses four of the things it stopped on. `FLAG long` is the dangerous one: French flags are two characters, so the pt-PT reader's `set(flagstr)` yields a bag of unrelated letters and expands every entry through the wrong paradigm without erroring. Plus continuation flags (French really does affix an affixed form), NEEDAFFIX on 68,075 of 84,140 stems, and FULLSTRIP. The rewritten `build_hunspell_dictionary.py` carries a per-language profile and asserts that CIRCUMFIX and FORBIDDENWORD are still unused — and **rebuilds pt-PT byte-identical to the shipped asset**, which is the only thing that makes "generalized" a claim rather than a hope. **The second decision was elision, and it was made by measuring both halves**: keeping `l'arbre` and its thirty-three siblings costs 3,159,832 forms and 8.25 MB gzipped; dropping them costs 473,326 and 1.19 MB. They are not new words, but the tokenizer keeps internal apostrophes, so they really would have been underlined — so they moved out of the dictionary and into `withElision`, which splits at a known clitic and still requires the remainder to be a word (`l'zzzz` stays flagged). Real nspell: 369 ms and 74 MB for the larger language, against pt-PT's 842 ms and 139 MB. **Where the regional trap lives is the mirror image of Portuguese's**: every `fr_*` Piper voice is fr_FR and every Debian fr dictionary is one shared word list, so nothing can be quietly wrong about the country — the whole decision is in the copy, which is why the pack is greped for *courriel* and *magasiner* the way pt-PT is greped for *arquivo*. What French does have instead is the 1990 reform, packaged three ways; Petal ships comprehensive, because Petal never corrects her French and *coût* and *cout* are both correct. go build/vet/test, tsc, vite, vitest 190/190. **Two things owed and both said plainly**: no native speaker has read the pack (SUGGESTIONS §3's bar, unmet for pt-PT too), and nothing here has been seen in a browser. **Then, same session, an interim answer to the first of those** (user: "perhaps for now, we could leverage multiple LLMs to act as reviewers?"): four models reviewed each Latin pack independently, and only findings ≥2 of them reached on their own were applied — five per pack. It earned its keep on the pack that was *already shipped*: pt-PT had **pre-Acordo spellings in a file whose own header commits to post-Acordo**, because the Phase 21 greps checked for Brazilian vocabulary and never checked the pack against its own spelling policy. That grep now exists and was confirmed to fail on the old text. Where reviewers agreed a line was wrong but split on the fix, the wording is mine and the reasoning is in the phase entry rather than averaged away. Still not a native speaker, and both packs now say so precisely.
|
||||||
|
|||||||
+21
-6
@@ -23,6 +23,7 @@ import { PetalFall } from './effects/PetalFall'
|
|||||||
import { usePack } from './i18n'
|
import { usePack } from './i18n'
|
||||||
import { useNightMode } from './hooks/useNightMode'
|
import { useNightMode } from './hooks/useNightMode'
|
||||||
import { playSuggestionSound } from './audio/sounds'
|
import { playSuggestionSound } from './audio/sounds'
|
||||||
|
import { fromIME } from './lib/ime'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const updateAvailable = useVersionWatch()
|
const updateAvailable = useVersionWatch()
|
||||||
@@ -331,14 +332,25 @@ export default function App() {
|
|||||||
|
|
||||||
const handleEditorChange = useCallback(
|
const handleEditorChange = useCallback(
|
||||||
(change: EditorChange) => {
|
(change: EditorChange) => {
|
||||||
|
const { composing, ...patch } = change
|
||||||
setWordCount(change.word_count)
|
setWordCount(change.word_count)
|
||||||
setDocText(change.content_text)
|
|
||||||
setEditTick((n) => n + 1)
|
|
||||||
if (currentDoc) {
|
if (currentDoc) {
|
||||||
patchSummary(currentDoc.id, { word_count: change.word_count })
|
patchSummary(currentDoc.id, { word_count: change.word_count })
|
||||||
schedule(change)
|
// The save is never held: see EditorChange.composing. The flag itself
|
||||||
scheduleCheckpoint(change.content_text)
|
// stays out of the patch — it describes the keyboard, not the document,
|
||||||
|
// and the stashed draft a signed-out save leaves behind should be the
|
||||||
|
// document alone.
|
||||||
|
schedule(patch)
|
||||||
}
|
}
|
||||||
|
// Everything below reads the text as prose. While an IME composition is
|
||||||
|
// in flight it is not prose yet — it is the pinyin she is converting — so
|
||||||
|
// the checkpoint, the rule pack and the companion all wait for the word
|
||||||
|
// to commit. EditorCore emits one more change the moment it does, so
|
||||||
|
// nothing is skipped, only deferred by the length of a word.
|
||||||
|
if (composing) return
|
||||||
|
setDocText(change.content_text)
|
||||||
|
setEditTick((n) => n + 1)
|
||||||
|
if (currentDoc) scheduleCheckpoint(change.content_text)
|
||||||
},
|
},
|
||||||
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
[currentDoc, patchSummary, schedule, scheduleCheckpoint],
|
||||||
)
|
)
|
||||||
@@ -414,11 +426,14 @@ export default function App() {
|
|||||||
[patchSummary],
|
[patchSummary],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Escape always restores the sidebar while in distraction-free mode.
|
// Escape always restores the sidebar while in distraction-free mode — unless
|
||||||
|
// it belongs to an IME, where it cancels a candidate and never reaches Petal
|
||||||
|
// at all. This is the writer typing Chinese in the very mode built for
|
||||||
|
// uninterrupted writing, so it is the one worth getting right.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focusMode) return
|
if (!focusMode) return
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') setFocusMode(false)
|
if (e.key === 'Escape' && !fromIME(e)) setFocusMode(false)
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { tagColorVar, type Tag, type TagColor } from '../../api/client'
|
import { tagColorVar, type Tag, type TagColor } from '../../api/client'
|
||||||
import { usePack } from '../../i18n'
|
import { usePack } from '../../i18n'
|
||||||
|
import { fromIME } from '../../lib/ime'
|
||||||
|
|
||||||
const COLORS: TagColor[] = ['rose', 'mint', 'peach', 'lavender', 'sky', 'honey']
|
const COLORS: TagColor[] = ['rose', 'mint', 'peach', 'lavender', 'sky', 'honey']
|
||||||
|
|
||||||
@@ -26,7 +27,9 @@ export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }:
|
|||||||
if (!ref.current?.contains(e.target as Node)) onClose()
|
if (!ref.current?.contains(e.target as Node)) onClose()
|
||||||
}
|
}
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
// Not while an IME is open: a tag named in Chinese is composed in this
|
||||||
|
// very field, and Escape there means "wrong candidate", not "close".
|
||||||
|
if (e.key === 'Escape' && !fromIME(e)) onClose()
|
||||||
}
|
}
|
||||||
// Defer so the opening click doesn't immediately close it.
|
// Defer so the opening click doesn't immediately close it.
|
||||||
const id = setTimeout(() => document.addEventListener('pointerdown', onDown), 0)
|
const id = setTimeout(() => document.addEventListener('pointerdown', onDown), 0)
|
||||||
@@ -114,7 +117,7 @@ export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }:
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') submit()
|
if (e.key === 'Enter' && !fromIME(e)) submit()
|
||||||
}}
|
}}
|
||||||
placeholder={t.docs.newTagPlaceholder}
|
placeholder={t.docs.newTagPlaceholder}
|
||||||
aria-label="New tag name"
|
aria-label="New tag name"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
|
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
|
||||||
import { usePack } from '../../i18n'
|
import { usePack } from '../../i18n'
|
||||||
import { splitBilingual } from './bilingualReply'
|
import { splitBilingual } from './bilingualReply'
|
||||||
|
import { fromIME } from '../../lib/ime'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
suggestionId: string
|
suggestionId: string
|
||||||
@@ -177,6 +178,13 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
// She asks Petal in Mandarin, so the Enter that commits an IME
|
||||||
|
// candidate lands in this field constantly. Most browsers already
|
||||||
|
// withhold implicit form submission during a composition; the ones
|
||||||
|
// that don't would send her half-typed question. Cheap to be certain.
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && fromIME(e)) e.preventDefault()
|
||||||
|
}}
|
||||||
placeholder={t.editor.askPlaceholder}
|
placeholder={t.editor.askPlaceholder}
|
||||||
className="min-w-0 flex-1 rounded-full px-3 py-1.5 text-xs focus:outline-none"
|
className="min-w-0 flex-1 rounded-full px-3 py-1.5 text-xs focus:outline-none"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { EditorState, TextSelection } from '@tiptap/pm/state'
|
||||||
|
import type { Transaction } from '@tiptap/pm/state'
|
||||||
|
import { Schema } from '@tiptap/pm/model'
|
||||||
|
import { compositionKey, compositionPlugin, isComposing, holdRedraw } from './Composition'
|
||||||
|
import { suggestionPlugin, suggestionPluginKey, setSuggestions } from './SuggestionHighlight'
|
||||||
|
import { spellPlugin, spellPluginKey, setSpellChecker } from './SpellCheck'
|
||||||
|
import { searchPlugin, searchPluginKey, setSearch } from './SearchHighlight'
|
||||||
|
import type { Suggestion } from '../../api/client'
|
||||||
|
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||||
|
|
||||||
|
// These tests are about one moment: she is typing 公园 with a pinyin IME, so the
|
||||||
|
// document briefly contains "gongyuan" and a candidate window sits over it. Every
|
||||||
|
// decoration layer wants to recompute, and recomputing rewrites the DOM around
|
||||||
|
// the node the browser is composing in — which is what eats half-typed input.
|
||||||
|
//
|
||||||
|
// Nothing here needs a real EditorView: composition is tracked in plugin state
|
||||||
|
// by the compositionstart/compositionend handlers, so a plain EditorState with
|
||||||
|
// the same plugins reproduces exactly the decisions the layers make.
|
||||||
|
|
||||||
|
const schema = new Schema({
|
||||||
|
nodes: {
|
||||||
|
doc: { content: 'block+' },
|
||||||
|
paragraph: { group: 'block', content: 'inline*', toDOM: () => ['p', 0] },
|
||||||
|
text: { group: 'inline' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const doc = (text: string) =>
|
||||||
|
schema.node('doc', null, [schema.node('paragraph', null, text ? [schema.text(text)] : [])])
|
||||||
|
|
||||||
|
// A dictionary that knows ordinary English and nothing else — so the pinyin run
|
||||||
|
// an IME leaves in the document mid-composition is a misspelling to it, which is
|
||||||
|
// precisely the risk this guard exists for.
|
||||||
|
const english: SpellChecker = {
|
||||||
|
correct: (w) => ['the', 'park', 'went', 'to', 'today'].includes(w.toLowerCase()),
|
||||||
|
suggest: () => [],
|
||||||
|
extendedAlphabet: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestion = (original: string, replacement: string): Suggestion => ({
|
||||||
|
id: `s-${original}`,
|
||||||
|
doc_id: 'd',
|
||||||
|
from_pos: 0,
|
||||||
|
to_pos: 0,
|
||||||
|
original,
|
||||||
|
replacement,
|
||||||
|
explanation: '',
|
||||||
|
type: 'grammar',
|
||||||
|
status: 'pending',
|
||||||
|
source: 'llm',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function harness(text: string) {
|
||||||
|
let state = EditorState.create({
|
||||||
|
schema,
|
||||||
|
doc: doc(text),
|
||||||
|
plugins: [compositionPlugin({ onEnd: null }), suggestionPlugin(), spellPlugin(), searchPlugin()],
|
||||||
|
})
|
||||||
|
const api = {
|
||||||
|
get state() {
|
||||||
|
return state
|
||||||
|
},
|
||||||
|
tr: (f: (tr: Transaction) => Transaction) => {
|
||||||
|
state = state.apply(f(state.tr))
|
||||||
|
},
|
||||||
|
dispatch: (tr: Transaction) => {
|
||||||
|
state = state.apply(tr)
|
||||||
|
},
|
||||||
|
// The two ends of a composition, as the DOM handlers dispatch them.
|
||||||
|
startComposing: () => api.tr((tr) => tr.setMeta(compositionKey, true)),
|
||||||
|
endComposing: () => api.tr((tr) => tr.setMeta(compositionKey, false)),
|
||||||
|
// Typing, whether by keystroke or by an IME writing into the document.
|
||||||
|
type: (at: number, text: string) =>
|
||||||
|
api.tr((tr) => tr.insertText(text, at).setSelection(TextSelection.create(tr.doc, at + text.length))),
|
||||||
|
// Replace a span, the way committing an IME candidate does.
|
||||||
|
commit: (from: number, to: number, text: string) => api.tr((tr) => tr.insertText(text, from, to)),
|
||||||
|
spans: (key: typeof suggestionPluginKey | typeof spellPluginKey | typeof searchPluginKey) => {
|
||||||
|
const deco = (key.getState(state) as { decorations: import('@tiptap/pm/view').DecorationSet }).decorations
|
||||||
|
return deco.find().map((d) => [d.from, d.to] as const)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('composition tracking', () => {
|
||||||
|
it('is off until a composition starts, and off again once it ends', () => {
|
||||||
|
const h = harness('I went to the ')
|
||||||
|
expect(isComposing(h.state)).toBe(false)
|
||||||
|
h.startComposing()
|
||||||
|
expect(isComposing(h.state)).toBe(true)
|
||||||
|
h.endComposing()
|
||||||
|
expect(isComposing(h.state)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('releases the redraw on the very transaction that ends the composition', () => {
|
||||||
|
const h = harness('hello')
|
||||||
|
h.startComposing()
|
||||||
|
const before = h.state
|
||||||
|
expect(holdRedraw(before.tr, before)).toBe(true)
|
||||||
|
// The end transaction is dispatched while composing is still true; if it
|
||||||
|
// held its own redraw like any other, nothing would ever release it.
|
||||||
|
expect(holdRedraw(before.tr.setMeta(compositionKey, false), before)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('spell underlines during composition', () => {
|
||||||
|
it('does not underline the pinyin she is part-way through converting', () => {
|
||||||
|
const h = harness('I went to the ')
|
||||||
|
h.dispatch(h.state.tr.setMeta(spellPluginKey, english))
|
||||||
|
expect(h.spans(spellPluginKey)).toEqual([])
|
||||||
|
|
||||||
|
h.startComposing()
|
||||||
|
// The IME writes its buffer into the document one letter at a time. The
|
||||||
|
// caret sits inside the run, so the caret exemption would cover "gongyuan"
|
||||||
|
// on its own — but not a second word, and not once she moves back to fix a
|
||||||
|
// syllable. The guard is what makes that irrelevant.
|
||||||
|
h.type(15, 'gong')
|
||||||
|
h.type(19, 'yuan')
|
||||||
|
expect(h.spans(spellPluginKey)).toEqual([])
|
||||||
|
// And the caret has moved away, which normally forces a rebuild.
|
||||||
|
h.tr((tr) => tr.setSelection(TextSelection.create(tr.doc, 1)))
|
||||||
|
expect(h.spans(spellPluginKey)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-checks the moment the candidate is committed', () => {
|
||||||
|
const h = harness('I went to the ')
|
||||||
|
h.dispatch(h.state.tr.setMeta(spellPluginKey, english))
|
||||||
|
h.startComposing()
|
||||||
|
h.type(15, 'gongyuan')
|
||||||
|
h.commit(15, 23, '公园') // she picks 公园; the pinyin is gone
|
||||||
|
h.endComposing()
|
||||||
|
// Nothing to flag: the pinyin never existed by the time anyone looked, and
|
||||||
|
// CJK is not tokenized at all.
|
||||||
|
expect(h.spans(spellPluginKey)).toEqual([])
|
||||||
|
|
||||||
|
// A real misspelling typed afterwards still underlines, so the layer is
|
||||||
|
// released rather than switched off. (The caret moves off it first: a word
|
||||||
|
// under the cursor is exempt, mid-typing, IME or no IME.)
|
||||||
|
h.type(17, ' parc')
|
||||||
|
h.tr((tr) => tr.setSelection(TextSelection.create(tr.doc, 1)))
|
||||||
|
expect(h.spans(spellPluginKey).length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('underlines the same text immediately when no IME is involved', () => {
|
||||||
|
const h = harness('I went to the ')
|
||||||
|
h.dispatch(h.state.tr.setMeta(spellPluginKey, english))
|
||||||
|
h.type(15, 'gongyuan')
|
||||||
|
h.tr((tr) => tr.setSelection(TextSelection.create(tr.doc, 1)))
|
||||||
|
expect(h.spans(spellPluginKey).length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('suggestion highlights during composition', () => {
|
||||||
|
it('carries existing highlights along with the text instead of re-anchoring', () => {
|
||||||
|
const h = harness('I went to the park today')
|
||||||
|
setSuggestions(h.state, h.dispatch, [suggestion('went to', 'go to')])
|
||||||
|
expect(h.spans(suggestionPluginKey)).toEqual([[3, 10]])
|
||||||
|
|
||||||
|
h.startComposing()
|
||||||
|
h.type(1, 'x') // insert before the highlight: it has to move with the text
|
||||||
|
expect(h.spans(suggestionPluginKey)).toEqual([[4, 11]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('holds a freshly arrived suggestion list until the composition ends', () => {
|
||||||
|
const h = harness('I went to the park today')
|
||||||
|
h.startComposing()
|
||||||
|
setSuggestions(h.state, h.dispatch, [suggestion('the park', 'a park')])
|
||||||
|
// The list is stored, but the page is not repainted under the IME.
|
||||||
|
expect(h.spans(suggestionPluginKey)).toEqual([])
|
||||||
|
h.endComposing()
|
||||||
|
expect(h.spans(suggestionPluginKey)).toEqual([[11, 19]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-anchors against the committed text, not the pinyin it replaced', () => {
|
||||||
|
const h = harness('I went to ')
|
||||||
|
setSuggestions(h.state, h.dispatch, [suggestion('公园', '花园')])
|
||||||
|
expect(h.spans(suggestionPluginKey)).toEqual([]) // not there yet
|
||||||
|
h.startComposing()
|
||||||
|
h.type(11, 'gongyuan')
|
||||||
|
h.commit(11, 19, '公园')
|
||||||
|
h.endComposing()
|
||||||
|
expect(h.spans(suggestionPluginKey)).toEqual([[11, 13]])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('find-and-replace highlights during composition', () => {
|
||||||
|
it('holds the match set, then refreshes it against the committed text', () => {
|
||||||
|
const h = harness('公园 and 公园')
|
||||||
|
setSearch(h.state, h.dispatch, '公园', false)
|
||||||
|
expect(h.spans(searchPluginKey).length).toBe(2)
|
||||||
|
|
||||||
|
h.startComposing()
|
||||||
|
h.type(10, ' gongyuan') // at the end of the text, where the caret is
|
||||||
|
expect(h.spans(searchPluginKey).length).toBe(2) // still two, not three
|
||||||
|
h.commit(11, 19, '公园')
|
||||||
|
h.endComposing()
|
||||||
|
expect(h.spans(searchPluginKey).length).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closing the bar clears immediately — a composition never holds a removal', () => {
|
||||||
|
const h = harness('公园 and 公园')
|
||||||
|
setSearch(h.state, h.dispatch, '公园', false)
|
||||||
|
h.startComposing()
|
||||||
|
h.dispatch(h.state.tr.setMeta(searchPluginKey, { kind: 'clear' }))
|
||||||
|
expect(h.spans(searchPluginKey)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the layers are only paused, never left stale', () => {
|
||||||
|
it('rebuilds even if the composition ends on a transaction of its own', () => {
|
||||||
|
// The end signal is dispatched on a timer, after ProseMirror has flushed the
|
||||||
|
// composition's last document change — so the releasing transaction usually
|
||||||
|
// carries no document change at all. That must still be enough.
|
||||||
|
const h = harness('I went to the ')
|
||||||
|
h.dispatch(h.state.tr.setMeta(spellPluginKey, english))
|
||||||
|
h.startComposing()
|
||||||
|
h.type(15, 'parc')
|
||||||
|
expect(h.spans(spellPluginKey)).toEqual([])
|
||||||
|
h.tr((tr) => tr.setSelection(TextSelection.create(tr.doc, 1)))
|
||||||
|
h.endComposing() // no doc change, no selection change
|
||||||
|
expect(h.spans(spellPluginKey).length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a checker arriving mid-composition is applied once it ends', () => {
|
||||||
|
const h = harness('公园 parc')
|
||||||
|
h.startComposing()
|
||||||
|
setSpellChecker(h.state, h.dispatch, english)
|
||||||
|
expect(h.spans(spellPluginKey)).toEqual([])
|
||||||
|
h.tr((tr) => tr.setSelection(TextSelection.create(tr.doc, 1)))
|
||||||
|
h.endComposing()
|
||||||
|
expect(h.spans(spellPluginKey).length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
||||||
|
import type { EditorView } from '@tiptap/pm/view'
|
||||||
|
|
||||||
|
// Composition tracks whether an IME composition is in flight, and is the one
|
||||||
|
// place the rest of the editor asks.
|
||||||
|
//
|
||||||
|
// Why it exists: typing Chinese (or Japanese, or Korean) does not produce
|
||||||
|
// characters a keystroke at a time. The IME opens a *composition* — the pinyin
|
||||||
|
// she types goes into the document as it is typed, a candidate window sits over
|
||||||
|
// it, and only when she picks a candidate is the run replaced with hanzi.
|
||||||
|
// Petal's three decoration layers (SuggestionHighlight, SpellCheck,
|
||||||
|
// SearchHighlight) all recompute from the live document on every change, so
|
||||||
|
// mid-composition they would recompute over half-typed pinyin — and rebuilding
|
||||||
|
// decorations means rewriting the DOM around the node the IME is composing in.
|
||||||
|
// That is the classic bug that eats half-typed input: the composition is
|
||||||
|
// abandoned by the browser and the letters vanish or double.
|
||||||
|
//
|
||||||
|
// The fix is to hold the redraws, not to skip them. Decorations that are due
|
||||||
|
// while a composition is in flight are kept (mapped through the transaction, so
|
||||||
|
// they follow the text that moved) and rebuilt the moment the composition ends.
|
||||||
|
// Nothing is lost — the pause is measured in the length of one word.
|
||||||
|
//
|
||||||
|
// Input rules need no guard here: Tiptap's own input-rule plugin already returns
|
||||||
|
// early while `view.composing` is true, which matters because pinyin uses an
|
||||||
|
// apostrophe as a syllable separator (xi'an → 西安) and Typography.ts rewrites
|
||||||
|
// every ' into a curly ’.
|
||||||
|
|
||||||
|
export const compositionKey = new PluginKey<boolean>('petalComposition')
|
||||||
|
|
||||||
|
// isComposing answers "was an IME composition in flight as of this state?".
|
||||||
|
// Decoration plugins ask it of the state *before* the transaction they are
|
||||||
|
// applying, which is what makes the answer independent of plugin ordering: the
|
||||||
|
// flag was set by an earlier transaction (compositionstart), not by this one.
|
||||||
|
export function isComposing(state: EditorState): boolean {
|
||||||
|
return compositionKey.getState(state) === true
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdRedraw is the question every decoration layer asks in its `apply`: should
|
||||||
|
// this rebuild wait? Yes while composing — except on the transaction that ends
|
||||||
|
// the composition, which is precisely the one that releases the held redraws.
|
||||||
|
export function holdRedraw(tr: Transaction, stateBefore: EditorState): boolean {
|
||||||
|
if (tr.getMeta(compositionKey) === false) return false
|
||||||
|
return isComposing(stateBefore)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setComposing(view: EditorView, composing: boolean) {
|
||||||
|
if (compositionKey.getState(view.state) === composing) return
|
||||||
|
view.dispatch(view.state.tr.setMeta(compositionKey, composing))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionOptions {
|
||||||
|
// Called once after a composition has ended and the document has settled.
|
||||||
|
// EditorCore uses it to re-report the committed text, since the analysis
|
||||||
|
// passes were told to ignore everything typed while composing.
|
||||||
|
onEnd: (() => void) | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compositionPlugin(options: CompositionOptions): Plugin<boolean> {
|
||||||
|
return new Plugin<boolean>({
|
||||||
|
key: compositionKey,
|
||||||
|
state: {
|
||||||
|
init: () => false,
|
||||||
|
apply(tr, value) {
|
||||||
|
const meta = tr.getMeta(compositionKey)
|
||||||
|
return typeof meta === 'boolean' ? meta : value
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
compositionstart: (view) => {
|
||||||
|
setComposing(view, true)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
// A custom handleDOMEvents handler runs *before* ProseMirror's own, and
|
||||||
|
// ProseMirror's compositionend queues the composition's final DOM
|
||||||
|
// changes as a microtask. Ending on a macrotask puts us after both, so
|
||||||
|
// the rebuild we release sees the committed hanzi rather than the pinyin
|
||||||
|
// it replaced. (If a transaction from that flush arrives first it
|
||||||
|
// rebuilds anyway — by then `composing` is false. Both orders land.)
|
||||||
|
compositionend: (view) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (view.isDestroyed) return
|
||||||
|
setComposing(view, false)
|
||||||
|
options.onEnd?.()
|
||||||
|
}, 0)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
// Clicking away mid-candidate abandons the composition without a
|
||||||
|
// compositionend in some browsers. Without this the layers would stay
|
||||||
|
// held — silently, and until she typed again.
|
||||||
|
blur: (view) => {
|
||||||
|
setComposing(view, false)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Composition = Extension.create<CompositionOptions>({
|
||||||
|
name: 'composition',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return { onEnd: null }
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [compositionPlugin(this.options)]
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -29,6 +29,7 @@ import { SelectionBubble } from './SelectionBubble'
|
|||||||
import { SearchHighlight } from './SearchHighlight'
|
import { SearchHighlight } from './SearchHighlight'
|
||||||
import { FindReplace } from './FindReplace'
|
import { FindReplace } from './FindReplace'
|
||||||
import { Typography } from './Typography'
|
import { Typography } from './Typography'
|
||||||
|
import { Composition } from './Composition'
|
||||||
import { RewritePreview, type RewriteStatus } from './RewritePreview'
|
import { RewritePreview, type RewriteStatus } from './RewritePreview'
|
||||||
import { planBatch } from './acceptBatch'
|
import { planBatch } from './acceptBatch'
|
||||||
import { api, type Suggestion, type SuggestionType, type WordInfo } from '../../api/client'
|
import { api, type Suggestion, type SuggestionType, type WordInfo } from '../../api/client'
|
||||||
@@ -46,6 +47,17 @@ export interface EditorChange {
|
|||||||
content: string // Tiptap JSON, stringified
|
content: string // Tiptap JSON, stringified
|
||||||
content_text: string // flattened plain text for the LLM
|
content_text: string // flattened plain text for the LLM
|
||||||
word_count: number
|
word_count: number
|
||||||
|
// True while an IME composition is in flight: this text contains the pinyin
|
||||||
|
// she is part-way through converting, not the sentence she is writing.
|
||||||
|
//
|
||||||
|
// The save is deliberately NOT gated on it — a tablet keyboard can hold one
|
||||||
|
// composition open for a whole sentence, and Petal never makes writing wait
|
||||||
|
// for anything. Saving an intermediate state costs nothing: the next change
|
||||||
|
// supersedes it, and one always arrives (this component emits a final change
|
||||||
|
// once the composition commits). What it gates is *analysis* — asking the
|
||||||
|
// rule pack or the model to read half-typed pinyin can only produce advice
|
||||||
|
// about text that is about to stop existing.
|
||||||
|
composing: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -330,6 +342,13 @@ export function EditorCore({
|
|||||||
// once at construction) can trigger a re-measure without stale closures.
|
// once at construction) can trigger a re-measure without stale closures.
|
||||||
const recomputeRailRef = useRef<() => void>(() => {})
|
const recomputeRailRef = useRef<() => void>(() => {})
|
||||||
|
|
||||||
|
// Re-report the document once an IME composition commits. Everything typed
|
||||||
|
// while composing was reported with `composing: true`, so the analysis passes
|
||||||
|
// ignored it; without this nudge the committed sentence would wait for the
|
||||||
|
// next keystroke to be looked at. Held in a ref because the extension list is
|
||||||
|
// built once, at construction.
|
||||||
|
const emitCommittedRef = useRef<() => void>(() => {})
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit,
|
||||||
@@ -347,6 +366,11 @@ export function EditorCore({
|
|||||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||||
Placeholder.configure({ placeholder: 'Start writing…' }),
|
Placeholder.configure({ placeholder: 'Start writing…' }),
|
||||||
CharacterCount,
|
CharacterCount,
|
||||||
|
// First in the list so its state is settled before the layers that read
|
||||||
|
// it — not that they depend on the ordering (they read the state as of
|
||||||
|
// the transaction before), but the one that answers the question should
|
||||||
|
// come before the ones that ask it.
|
||||||
|
Composition.configure({ onEnd: () => emitCommittedRef.current() }),
|
||||||
SuggestionHighlight,
|
SuggestionHighlight,
|
||||||
SpellCheck,
|
SpellCheck,
|
||||||
SearchHighlight,
|
SearchHighlight,
|
||||||
@@ -391,6 +415,7 @@ export function EditorCore({
|
|||||||
content: JSON.stringify(editor.getJSON()),
|
content: JSON.stringify(editor.getJSON()),
|
||||||
content_text: editor.getText(),
|
content_text: editor.getText(),
|
||||||
word_count: editor.storage.characterCount.words(),
|
word_count: editor.storage.characterCount.words(),
|
||||||
|
composing: editor.view.composing,
|
||||||
})
|
})
|
||||||
// Edits reflow the text, so the rail anchors need re-measuring.
|
// Edits reflow the text, so the rail anchors need re-measuring.
|
||||||
recomputeRailRef.current()
|
recomputeRailRef.current()
|
||||||
@@ -417,6 +442,20 @@ export function EditorCore({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The composition-end nudge. Same payload as onUpdate's, with `composing`
|
||||||
|
// false by construction — this runs after the composition has ended and its
|
||||||
|
// final changes have been flushed, so the text here is the committed one.
|
||||||
|
emitCommittedRef.current = () => {
|
||||||
|
if (!editor) return
|
||||||
|
onChange({
|
||||||
|
content: JSON.stringify(editor.getJSON()),
|
||||||
|
content_text: editor.getText(),
|
||||||
|
word_count: editor.storage.characterCount.words(),
|
||||||
|
composing: false,
|
||||||
|
})
|
||||||
|
recomputeRailRef.current()
|
||||||
|
}
|
||||||
|
|
||||||
// When the selected document changes, swap in its content without emitting an
|
// When the selected document changes, swap in its content without emitting an
|
||||||
// update (false) so loading a doc doesn't trigger a spurious save.
|
// update (false) so loading a doc doesn't trigger a spurious save.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import type { Editor } from '@tiptap/react'
|
import type { Editor } from '@tiptap/react'
|
||||||
import { clearSearch, getSearchState, setActive, setSearch } from './SearchHighlight'
|
import { clearSearch, getSearchState, setActive, setSearch } from './SearchHighlight'
|
||||||
import { usePack } from '../../i18n'
|
import { usePack } from '../../i18n'
|
||||||
|
import { fromIME } from '../../lib/ime'
|
||||||
|
|
||||||
// FindReplace is the in-document search bar (Ctrl/Cmd+F). It drives the
|
// FindReplace is the in-document search bar (Ctrl/Cmd+F). It drives the
|
||||||
// SearchHighlight decoration layer: typing updates the highlighted matches, the
|
// SearchHighlight decoration layer: typing updates the highlighted matches, the
|
||||||
@@ -108,7 +109,10 @@ export function FindReplace({ editor, onClose }: Props) {
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="Find and replace"
|
aria-label="Find and replace"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') {
|
// Both fields take Chinese, so both take an IME: Escape cancels a
|
||||||
|
// candidate and Enter commits one. A key that belongs to the composition
|
||||||
|
// is not a command here — see lib/ime.
|
||||||
|
if (e.key === 'Escape' && !fromIME(e)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -137,7 +141,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter' && !fromIME(e)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
go(e.shiftKey ? -1 : 1)
|
go(e.shiftKey ? -1 : 1)
|
||||||
}
|
}
|
||||||
@@ -171,7 +175,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
|||||||
value={replacement}
|
value={replacement}
|
||||||
onChange={(e) => setReplacement(e.target.value)}
|
onChange={(e) => setReplacement(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter' && !fromIME(e)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
replaceActive()
|
replaceActive()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { EditorState, Transaction } from '@tiptap/pm/state'
|
|||||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||||
import { mapOffset } from './SuggestionHighlight'
|
import { mapOffset } from './SuggestionHighlight'
|
||||||
|
import { holdRedraw } from './Composition'
|
||||||
|
|
||||||
// SearchHighlight powers the in-document Find & Replace bar. Like the suggestion
|
// SearchHighlight powers the in-document Find & Replace bar. Like the suggestion
|
||||||
// layer it uses ProseMirror *decorations* (not stored marks), so matches are
|
// layer it uses ProseMirror *decorations* (not stored marks), so matches are
|
||||||
@@ -22,6 +23,11 @@ interface PluginState {
|
|||||||
matches: Match[]
|
matches: Match[]
|
||||||
active: number // index into matches, or -1 when there are none
|
active: number // index into matches, or -1 when there are none
|
||||||
decorations: DecorationSet
|
decorations: DecorationSet
|
||||||
|
// Held back while an IME composition was in flight — see Composition.ts.
|
||||||
|
// `matches` is held with the decorations rather than recomputed on its own:
|
||||||
|
// the Find bar's "3 / 7" and the wash on the page are one answer, and half of
|
||||||
|
// it moving while the other half waits would be worse than both waiting.
|
||||||
|
stale: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const searchPluginKey = new PluginKey<PluginState>('petalSearch')
|
export const searchPluginKey = new PluginKey<PluginState>('petalSearch')
|
||||||
@@ -57,7 +63,7 @@ function build(doc: PMNode, query: string, caseSensitive: boolean, preferred: nu
|
|||||||
class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match',
|
class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match',
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
return { query, caseSensitive, matches, active, decorations: DecorationSet.create(doc, decos) }
|
return { query, caseSensitive, matches, active, decorations: DecorationSet.create(doc, decos), stale: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: PluginState = {
|
const EMPTY: PluginState = {
|
||||||
@@ -66,6 +72,7 @@ const EMPTY: PluginState = {
|
|||||||
matches: [],
|
matches: [],
|
||||||
active: -1,
|
active: -1,
|
||||||
decorations: DecorationSet.empty,
|
decorations: DecorationSet.empty,
|
||||||
|
stale: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// setSearch updates the query / case-sensitivity and recomputes matches. Passing
|
// setSearch updates the query / case-sensitivity and recomputes matches. Passing
|
||||||
@@ -100,44 +107,63 @@ type Meta =
|
|||||||
| { kind: 'active'; index: number }
|
| { kind: 'active'; index: number }
|
||||||
| { kind: 'clear' }
|
| { kind: 'clear' }
|
||||||
|
|
||||||
|
export function searchPlugin(): Plugin<PluginState> {
|
||||||
|
return new Plugin<PluginState>({
|
||||||
|
key: searchPluginKey,
|
||||||
|
state: {
|
||||||
|
init: () => EMPTY,
|
||||||
|
apply(tr, value, oldState, newState): PluginState {
|
||||||
|
const meta = tr.getMeta(searchPluginKey) as Meta | undefined
|
||||||
|
// Clearing the layer is the one thing a composition never holds: it
|
||||||
|
// removes decorations rather than adding them, and it is what closing
|
||||||
|
// the Find bar does.
|
||||||
|
if (meta?.kind === 'clear') return EMPTY
|
||||||
|
|
||||||
|
const held = holdRedraw(tr, oldState)
|
||||||
|
const query = meta?.kind === 'search' ? meta.query : value.query
|
||||||
|
const caseSensitive = meta?.kind === 'search' ? meta.caseSensitive : value.caseSensitive
|
||||||
|
|
||||||
|
if (meta?.kind === 'active') {
|
||||||
|
if (value.matches.length === 0) return value
|
||||||
|
const active = ((meta.index % value.matches.length) + value.matches.length) % value.matches.length
|
||||||
|
if (held) return { ...value, active, stale: true }
|
||||||
|
const decos = value.matches.map((m, i) =>
|
||||||
|
Decoration.inline(m.from, m.to, {
|
||||||
|
class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { ...value, active, stale: false, decorations: DecorationSet.create(newState.doc, decos) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new query, or any document change: re-anchor so highlights track
|
||||||
|
// edits and replaces. Once due, it stays due until it happens.
|
||||||
|
const due = value.stale || meta?.kind === 'search' || (tr.docChanged && !!value.query)
|
||||||
|
if (!due) return value
|
||||||
|
if (held) {
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
query,
|
||||||
|
caseSensitive,
|
||||||
|
stale: true,
|
||||||
|
decorations: tr.docChanged ? value.decorations.map(tr.mapping, tr.doc) : value.decorations,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const preferred = meta?.kind === 'search' && value.active < 0 ? 0 : value.active
|
||||||
|
return build(newState.doc, query, caseSensitive, preferred)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return searchPluginKey.getState(state)?.decorations
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const SearchHighlight = Extension.create({
|
export const SearchHighlight = Extension.create({
|
||||||
name: 'searchHighlight',
|
name: 'searchHighlight',
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
return [
|
return [searchPlugin()]
|
||||||
new Plugin<PluginState>({
|
|
||||||
key: searchPluginKey,
|
|
||||||
state: {
|
|
||||||
init: () => EMPTY,
|
|
||||||
apply(tr, value, _oldState, newState): PluginState {
|
|
||||||
const meta = tr.getMeta(searchPluginKey) as Meta | undefined
|
|
||||||
if (meta?.kind === 'search') {
|
|
||||||
return build(newState.doc, meta.query, meta.caseSensitive, value.active < 0 ? 0 : value.active)
|
|
||||||
}
|
|
||||||
if (meta?.kind === 'active') {
|
|
||||||
if (value.matches.length === 0) return value
|
|
||||||
const active = ((meta.index % value.matches.length) + value.matches.length) % value.matches.length
|
|
||||||
const decos = value.matches.map((m, i) =>
|
|
||||||
Decoration.inline(m.from, m.to, {
|
|
||||||
class: i === active ? 'petal-find-match petal-find-match-active' : 'petal-find-match',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return { ...value, active, decorations: DecorationSet.create(newState.doc, decos) }
|
|
||||||
}
|
|
||||||
if (meta?.kind === 'clear') return EMPTY
|
|
||||||
// Re-anchor on any document change so highlights track edits/replaces.
|
|
||||||
if (tr.docChanged && value.query) {
|
|
||||||
return build(newState.doc, value.query, value.caseSensitive, value.active)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decorations(state) {
|
|
||||||
return searchPluginKey.getState(state)?.decorations
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { EditorState, Transaction } from '@tiptap/pm/state'
|
|||||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||||
import { mapOffset } from './SuggestionHighlight'
|
import { mapOffset } from './SuggestionHighlight'
|
||||||
|
import { holdRedraw } from './Composition'
|
||||||
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||||
|
|
||||||
// SpellCheck renders browser-side nspell misspellings as ProseMirror
|
// SpellCheck renders browser-side nspell misspellings as ProseMirror
|
||||||
@@ -18,6 +19,11 @@ export const spellPluginKey = new PluginKey<PluginState>('petalSpellCheck')
|
|||||||
interface PluginState {
|
interface PluginState {
|
||||||
checker: SpellChecker | null
|
checker: SpellChecker | null
|
||||||
decorations: DecorationSet
|
decorations: DecorationSet
|
||||||
|
// Held back while an IME composition was in flight — see Composition.ts. This
|
||||||
|
// layer is the one with the most to gain from the guard: the pinyin she is
|
||||||
|
// part-way through typing is Latin letters, so it is exactly what the
|
||||||
|
// tokenizer picks up and exactly what an underline would redraw over.
|
||||||
|
stale: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// A word is a run of Latin letters with optional internal/edge apostrophes
|
// A word is a run of Latin letters with optional internal/edge apostrophes
|
||||||
@@ -151,33 +157,45 @@ export function setSpellChecker(
|
|||||||
dispatch(state.tr.setMeta(spellPluginKey, checker ?? null))
|
dispatch(state.tr.setMeta(spellPluginKey, checker ?? null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function spellPlugin(): Plugin<PluginState> {
|
||||||
|
return new Plugin<PluginState>({
|
||||||
|
key: spellPluginKey,
|
||||||
|
state: {
|
||||||
|
init: () => ({ checker: null, decorations: DecorationSet.empty, stale: false }),
|
||||||
|
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, stale: false }
|
||||||
|
// 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).
|
||||||
|
const due = value.stale || meta !== undefined || tr.docChanged || tr.selectionSet
|
||||||
|
if (!due) return value
|
||||||
|
if (holdRedraw(tr, oldState)) {
|
||||||
|
return {
|
||||||
|
checker,
|
||||||
|
stale: true,
|
||||||
|
decorations: tr.docChanged ? value.decorations.map(tr.mapping, tr.doc) : value.decorations,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
checker,
|
||||||
|
stale: false,
|
||||||
|
decorations: buildDecorations(newState.doc, checker, newState.selection.head),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return spellPluginKey.getState(state)?.decorations
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const SpellCheck = Extension.create({
|
export const SpellCheck = Extension.create({
|
||||||
name: 'spellCheck',
|
name: 'spellCheck',
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
return [
|
return [spellPlugin()]
|
||||||
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
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { EditorState, Transaction } from '@tiptap/pm/state'
|
|||||||
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
||||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||||
import type { Suggestion } from '../../api/client'
|
import type { Suggestion } from '../../api/client'
|
||||||
|
import { holdRedraw } from './Composition'
|
||||||
|
|
||||||
// SuggestionHighlight renders LLM suggestions as ProseMirror *decorations*, not
|
// SuggestionHighlight renders LLM suggestions as ProseMirror *decorations*, not
|
||||||
// stored marks. Decorations are ephemeral overlays recomputed from the live
|
// stored marks. Decorations are ephemeral overlays recomputed from the live
|
||||||
@@ -21,6 +22,10 @@ interface PluginState {
|
|||||||
// decoration repaints that fire on every document change.
|
// decoration repaints that fire on every document change.
|
||||||
activeId: string | null
|
activeId: string | null
|
||||||
decorations: DecorationSet
|
decorations: DecorationSet
|
||||||
|
// A rebuild fell due while an IME composition was in flight and was held back
|
||||||
|
// (see Composition.ts). The decorations on screen are the previous ones,
|
||||||
|
// mapped forward; this says they still owe a rebuild.
|
||||||
|
stale: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meta carried on a transaction to update the plugin: either a fresh suggestion
|
// Meta carried on a transaction to update the plugin: either a fresh suggestion
|
||||||
@@ -173,48 +178,51 @@ export function setActiveSuggestion(
|
|||||||
dispatch(state.tr.setMeta(suggestionPluginKey, { activeId } satisfies SuggestionMeta))
|
dispatch(state.tr.setMeta(suggestionPluginKey, { activeId } satisfies SuggestionMeta))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function suggestionPlugin(): Plugin<PluginState> {
|
||||||
|
return new Plugin<PluginState>({
|
||||||
|
key: suggestionPluginKey,
|
||||||
|
state: {
|
||||||
|
init: () => ({ suggestions: [], activeId: null, decorations: DecorationSet.empty, stale: false }),
|
||||||
|
apply(tr, value, oldState, newState) {
|
||||||
|
const meta = tr.getMeta(suggestionPluginKey) as SuggestionMeta | undefined
|
||||||
|
const suggestions = meta && 'suggestions' in meta ? meta.suggestions : value.suggestions
|
||||||
|
const activeId = meta && 'activeId' in meta ? meta.activeId : value.activeId
|
||||||
|
// A rebuild is due on a new list, a new emphasis, or any document change
|
||||||
|
// (which is how a suggestion re-anchors by string), and stays due until
|
||||||
|
// it happens.
|
||||||
|
const due = value.stale || meta !== undefined || tr.docChanged
|
||||||
|
if (!due) return value
|
||||||
|
if (holdRedraw(tr, oldState)) {
|
||||||
|
return {
|
||||||
|
suggestions,
|
||||||
|
activeId,
|
||||||
|
stale: true,
|
||||||
|
// Map rather than keep: the composing text is growing under these
|
||||||
|
// highlights, and an unmapped decoration would drift a character at
|
||||||
|
// a time across a word she is still typing.
|
||||||
|
decorations: tr.docChanged ? value.decorations.map(tr.mapping, tr.doc) : value.decorations,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
suggestions,
|
||||||
|
activeId,
|
||||||
|
stale: false,
|
||||||
|
decorations: buildDecorations(newState.doc, suggestions, activeId),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return suggestionPluginKey.getState(state)?.decorations
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const SuggestionHighlight = Extension.create({
|
export const SuggestionHighlight = Extension.create({
|
||||||
name: 'suggestionHighlight',
|
name: 'suggestionHighlight',
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
return [
|
return [suggestionPlugin()]
|
||||||
new Plugin<PluginState>({
|
|
||||||
key: suggestionPluginKey,
|
|
||||||
state: {
|
|
||||||
init: () => ({ suggestions: [], activeId: null, decorations: DecorationSet.empty }),
|
|
||||||
apply(tr, value, _oldState, newState) {
|
|
||||||
const meta = tr.getMeta(suggestionPluginKey) as SuggestionMeta | undefined
|
|
||||||
if (meta && 'suggestions' in meta) {
|
|
||||||
return {
|
|
||||||
suggestions: meta.suggestions,
|
|
||||||
activeId: value.activeId,
|
|
||||||
decorations: buildDecorations(newState.doc, meta.suggestions, value.activeId),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (meta && 'activeId' in meta) {
|
|
||||||
return {
|
|
||||||
suggestions: value.suggestions,
|
|
||||||
activeId: meta.activeId,
|
|
||||||
decorations: buildDecorations(newState.doc, value.suggestions, meta.activeId),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// On any document change, re-anchor by string against the new doc.
|
|
||||||
if (tr.docChanged) {
|
|
||||||
return {
|
|
||||||
suggestions: value.suggestions,
|
|
||||||
activeId: value.activeId,
|
|
||||||
decorations: buildDecorations(newState.doc, value.suggestions, value.activeId),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decorations(state) {
|
|
||||||
return suggestionPluginKey.getState(state)?.decorations
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// fromIME answers whether a keydown belongs to an in-flight IME composition
|
||||||
|
// rather than to the app.
|
||||||
|
//
|
||||||
|
// While a candidate window is open, Enter and Escape mean something to the IME
|
||||||
|
// and nothing to Petal: Enter commits the candidate, Escape cancels it back to
|
||||||
|
// the pinyin. A handler that acts on them anyway steals the key — she presses
|
||||||
|
// Escape to fix a wrong candidate and the sidebar reappears; she presses Enter
|
||||||
|
// to accept 公园 and the Find bar jumps to the next match instead. In neither
|
||||||
|
// case does the IME get its keystroke.
|
||||||
|
//
|
||||||
|
// `isComposing` is the standard signal and is what modern browsers set. The 229
|
||||||
|
// keyCode is the older one, still the only signal some Safari/IME combinations
|
||||||
|
// give, and costs one comparison to honour.
|
||||||
|
// React's synthetic keyboard event doesn't surface `isComposing`, so the native
|
||||||
|
// event underneath it is what gets asked — the same object either way.
|
||||||
|
export function fromIME(e: KeyboardEvent | { nativeEvent: KeyboardEvent }): boolean {
|
||||||
|
const native = 'nativeEvent' in e ? e.nativeEvent : e
|
||||||
|
return native.isComposing || native.keyCode === 229
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user