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:
@@ -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)]
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user