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:
+21
-6
@@ -23,6 +23,7 @@ import { PetalFall } from './effects/PetalFall'
|
||||
import { usePack } from './i18n'
|
||||
import { useNightMode } from './hooks/useNightMode'
|
||||
import { playSuggestionSound } from './audio/sounds'
|
||||
import { fromIME } from './lib/ime'
|
||||
|
||||
export default function App() {
|
||||
const updateAvailable = useVersionWatch()
|
||||
@@ -331,14 +332,25 @@ export default function App() {
|
||||
|
||||
const handleEditorChange = useCallback(
|
||||
(change: EditorChange) => {
|
||||
const { composing, ...patch } = change
|
||||
setWordCount(change.word_count)
|
||||
setDocText(change.content_text)
|
||||
setEditTick((n) => n + 1)
|
||||
if (currentDoc) {
|
||||
patchSummary(currentDoc.id, { word_count: change.word_count })
|
||||
schedule(change)
|
||||
scheduleCheckpoint(change.content_text)
|
||||
// The save is never held: see EditorChange.composing. The flag itself
|
||||
// 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],
|
||||
)
|
||||
@@ -414,11 +426,14 @@ export default function App() {
|
||||
[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(() => {
|
||||
if (!focusMode) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setFocusMode(false)
|
||||
if (e.key === 'Escape' && !fromIME(e)) setFocusMode(false)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { tagColorVar, type Tag, type TagColor } from '../../api/client'
|
||||
import { usePack } from '../../i18n'
|
||||
import { fromIME } from '../../lib/ime'
|
||||
|
||||
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()
|
||||
}
|
||||
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.
|
||||
const id = setTimeout(() => document.addEventListener('pointerdown', onDown), 0)
|
||||
@@ -114,7 +117,7 @@ export function TagPicker({ roster, assignedIds, onToggle, onCreate, onClose }:
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submit()
|
||||
if (e.key === 'Enter' && !fromIME(e)) submit()
|
||||
}}
|
||||
placeholder={t.docs.newTagPlaceholder}
|
||||
aria-label="New tag name"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { api, streamSuggestionChat, type ChatMessage } from '../../api/client'
|
||||
import { usePack } from '../../i18n'
|
||||
import { splitBilingual } from './bilingualReply'
|
||||
import { fromIME } from '../../lib/ime'
|
||||
|
||||
interface Props {
|
||||
suggestionId: string
|
||||
@@ -177,6 +178,13 @@ export function AskPetal({ suggestionId, explanation }: Props) {
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
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}
|
||||
className="min-w-0 flex-1 rounded-full px-3 py-1.5 text-xs focus:outline-none"
|
||||
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 { FindReplace } from './FindReplace'
|
||||
import { Typography } from './Typography'
|
||||
import { Composition } from './Composition'
|
||||
import { RewritePreview, type RewriteStatus } from './RewritePreview'
|
||||
import { planBatch } from './acceptBatch'
|
||||
import { api, type Suggestion, type SuggestionType, type WordInfo } from '../../api/client'
|
||||
@@ -46,6 +47,17 @@ export interface EditorChange {
|
||||
content: string // Tiptap JSON, stringified
|
||||
content_text: string // flattened plain text for the LLM
|
||||
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 {
|
||||
@@ -330,6 +342,13 @@ export function EditorCore({
|
||||
// once at construction) can trigger a re-measure without stale closures.
|
||||
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({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
@@ -347,6 +366,11 @@ export function EditorCore({
|
||||
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
||||
Placeholder.configure({ placeholder: 'Start writing…' }),
|
||||
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,
|
||||
SpellCheck,
|
||||
SearchHighlight,
|
||||
@@ -391,6 +415,7 @@ export function EditorCore({
|
||||
content: JSON.stringify(editor.getJSON()),
|
||||
content_text: editor.getText(),
|
||||
word_count: editor.storage.characterCount.words(),
|
||||
composing: editor.view.composing,
|
||||
})
|
||||
// Edits reflow the text, so the rail anchors need re-measuring.
|
||||
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
|
||||
// update (false) so loading a doc doesn't trigger a spurious save.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { clearSearch, getSearchState, setActive, setSearch } from './SearchHighlight'
|
||||
import { usePack } from '../../i18n'
|
||||
import { fromIME } from '../../lib/ime'
|
||||
|
||||
// FindReplace is the in-document search bar (Ctrl/Cmd+F). It drives the
|
||||
// SearchHighlight decoration layer: typing updates the highlighted matches, the
|
||||
@@ -108,7 +109,10 @@ export function FindReplace({ editor, onClose }: Props) {
|
||||
role="dialog"
|
||||
aria-label="Find and replace"
|
||||
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()
|
||||
onClose()
|
||||
}
|
||||
@@ -137,7 +141,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === 'Enter' && !fromIME(e)) {
|
||||
e.preventDefault()
|
||||
go(e.shiftKey ? -1 : 1)
|
||||
}
|
||||
@@ -171,7 +175,7 @@ export function FindReplace({ editor, onClose }: Props) {
|
||||
value={replacement}
|
||||
onChange={(e) => setReplacement(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === 'Enter' && !fromIME(e)) {
|
||||
e.preventDefault()
|
||||
replaceActive()
|
||||
}
|
||||
|
||||
@@ -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()]
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||
|
||||
// SpellCheck renders browser-side nspell misspellings as ProseMirror
|
||||
@@ -18,6 +19,11 @@ export const spellPluginKey = new PluginKey<PluginState>('petalSpellCheck')
|
||||
interface PluginState {
|
||||
checker: SpellChecker | null
|
||||
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
|
||||
@@ -151,33 +157,45 @@ export function setSpellChecker(
|
||||
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({
|
||||
name: 'spellCheck',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin<PluginState>({
|
||||
key: spellPluginKey,
|
||||
state: {
|
||||
init: () => ({ checker: null, decorations: DecorationSet.empty }),
|
||||
apply(tr, value, _oldState, newState) {
|
||||
const meta = tr.getMeta(spellPluginKey) as SpellChecker | null | undefined
|
||||
const checker = meta !== undefined ? meta : value.checker
|
||||
if (!checker) return { checker: null, decorations: DecorationSet.empty }
|
||||
// Rebuild on a checker swap, a doc edit, or a caret move (so the word
|
||||
// you just left gets re-evaluated and the new caret word is exempt).
|
||||
if (meta !== undefined || tr.docChanged || tr.selectionSet) {
|
||||
return { checker, decorations: buildDecorations(newState.doc, checker, newState.selection.head) }
|
||||
}
|
||||
return { checker, decorations: value.decorations }
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return spellPluginKey.getState(state)?.decorations
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
return [spellPlugin()]
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 type { Suggestion } from '../../api/client'
|
||||
import { holdRedraw } from './Composition'
|
||||
|
||||
// SuggestionHighlight renders LLM suggestions as ProseMirror *decorations*, not
|
||||
// stored marks. Decorations are ephemeral overlays recomputed from the live
|
||||
@@ -21,6 +22,10 @@ interface PluginState {
|
||||
// decoration repaints that fire on every document change.
|
||||
activeId: string | null
|
||||
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
|
||||
@@ -173,48 +178,51 @@ export function setActiveSuggestion(
|
||||
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({
|
||||
name: 'suggestionHighlight',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
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
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
return [suggestionPlugin()]
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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