// TipTap/ProseMirror plugin: dotted-underline decoration on text spans whose // surface form matches a taxonomy term label or synonym. Reads the term list // from the editor's storage (set by TextCanvas via `editor.storage.terms`). // // Click → convert the underlined span into a chip via the editor's // insertChip command. Hover → tooltip handled by CSS title attribute (we // stash the definition there so we don't need a portal). // // Decorations only apply over plain text, never inside a chip node (atomic). import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; interface TermLite { id: string; label: string; definition: string | null; synonyms: string[]; linkedBlockId: string | null; } const KEY = new PluginKey("chip-suggestion"); export const ChipSuggestionExtension = Extension.create({ name: "chipSuggestion", addProseMirrorPlugins() { const editor = this.editor; return [ new Plugin({ key: KEY, state: { init(_, state) { const terms = readTerms(editor); return buildDecorations(state.doc, terms); }, apply(tr, oldSet, _oldState, newState) { // Always rebuild on doc change OR when the editor signals that the // term list might have changed (force-update meta, dispatched by // TextCanvas after analyze runs). if (tr.docChanged || tr.getMeta("force-update")) { const terms = readTerms(editor); return buildDecorations(newState.doc, terms); } return oldSet.map(tr.mapping, tr.doc); }, }, props: { decorations(state) { return KEY.getState(state); }, handleClick(view, _pos, ev) { const target = ev.target as HTMLElement | null; if (!target) return false; const span = target.closest(".chip-suggestion") as HTMLElement | null; if (!span) return false; const from = parseInt(span.dataset.from ?? "", 10); const to = parseInt(span.dataset.to ?? "", 10); const termId = span.dataset.termId ?? ""; const label = span.dataset.label ?? span.innerText; if (Number.isNaN(from) || Number.isNaN(to) || !termId) return false; // Replace the span with a chip node. const { tr } = view.state; const chipType = view.state.schema.nodes.chip; if (!chipType) return false; tr.replaceWith( from, to, chipType.create({ kind: "block", refId: termId, label }) ); view.dispatch(tr); return true; }, }, }), ]; }, }); function readTerms(editor: { storage: unknown }): TermLite[] { const storage = editor.storage as Record; const raw = storage?.terms; if (!Array.isArray(raw)) return []; return (raw as TermLite[]).filter(t => t && typeof t.label === "string"); } interface MatchSpec { pattern: RegExp; term: TermLite; surface: string; } function buildPatterns(terms: TermLite[]): MatchSpec[] { const specs: MatchSpec[] = []; for (const t of terms) { const surfaces = dedupe([t.label, ...t.synonyms]).filter(s => s.trim().length >= 2); for (const s of surfaces) { specs.push({ pattern: new RegExp(`\\b${escapeRegex(s)}\\b`, "gi"), term: t, surface: s, }); } } // Match longer phrases first so "Socratic Tutor" wins over "Tutor". specs.sort((a, b) => b.surface.length - a.surface.length); return specs; } function buildDecorations(doc: import("@tiptap/pm/model").Node, terms: TermLite[]): DecorationSet { if (terms.length === 0) return DecorationSet.empty; const specs = buildPatterns(terms); const decos: Decoration[] = []; doc.descendants((node, pos) => { if (!node.isText || !node.text) return; // Skip text nodes that are inside a chip — chips are atomic, but be safe. const text = node.text; const occupied: Array<[number, number]> = []; // [start, end) within the text node for (const spec of specs) { spec.pattern.lastIndex = 0; let m: RegExpExecArray | null; while ((m = spec.pattern.exec(text)) !== null) { const start = m.index; const end = start + m[0].length; if (overlaps(occupied, start, end)) continue; occupied.push([start, end]); const from = pos + start; const to = pos + end; decos.push( Decoration.inline(from, to, { class: "chip-suggestion", "data-term-id": spec.term.id, "data-label": spec.term.label, "data-from": String(from), "data-to": String(to), title: spec.term.definition ?? "", }) ); } } }); return DecorationSet.create(doc, decos); } function overlaps(ranges: Array<[number, number]>, a: number, b: number): boolean { for (const [s, e] of ranges) if (a < e && b > s) return true; return false; } function dedupe(xs: string[]): string[] { const seen = new Set(); const out: string[] = []; for (const x of xs) { const k = x.trim().toLowerCase(); if (!k || seen.has(k)) continue; seen.add(k); out.push(x.trim()); } return out; } function escapeRegex(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }