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:
prosolis
2026-07-28 19:20:11 -07:00
parent 77f284f65c
commit c348a9b8ae
12 changed files with 617 additions and 112 deletions
+62 -36
View File
@@ -4,6 +4,7 @@ import type { EditorState, Transaction } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import type { Node as PMNode } from '@tiptap/pm/model'
import { mapOffset } from './SuggestionHighlight'
import { holdRedraw } from './Composition'
// SearchHighlight powers the in-document Find & Replace bar. Like the suggestion
// layer it uses ProseMirror *decorations* (not stored marks), so matches are
@@ -22,6 +23,11 @@ interface PluginState {
matches: Match[]
active: number // index into matches, or -1 when there are none
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')
@@ -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',
}),
)
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 = {
@@ -66,6 +72,7 @@ const EMPTY: PluginState = {
matches: [],
active: -1,
decorations: DecorationSet.empty,
stale: false,
}
// setSearch updates the query / case-sensitivity and recomputes matches. Passing
@@ -100,44 +107,63 @@ type Meta =
| { kind: 'active'; index: number }
| { 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({
name: 'searchHighlight',
addProseMirrorPlugins() {
return [
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
},
},
}),
]
return [searchPlugin()]
},
})