Surface every outstanding suggestion as a card in the right-hand
whitespace, vertically aligned to the text it flags — so the writer sees
the whole queue at once instead of hovering each highlight. Cards stack
with collision avoidance, link both ways with their highlight (hover/click
↔ soft text wash, driven through the decoration plugin so it survives
edit repaints), and carry the same Accept / Dismiss / Ask Petal actions.
The rail is a progressive enhancement: it mounts only when there's room
beside the editor, otherwise the existing inline hover card is unchanged.
Stacked cards that reach the bottom-right corner tuck behind the
companion mascot (z-order).
When a card is expanded, the Ask Petal bubble now opens with the
Simplified-Chinese translation of the explanation (the English stays in
the card body) instead of repeating the same text twice — a new
POST /api/suggestions/{id}/translate one-shot LLM endpoint, loaded
lazily on open with an English fallback.
Verified live against the local LLM via the uitest harness: rail
stacking, hover↔text wash, expand/Ask Petal, accept-from-rail, narrow
fallback, and the Mandarin bubble.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
221 lines
8.2 KiB
TypeScript
221 lines
8.2 KiB
TypeScript
import { Extension } from '@tiptap/core'
|
||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||
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'
|
||
|
||
// SuggestionHighlight renders LLM suggestions as ProseMirror *decorations*, not
|
||
// stored marks. Decorations are ephemeral overlays recomputed from the live
|
||
// document on every change, which is exactly the string-anchoring the spec
|
||
// requires (Note #6): each suggestion is re-located by matching its `original`
|
||
// text in the current doc, so edits made while a checkpoint is in flight never
|
||
// leave a highlight stranded on stale coordinates.
|
||
|
||
export const suggestionPluginKey = new PluginKey<PluginState>('petalSuggestions')
|
||
|
||
interface PluginState {
|
||
suggestions: Suggestion[]
|
||
// The suggestion currently emphasized from its margin card / a hover, or null.
|
||
// Carried in plugin state (not an imperative DOM class) so it survives the
|
||
// decoration repaints that fire on every document change.
|
||
activeId: string | null
|
||
decorations: DecorationSet
|
||
}
|
||
|
||
// Meta carried on a transaction to update the plugin: either a fresh suggestion
|
||
// list or a change to which suggestion is emphasized.
|
||
type SuggestionMeta = { suggestions: Suggestion[] } | { activeId: string | null }
|
||
|
||
// mapOffset converts a character offset within a textblock's flattened text into
|
||
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
|
||
// breaks) that occupy a position but contribute no text.
|
||
export function mapOffset(block: PMNode, blockPos: number, targetOffset: number): number {
|
||
let textOffset = 0
|
||
let pmPos = blockPos + 1 // inline content starts just inside the block
|
||
let result = pmPos
|
||
let done = false
|
||
block.forEach((child) => {
|
||
if (done) return
|
||
const len = child.isText ? (child.text?.length ?? 0) : 0
|
||
if (textOffset + len >= targetOffset) {
|
||
result = pmPos + (targetOffset - textOffset)
|
||
done = true
|
||
} else {
|
||
textOffset += len
|
||
pmPos += child.nodeSize
|
||
}
|
||
})
|
||
if (!done) result = pmPos
|
||
return result
|
||
}
|
||
|
||
// foldTypography canonicalizes the typographic variants that the editor's
|
||
// Typography input rules introduce (curly quotes, em/en dashes, single-glyph
|
||
// ellipsis) and the unicode spaces a paste can carry. The model echoes a
|
||
// suggestion's `original` from the same text but often normalizes these back to
|
||
// plain ASCII — straight quotes, `--`, `...` — so an exact match would miss and
|
||
// the suggestion could be neither highlighted nor applied.
|
||
//
|
||
// It returns the folded string plus `map`, where map[k] is the source index at
|
||
// which folded character k begins. A multi-character source run (`...`, `--`)
|
||
// folds to one glyph, so lengths differ; map projects a match in folded space
|
||
// back onto real document offsets. map has a trailing sentinel (= source length)
|
||
// so map[start + folded.length] yields the offset just past the match.
|
||
export function foldTypography(text: string): { folded: string; map: number[] } {
|
||
let folded = ''
|
||
const map: number[] = []
|
||
let i = 0
|
||
while (i < text.length) {
|
||
// Multi-character ASCII sequences the Typography rules would have replaced.
|
||
if (text.startsWith('...', i)) {
|
||
folded += '…'
|
||
map.push(i)
|
||
i += 3
|
||
continue
|
||
}
|
||
if (text[i] === '-' && text[i + 1] === '-') {
|
||
folded += '—'
|
||
map.push(i)
|
||
i += 2
|
||
continue
|
||
}
|
||
folded += foldChar(text[i])
|
||
map.push(i)
|
||
i++
|
||
}
|
||
map.push(text.length)
|
||
return { folded, map }
|
||
}
|
||
|
||
// foldChar maps a single character onto its canonical form (length-preserving).
|
||
function foldChar(c: string): string {
|
||
switch (c) {
|
||
case '‘':
|
||
case '’':
|
||
case '‚':
|
||
case '‛':
|
||
return "'"
|
||
case '“':
|
||
case '”':
|
||
case '„':
|
||
case '‟':
|
||
return '"'
|
||
case '–': // en dash → em dash, the canonical form `--` also folds to
|
||
return '—'
|
||
case ' ': // non-breaking space
|
||
case ' ': // thin space
|
||
case ' ': // narrow no-break space
|
||
return ' '
|
||
default:
|
||
return c
|
||
}
|
||
}
|
||
|
||
// findRange locates the first occurrence of `search` within a single textblock
|
||
// and returns its ProseMirror range, or null if the string isn't present (the
|
||
// user may have edited or removed it since the checkpoint ran). Matching is done
|
||
// in canonical typographic space (see foldTypography) so curly-quote/dash/ellipsis
|
||
// differences between the model's echo and the live document don't strand the
|
||
// anchor. Exported so the accept flow can resolve the same span to replace.
|
||
export function findRange(doc: PMNode, search: string): { from: number; to: number } | null {
|
||
const needle = foldTypography(search).folded
|
||
if (!needle) return null
|
||
let result: { from: number; to: number } | null = null
|
||
doc.descendants((node, pos) => {
|
||
if (result) return false
|
||
if (!node.isTextblock) return true // keep descending to the textblock
|
||
const { folded, map } = foldTypography(node.textContent)
|
||
const idx = folded.indexOf(needle)
|
||
if (idx >= 0) {
|
||
result = {
|
||
from: mapOffset(node, pos, map[idx]),
|
||
to: mapOffset(node, pos, map[idx + needle.length]),
|
||
}
|
||
}
|
||
return false // never descend into a textblock's inline children
|
||
})
|
||
return result
|
||
}
|
||
|
||
function buildDecorations(doc: PMNode, suggestions: Suggestion[], activeId: string | null): DecorationSet {
|
||
const decos: Decoration[] = []
|
||
for (const s of suggestions) {
|
||
const range = findRange(doc, s.original)
|
||
if (!range) continue
|
||
const active = s.id === activeId ? ' petal-suggestion-active' : ''
|
||
decos.push(
|
||
Decoration.inline(range.from, range.to, {
|
||
class: `petal-suggestion petal-suggestion-${s.type}${active}`,
|
||
'data-suggestion-id': s.id,
|
||
}),
|
||
)
|
||
}
|
||
return DecorationSet.create(doc, decos)
|
||
}
|
||
|
||
// setSuggestions pushes a new suggestion list into the plugin. Decorations are
|
||
// rebuilt against the current document immediately.
|
||
export function setSuggestions(state: EditorState, dispatch: (tr: Transaction) => void, suggestions: Suggestion[]) {
|
||
const tr = state.tr.setMeta(suggestionPluginKey, { suggestions } satisfies SuggestionMeta)
|
||
dispatch(tr)
|
||
}
|
||
|
||
// setActiveSuggestion marks one suggestion (or none) as emphasized, rebuilding
|
||
// the decorations so the flagged text gets the active wash. Driven by the margin
|
||
// rail's hover/selection so the card↔text link reads both ways.
|
||
export function setActiveSuggestion(
|
||
state: EditorState,
|
||
dispatch: (tr: Transaction) => void,
|
||
activeId: string | null,
|
||
) {
|
||
if (suggestionPluginKey.getState(state)?.activeId === activeId) return
|
||
dispatch(state.tr.setMeta(suggestionPluginKey, { activeId } satisfies SuggestionMeta))
|
||
}
|
||
|
||
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
|
||
},
|
||
},
|
||
}),
|
||
]
|
||
},
|
||
})
|