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('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 { return new Plugin({ 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({ name: 'composition', addOptions() { return { onEnd: null } }, addProseMirrorPlugins() { return [compositionPlugin(this.options)] }, })