Files
petal/web/src/components/Editor/SuggestionHighlight.ts
prosolis 82e2bcc777 Suggestions: right-margin comment rail + Mandarin explanations
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
2026-06-26 12:23:02 -07:00

221 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
},
},
}),
]
},
})