From 6502b4d531a0afea9b01d6d16f11b8985cd32654 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 17 Sep 2026 11:16:40 -0700 Subject: [PATCH 01/10] feat(core): diagram anchor codec, accepted by external annotations and the feedback archive DiagramAnchor ({ v, family, kind, id | from + to, label, sourceLine }) and its fail-closed parser live in @plannotator/core/diagram-anchor beside html-anchor. POST /api/external-annotations validates an optional diagramAnchor on plan comments in both runtimes (the module is vendored to Pi), and the feedback archive records the validated anchor as an additive field. --- .../server/external-annotations.test.ts | 22 ++ apps/pi-extension/vendor.sh | 2 +- packages/core/diagram-anchor.test.ts | 133 ++++++++ packages/core/diagram-anchor.ts | 304 ++++++++++++++++++ packages/core/external-annotation.ts | 19 ++ packages/core/package.json | 1 + packages/server/external-annotations.test.ts | 26 ++ packages/shared/feedback-archive.test.ts | 27 ++ packages/shared/feedback-archive.ts | 8 + 9 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 packages/core/diagram-anchor.test.ts create mode 100644 packages/core/diagram-anchor.ts diff --git a/apps/pi-extension/server/external-annotations.test.ts b/apps/pi-extension/server/external-annotations.test.ts index 997f8e631..e76a83fdd 100644 --- a/apps/pi-extension/server/external-annotations.test.ts +++ b/apps/pi-extension/server/external-annotations.test.ts @@ -43,6 +43,28 @@ describe("pi external annotations: PATCH inReplyTo", () => { return { status: res.status, body: (await res.json()) as { error?: string; annotation?: { inReplyTo?: string } } }; }; + test("POST accepts a validated diagramAnchor and refuses a malformed one (Node mirror of the Bun case)", async () => { + const post = async (body: unknown) => { + const res = await fetch(`${base}/api/external-annotations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return { status: res.status, body: (await res.json()) as { ids?: string[]; error?: string } }; + }; + const anchor = { v: 1, family: "flowchart", kind: "node", id: "D", label: "Approve?", sourceLine: [7, 7] }; + const ok = await post({ source: "review-bot", type: "COMMENT", text: "rename", originalText: "Approve?", diagramAnchor: anchor }); + expect(ok.status).toBe(201); + const snapshot = (await (await fetch(`${base}/api/external-annotations`)).json()) as { + annotations: Array<{ id: string; diagramAnchor?: unknown }>; + }; + expect(snapshot.annotations.find((a) => a.id === ok.body.ids?.[0])?.diagramAnchor).toEqual(anchor); + + const bad = await post({ source: "review-bot", type: "COMMENT", text: "rename", originalText: "Approve?", diagramAnchor: { kind: "node" } }); + expect(bad.status).toBe(400); + expect(bad.body.error).toContain("diagramAnchor"); + }); + test("refuses an inReplyTo that is self, missing, or would close a cycle; accepts a valid reply", async () => { const added = handler.addAnnotations({ annotations: [ diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 347a446e3..d1f31a780 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -8,7 +8,7 @@ rm -rf generated mkdir -p generated generated/ai/providers # Modules that MOVED to @plannotator/core — vendor the real impl from core. -for f in feedback-templates project favicon code-file annotatable annotation-threads external-annotation agent-jobs agent-terminal source-save open-in-apps diff-paths diff-files guide guide-format guide-viewer-manifest compress crypto; do +for f in feedback-templates project favicon code-file annotatable annotation-threads diagram-anchor external-annotation agent-jobs agent-terminal source-save open-in-apps diff-paths diff-files guide guide-format guide-viewer-manifest compress crypto; do src="../../packages/core/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/core/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done diff --git a/packages/core/diagram-anchor.test.ts b/packages/core/diagram-anchor.test.ts new file mode 100644 index 000000000..5f9955167 --- /dev/null +++ b/packages/core/diagram-anchor.test.ts @@ -0,0 +1,133 @@ +/** + * The diagram anchor codec, pure half. + * + * What regresses if these fail: + * - the anchor a viewer writes is not what `parseDiagramAnchor` reads back, + * so every diagram comment lists as unanchored on the next load; + * - a foreign, partial or unversioned value throws instead of degrading to + * null (the standing rule: unanchored but listed) — the external-annotation + * POST, the feedback archive and the export all run this parser on data + * another writer produced; + * - `diagramSourceLine` points at the wrong line (`D` matching `DR`), so the + * Source pane's gutter mark and an agent's grep land on the wrong text; + * - the export's location line loses the part id an agent greps the fence for. + */ +import { describe, expect, test } from 'bun:test'; +import { + buildDiagramAnchor, + buildDiagramAnchorValue, + diagramAnchorLocationLine, + diagramSourceLine, + diagramTargetName, + diagramTargetText, + lineMentions, + parseDiagramAdditionalTargets, + parseDiagramAnchor, + sameTarget, + type DiagramTarget, +} from './diagram-anchor'; + +const node: DiagramTarget = { family: 'flowchart', kind: 'node', id: 'D', label: 'Approve?' }; +const edge: DiagramTarget = { family: 'flowchart', kind: 'edge', from: 'D', to: 'M', label: 'Yes' }; + +describe('the wire codec', () => { + test('the anchor value round-trips through JSON and the parser', () => { + const value = buildDiagramAnchorValue(node, [4, 4]); + expect(value).toEqual({ v: 1, family: 'flowchart', kind: 'node', id: 'D', label: 'Approve?', sourceLine: [4, 4] }); + expect(parseDiagramAnchor(JSON.parse(JSON.stringify(value)))).toEqual(value); + const edgeValue = buildDiagramAnchorValue({ ...edge, label: '' }, null); + expect(edgeValue).toEqual({ v: 1, family: 'flowchart', kind: 'edge', from: 'D', to: 'M', label: '', sourceLine: null }); + expect(parseDiagramAnchor(edgeValue)).toEqual(edgeValue); + }); + + test('the opaque-blob shape a host stores carries originalText, the anchor and the extra targets', () => { + const wire = buildDiagramAnchor(node, [2, 2], [edge]); + expect(wire).toEqual({ + originalText: 'Approve?', + diagram: { v: 1, family: 'flowchart', kind: 'node', id: 'D', label: 'Approve?', sourceLine: [2, 2] }, + diagramAdditionalTargets: [{ family: 'flowchart', kind: 'edge', from: 'D', to: 'M', label: 'Yes' }], + }); + expect(buildDiagramAnchor({ ...edge, label: '' }, null, [])).toEqual({ + originalText: 'D → M', + diagram: { v: 1, family: 'flowchart', kind: 'edge', from: 'D', to: 'M', label: '', sourceLine: null }, + }); + }); + + test('degrades a foreign, partial or unversioned value to null, never a throw', () => { + expect(parseDiagramAnchor(undefined)).toBeNull(); + expect(parseDiagramAnchor('node D')).toBeNull(); + expect(parseDiagramAnchor({ kind: 'node', id: 'D' })).toBeNull(); + expect(parseDiagramAnchor({ v: 2, family: 'flowchart', kind: 'node', id: 'D' })).toBeNull(); + expect(parseDiagramAnchor({ v: 1, family: 'flowchart', kind: 'edge', from: 'D' })).toBeNull(); + expect(parseDiagramAnchor({ v: 1, family: 'plantuml', kind: 'node', id: 'D' })).toBeNull(); + // A bad sourceLine drops to null; the target survives. + expect(parseDiagramAnchor({ v: 1, family: 'flowchart', kind: 'node', id: 'D', sourceLine: [0] })).toEqual({ + v: 1, + family: 'flowchart', + kind: 'node', + id: 'D', + label: '', + sourceLine: null, + }); + }); + + test('caps oversized strings and the additional targets, and drops junk entries', () => { + const long = 'x'.repeat(1000); + expect(parseDiagramAnchor({ v: 1, family: 'graphviz', kind: 'node', id: long, label: long })?.id).toHaveLength(400); + const many = Array.from({ length: 20 }, (_, i) => ({ family: 'flowchart', kind: 'node', id: `N${i}`, label: '' })); + expect(parseDiagramAdditionalTargets(many, 16)).toHaveLength(16); + expect(parseDiagramAdditionalTargets([null, 4, { kind: 'node' }, edge], 16)).toEqual([edge]); + expect(parseDiagramAdditionalTargets('nope', 16)).toEqual([]); + }); + + test('sameTarget compares the id, else both ends, never the family', () => { + expect(sameTarget(node, { ...node, family: 'class', label: 'other' })).toBe(true); + expect(sameTarget(edge, { ...edge, label: '' })).toBe(true); + expect(sameTarget(edge, { ...edge, to: 'R' })).toBe(false); + expect(sameTarget(node, edge)).toBe(false); + }); + + test('names parts for chips, the composer and the export', () => { + expect(diagramTargetText(node)).toBe('Approve?'); + expect(diagramTargetText({ ...edge, label: '' })).toBe('D → M'); + expect(diagramTargetName(node)).toBe('node D'); + expect(diagramTargetName(edge)).toBe('edge D → M'); + expect(diagramTargetName({ family: 'state', kind: 'edge', id: 'edge3', label: '' })).toBe('edge edge3'); + // The export line names the label, the id in parentheses, and the line. + expect(diagramAnchorLocationLine(buildDiagramAnchorValue(node, [4, 4]))).toBe('Diagram node Approve? (D), line 4'); + expect(diagramAnchorLocationLine(buildDiagramAnchorValue(edge, [5, 6]))).toBe('Diagram edge Yes (D → M), lines 5–6'); + expect(diagramAnchorLocationLine(buildDiagramAnchorValue({ ...edge, label: '' }, null))).toBe('Diagram edge D → M'); + expect(diagramAnchorLocationLine(buildDiagramAnchorValue({ family: 'graphviz', kind: 'node', id: 'Ship', label: 'Ship' }, [3, 3]))).toBe( + 'Diagram node Ship, line 3', + ); + }); +}); + +describe('diagramSourceLine', () => { + const source = [ + 'flowchart LR', + ' U([Reviewer]) --> D{Approve?}', + ' D -->|Yes| M[(Merge)]', + ' D -->|No| R[Revise]', + ' subgraph Later["Later"]', + ' R --> DR[Re-review]', + ' end', + ].join('\n'); + + test('finds the first line that names a node, both ends of an edge, or a subgraph', () => { + expect(diagramSourceLine(source, { family: 'flowchart', kind: 'node', id: 'D', label: '' })).toEqual([2, 2]); + expect(diagramSourceLine(source, { family: 'flowchart', kind: 'node', id: 'R', label: '' })).toEqual([4, 4]); + expect(diagramSourceLine(source, { family: 'flowchart', kind: 'edge', from: 'D', to: 'R', label: '' })).toEqual([4, 4]); + expect(diagramSourceLine(source, { family: 'flowchart', kind: 'cluster', id: 'Later', label: '' })).toEqual([5, 5]); + }); + + test('matches whole tokens only (D is not DR) and answers null for a part not in the text', () => { + expect(diagramSourceLine(source, { family: 'flowchart', kind: 'node', id: 'DR', label: '' })).toEqual([6, 6]); + expect(diagramSourceLine(source, { family: 'flowchart', kind: 'node', id: 'Z', label: '' })).toBeNull(); + expect(diagramSourceLine(source, { family: 'state', kind: 'edge', id: 'edge2', label: '' })).toBeNull(); + // Plain string scanning: an id with a regex metacharacter is a token, + // not a pattern. + expect(lineMentions(' A.b(1) --> C', 'A.b(1)')).toBe(true); + expect(lineMentions(' Ab1 --> C', 'A.b(1)')).toBe(false); + }); +}); diff --git a/packages/core/diagram-anchor.ts b/packages/core/diagram-anchor.ts new file mode 100644 index 000000000..e75a50959 --- /dev/null +++ b/packages/core/diagram-anchor.ts @@ -0,0 +1,304 @@ +/** + * Diagram anchors: the pure, browser-safe half of the diagram comment codec + * (the DOM half — the finders that walk a rendered svg — lives in + * `@plannotator/ui/utils/diagram-anchor` and `diagram-anchor-graphviz`). + * + * A comment on a rendered diagram part is stored as ONE additive field on + * the annotation, `diagramAnchor`: + * + * { v: 1, family: "flowchart", kind: "node", id: "B", + * label: "Validate order", sourceLine: [4, 4] } + * + * The anchor is the diagram's own id for the part (edges: `from` and `to` + * where the family writes both into the element id, `id` alone where it + * only numbers them), never the rendered element id whole (`flowchart-B-3`: + * the trailing counter moves when nodes are added) and never geometry (ELK + * and dagre place the same node at different coordinates). Restore order, + * run by the ui finders: (1) the element whose id names the part, (2) a + * node whose label text equals the stored label, (3) the source line as a + * gutter mark in the Source pane, (4) none: unanchored but listed. + * + * `sourceLine` is 1-based and names DOCUMENT lines (a fence's lines are + * offset by the fence's position in the markdown), so an agent's grep and + * the pane's gutter mark land on real text. + * + * Same contract as `html-anchor.ts`: the types here are what + * `@plannotator/ui/types`'s `Annotation.diagramAnchor` carries, and + * `parseDiagramAnchor` is the one fail-closed validator every ingest + * (external-annotation POST, the feedback archive, the ui codec) runs. + */ + +export type DiagramFamily = + | 'flowchart' + | 'state' + | 'class' + | 'er' + | 'requirement' + | 'other' + // A Graphviz graph: the id is the DOT node name. + | 'graphviz'; + +export type DiagramTargetKind = 'node' | 'edge' | 'cluster'; + +/** One addressable part of a rendered diagram. `id` names a node, cluster, + * class, entity or state; edges carry `from` and `to` where the family + * writes them into the element id (flowchart, class, er, graphviz) and `id` + * alone where it only numbers them (state `edge{n}`, requirement `{a}-{b}`). */ +export interface DiagramTarget { + readonly family: DiagramFamily; + readonly kind: DiagramTargetKind; + readonly id?: string; + readonly from?: string; + readonly to?: string; + /** The part's text at write time: the node label, or the edge label. */ + readonly label: string; +} + +export interface DiagramAnchor extends DiagramTarget { + readonly v: 1; + /** 1-based lines in the document that holds the source, or null when + * the writer could not locate the part in the text. */ + readonly sourceLine: readonly [number, number] | null; +} + +/** The diagram kinds the renderer slot knows. */ +export type DiagramKind = 'mermaid' | 'graphviz'; + +const FAMILIES: ReadonlySet = new Set([ + 'flowchart', + 'state', + 'class', + 'er', + 'requirement', + 'other', + 'graphviz', +]); +const KINDS: ReadonlySet = new Set(['node', 'edge', 'cluster']); + +/** Longest id, from, to or label the parser keeps; a rendered svg never + * produces more, and the anchor is persisted (drafts, the archive). */ +export const MAX_DIAGRAM_ANCHOR_STRING_LENGTH = 400; + +/** The wire keys a host that stores anchors as an opaque JSON blob uses + * (Workspaces' `anchor_json`); Plannotator carries the anchor on the + * annotation itself under `diagramAnchor`. */ +export const DIAGRAM_ANCHOR_KEY = 'diagram'; +export const DIAGRAM_ADDITIONAL_TARGETS_KEY = 'diagramAdditionalTargets'; + +/** Two targets name the same part: same kind and the same id, or the same + * from and to. The family is not compared: a foreign family with the same + * id grammar would match by construction, and the render decides. */ +export function sameTarget(a: DiagramTarget, b: DiagramTarget): boolean { + if (a.kind !== b.kind) return false; + if (a.id !== undefined || b.id !== undefined) return a.id === b.id; + return a.from === b.from && a.to === b.to; +} + +/** The comment's context line: the node label, else the edge's ends. */ +export function diagramTargetText(target: DiagramTarget): string { + if (target.label !== '') return target.label; + if (target.from !== undefined && target.to !== undefined) return `${target.from} → ${target.to}`; + return target.id ?? ''; +} + +/** The short part name for chips and the composer ("node C", "edge B → C"). */ +export function diagramTargetName(target: DiagramTarget): string { + if (target.kind === 'edge') { + return target.from !== undefined && target.to !== undefined + ? `edge ${target.from} → ${target.to}` + : `edge ${target.id ?? ''}`; + } + return `${target.kind} ${target.id ?? ''}`; +} + +/** + * The one-line location an export prints under a diagram comment: + * `Diagram node Approve? (D), line 4`. Edges name both ends, a part with no + * label falls back to its id, and a null `sourceLine` prints no line. + */ +export function diagramAnchorLocationLine(anchor: DiagramAnchor): string { + const ref = + anchor.id !== undefined + ? anchor.id + : anchor.from !== undefined && anchor.to !== undefined + ? `${anchor.from} → ${anchor.to}` + : ''; + const label = anchor.label !== '' ? anchor.label : ref; + let line = `Diagram ${anchor.kind} ${label}`; + if (ref !== '' && ref !== label) line += ` (${ref})`; + if (anchor.sourceLine !== null) { + const [first, last] = anchor.sourceLine; + line += last > first ? `, lines ${first}–${last}` : `, line ${first}`; + } + return line; +} + +/** A word character for token matching: the `\w` class, spelled out so the + * match below stays plain string work. A node id built from any other + * character (a dot, a dash) ends the token, which is what the source text + * does too. */ +function isWordChar(ch: string | undefined): boolean { + if (ch === undefined) return false; + return ( + (ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch === '_' + ); +} + +/** + * `line` names `token` as a WHOLE token (D does not match DR). Plain string + * scanning, never a RegExp built from the token: the id comes from a + * rendered svg and a `.` or `(` in it would otherwise compile into the + * pattern. + */ +export function lineMentions(line: string, token: string): boolean { + if (token === '') return false; + for (let at = line.indexOf(token); at !== -1; at = line.indexOf(token, at + 1)) { + const before = at === 0 ? undefined : line[at - 1]; + const after = line[at + token.length]; + if (!isWordChar(before) && !isWordChar(after)) return true; + } + return false; +} + +/** + * The 1-based line range that declares the part in the source text: the + * first line that names the node id (a cluster: its `subgraph` line), or + * the first line that names both ends of an edge. Null when the part is + * not found as a token, which is what a node that exists only in an unsaved + * draft reads as until it is saved. Mermaid grammar; the Graphviz finder + * has its own (`subgraph` may sit mid-line there). + */ +export function diagramSourceLine( + source: string, + target: DiagramTarget, +): readonly [number, number] | null { + const lines = source.split('\n'); + const matches = (line: string): boolean => { + if (target.kind === 'edge') { + if (target.from !== undefined && target.to !== undefined) { + return lineMentions(line, target.from) && lineMentions(line, target.to); + } + return false; + } + if (target.id === undefined) return false; + if (target.kind === 'cluster') { + return /^\s*subgraph\b/u.test(line) && lineMentions(line, target.id); + } + return lineMentions(line, target.id); + }; + const index = lines.findIndex(matches); + return index === -1 ? null : [index + 1, index + 1]; +} + +/** The wire shape of one target (no `sourceLine`, no version). */ +function targetWire(target: DiagramTarget): Record { + return { + family: target.family, + kind: target.kind, + ...(target.id !== undefined ? { id: target.id } : {}), + ...(target.from !== undefined ? { from: target.from } : {}), + ...(target.to !== undefined ? { to: target.to } : {}), + label: target.label, + }; +} + +/** The anchor value itself, as `Annotation.diagramAnchor` carries it. */ +export function buildDiagramAnchorValue( + primary: DiagramTarget, + sourceLine: readonly [number, number] | null, +): DiagramAnchor { + return { + v: 1, + family: primary.family, + kind: primary.kind, + ...(primary.id !== undefined ? { id: primary.id } : {}), + ...(primary.from !== undefined ? { from: primary.from } : {}), + ...(primary.to !== undefined ? { to: primary.to } : {}), + label: primary.label, + sourceLine: sourceLine === null ? null : [sourceLine[0], sourceLine[1]], + }; +} + +/** + * The opaque-blob wire shape a host that stores every anchor kind in one + * JSON column writes (`originalText` is the primary target's label so a + * comments panel's context line reads the same as a markdown or html + * comment). Plannotator does not use this shape itself. + */ +export function buildDiagramAnchor( + primary: DiagramTarget, + sourceLine: readonly [number, number] | null, + additional: readonly DiagramTarget[], +): Record { + return { + originalText: diagramTargetText(primary), + [DIAGRAM_ANCHOR_KEY]: buildDiagramAnchorValue(primary, sourceLine), + ...(additional.length > 0 + ? { [DIAGRAM_ADDITIONAL_TARGETS_KEY]: additional.map(targetWire) } + : {}), + }; +} + +function boundedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + return value.length > MAX_DIAGRAM_ANCHOR_STRING_LENGTH + ? value.slice(0, MAX_DIAGRAM_ANCHOR_STRING_LENGTH) + : value; +} + +/** Read one target tolerantly: a foreign or partial value is null. */ +export function parseDiagramTarget(value: unknown): DiagramTarget | null { + if (typeof value !== 'object' || value === null) return null; + const raw = value as Record; + const family = raw['family']; + const kind = raw['kind']; + if (typeof family !== 'string' || !FAMILIES.has(family)) return null; + if (typeof kind !== 'string' || !KINDS.has(kind)) return null; + const id = boundedString(raw['id']); + const from = boundedString(raw['from']); + const to = boundedString(raw['to']); + if (id === undefined && (from === undefined || to === undefined)) return null; + const label = boundedString(raw['label']) ?? ''; + return { + family: family as DiagramFamily, + kind: kind as DiagramTargetKind, + ...(id !== undefined ? { id } : {}), + ...(from !== undefined ? { from } : {}), + ...(to !== undefined ? { to } : {}), + label, + }; +} + +/** Read an anchor tolerantly: a foreign, partial or unversioned value is + * null, never a throw (the comment then lists as unanchored, the standing + * rule). A malformed `sourceLine` drops to null while the target survives. */ +export function parseDiagramAnchor(value: unknown): DiagramAnchor | null { + const target = parseDiagramTarget(value); + if (target === null) return null; + const raw = value as Record; + if (raw['v'] !== 1) return null; + const line = raw['sourceLine']; + const sourceLine = + Array.isArray(line) && + line.length === 2 && + line.every((n) => typeof n === 'number' && Number.isInteger(n) && n > 0) + ? ([line[0] as number, line[1] as number] as const) + : null; + return { ...target, v: 1, sourceLine }; +} + +/** Read additional targets, capped at `max` entries; junk entries are + * dropped, never thrown. */ +export function parseDiagramAdditionalTargets(value: unknown, max: number): DiagramTarget[] { + if (!Array.isArray(value)) return []; + const targets: DiagramTarget[] = []; + for (const entry of value) { + if (targets.length >= max) break; + const target = parseDiagramTarget(entry); + if (target !== null) targets.push(target); + } + return targets; +} diff --git a/packages/core/external-annotation.ts b/packages/core/external-annotation.ts index da2e8c568..5804bfe08 100644 --- a/packages/core/external-annotation.ts +++ b/packages/core/external-annotation.ts @@ -14,6 +14,8 @@ // adapters import it from the module they already use. export { validateReplyTarget } from "./annotation-threads"; +import { parseDiagramAnchor, type DiagramAnchor } from "./diagram-anchor"; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -113,6 +115,10 @@ interface PlanAnnotation { createdA: number; author?: string; source?: string; + /** A comment on a rendered diagram part (see `diagram-anchor.ts`). The + * diagram blocks resolve it against their render; a row that resolves in + * no diagram lists as unanchored. */ + diagramAnchor?: DiagramAnchor; } const VALID_PLAN_TYPES = ["DELETION", "COMMENT", "GLOBAL_COMMENT"]; @@ -158,6 +164,18 @@ export function transformPlanInput( }; } + // A diagram anchor is validated by the same fail-closed parser the ui + // codec and the feedback archive run; a malformed one is refused rather + // than stored as an anchor nothing can restore. + let diagramAnchor: DiagramAnchor | undefined; + if (obj.diagramAnchor !== undefined) { + const parsed = parseDiagramAnchor(obj.diagramAnchor); + if (parsed === null) { + return { error: `annotations[${i}] invalid "diagramAnchor" (expected { v: 1, family, kind, id | from + to, label, sourceLine })` }; + } + diagramAnchor = parsed; + } + annotations.push({ id: crypto.randomUUID(), blockId: "external", @@ -169,6 +187,7 @@ export function transformPlanInput( createdA: Date.now(), author: typeof obj.author === "string" ? obj.author : undefined, source, + ...(diagramAnchor !== undefined && { diagramAnchor }), }); } diff --git a/packages/core/package.json b/packages/core/package.json index 567520422..9bbb0d1c0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,6 +23,7 @@ "./feedback-templates": "./feedback-templates.ts", "./goal-setup": "./goal-setup.ts", "./html-anchor": "./html-anchor.ts", + "./diagram-anchor": "./diagram-anchor.ts", "./open-in-apps": "./open-in-apps.ts", "./project": "./project.ts", "./source-save": "./source-save.ts", diff --git a/packages/server/external-annotations.test.ts b/packages/server/external-annotations.test.ts index 61a055705..c26fcd86f 100644 --- a/packages/server/external-annotations.test.ts +++ b/packages/server/external-annotations.test.ts @@ -17,6 +17,32 @@ describe("external annotations SSE", () => { }); }); +describe("POST /api/external-annotations: diagram anchors", () => { + test("accepts a valid diagramAnchor on a plan comment and refuses a malformed one", async () => { + const handler = createExternalAnnotationHandler("plan"); + const post = async (body: unknown) => { + const url = "http://localhost/api/external-annotations"; + const res = await handler.handle( + new Request(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), + new URL(url), + ); + return { status: res?.status, body: (await res!.json()) as { ids?: string[]; error?: string } }; + }; + const anchor = { v: 1, family: "flowchart", kind: "node", id: "D", label: "Approve?", sourceLine: [7, 7] }; + const ok = await post({ source: "review-bot", type: "COMMENT", text: "rename", originalText: "Approve?", diagramAnchor: anchor }); + expect(ok.status).toBe(201); + const snapshotUrl = "http://localhost/api/external-annotations"; + const snapshot = (await (await handler.handle(new Request(snapshotUrl), new URL(snapshotUrl)))!.json()) as { + annotations: Array<{ id: string; diagramAnchor?: unknown }>; + }; + expect(snapshot.annotations.find((a) => a.id === ok.body.ids?.[0])?.diagramAnchor).toEqual(anchor); + + const bad = await post({ source: "review-bot", type: "COMMENT", text: "rename", originalText: "Approve?", diagramAnchor: { kind: "node", id: "D" } }); + expect(bad.status).toBe(400); + expect(bad.body.error).toContain("diagramAnchor"); + }); +}); + describe("PATCH /api/external-annotations", () => { test("cannot clear or change the source marker (skill-injection guard, reproduced end-to-end)", async () => { const handler = createExternalAnnotationHandler("review"); diff --git a/packages/shared/feedback-archive.test.ts b/packages/shared/feedback-archive.test.ts index fe9519442..0333ba784 100644 --- a/packages/shared/feedback-archive.test.ts +++ b/packages/shared/feedback-archive.test.ts @@ -313,6 +313,33 @@ describe("feedback archive: project bucketing", () => { }); }); +describe("feedback archive: diagram anchors", () => { + test("a diagram comment records its validated anchor; a malformed one and a text comment stay anchor-free", () => { + // Regression: without the field a comment on a flowchart node archives + // as the quote "Approve?" and the index cannot say WHICH node, in WHICH + // fence line, the comment was about. + const dataDir = useTempDataDir(); + const anchor = { v: 1, family: "flowchart", kind: "node", id: "D", label: "Approve?", sourceLine: [7, 7] }; + appendFeedbackRecord({ + project: PROJECT, + origin: "claude-code", + surface: "plan", + decision: "deny", + target: { slug: "plan-2026-09-17" }, + feedback: "rename", + annotations: [ + { id: "d1", type: "COMMENT", text: "rename", originalText: "Approve?", blockId: "block-4", diagramAnchor: anchor }, + { id: "d2", type: "COMMENT", text: "junk", originalText: "x", diagramAnchor: { kind: "node" } }, + { id: "t1", type: "COMMENT", text: "plain", originalText: "some words" }, + ], + }); + const record = readIndex(dataDir)[0]!; + expect(record.annotations?.[0]?.diagramAnchor).toEqual(anchor); + expect(record.annotations?.[1]).not.toHaveProperty("diagramAnchor"); + expect(record.annotations?.[2]).not.toHaveProperty("diagramAnchor"); + }); +}); + describe("feedback archive: element identity", () => { test("a raw-HTML pinpoint records its element identity and route; other annotations stay identity-free", () => { // Regression: without these fields a pinpoint archives as the bridge's diff --git a/packages/shared/feedback-archive.ts b/packages/shared/feedback-archive.ts index f0616a1a9..cce55165d 100644 --- a/packages/shared/feedback-archive.ts +++ b/packages/shared/feedback-archive.ts @@ -47,6 +47,7 @@ import { appendFileSync, mkdirSync, writeFileSync } from "fs"; import { join } from "path"; +import { parseDiagramAnchor, type DiagramAnchor } from "@plannotator/core/diagram-anchor"; import { getPlannotatorDataDir } from "./data-dir"; import { extractDirName, extractRepoName, sanitizeTag } from "./project"; @@ -181,6 +182,11 @@ export interface FeedbackAnnotationRecord { elementName?: string; /** Live-app sessions: the route the annotation was made on. */ pageUrl?: string; + /** A comment on a rendered diagram part (a Mermaid or Graphviz fence): + * the part's own id, label and document source line, validated by the + * same parser the ui codec runs. Additive per the field contract above; + * absent for every other annotation kind. */ + diagramAnchor?: DiagramAnchor; images?: number; } @@ -326,6 +332,8 @@ function normalizeAnnotation(raw: unknown): FeedbackAnnotationRecord { if (elementName) record.elementName = elementName; const pageUrl = asString(a.pageUrl); if (pageUrl) record.pageUrl = pageUrl; + const diagramAnchor = a.diagramAnchor === undefined ? null : parseDiagramAnchor(a.diagramAnchor); + if (diagramAnchor !== null) record.diagramAnchor = diagramAnchor; return record; } From 088d5087e670b9060424165a75976145c4f55998 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 17 Sep 2026 11:16:48 -0700 Subject: [PATCH 02/10] feat(ui): the Workspaces diagram viewer becomes the diagram engine MermaidBlock and GraphvizBlock keep fence parsing, diagramLanguages and the lazy-retry contract and render through one renderer slot (utils/diagram-render) and one canvas (components/diagram); their own viewBox math, applyView and zoom controls are gone, and the popout is the same DiagramViewer at full size in the PopoutDialog chrome. Graphviz gets a runtime slot beside Mermaid's (utils/graphviz, @viz-js/viz pinned 3.30.0). mermaidSvg.ts is replaced by sanitizeDiagramSvg (DOMPurify parse + in-place scrub). A comment composed on a diagram part is an Annotation with diagramAnchor and the fence's document lines: the highlighter skips it, the block restores it through the engine's finder and reports unanchored rows, the export prints its location line, share links drop it. Interaction per owner feedback: click selects, drag pans (4 px threshold), no hover targeting without the platform modifier, and an invisible 14 px hit path beside every edge. --- .github/workflows/test.yml | 5 + bun.lock | 10 +- .../components/DiagramBlock.anchor.test.tsx | 261 +++++++ .../DiagramBlock.lazyRetry.test.tsx | 18 +- packages/ui/components/DiagramBlock.tsx | 317 ++++++++ packages/ui/components/GraphvizBlock.tsx | 619 +--------------- packages/ui/components/MermaidBlock.test.ts | 59 -- .../ui/components/MermaidBlock.theme.test.tsx | 11 +- packages/ui/components/MermaidBlock.tsx | 681 +----------------- packages/ui/components/Viewer.tsx | 22 +- .../ui/components/diagram/DiagramCanvas.tsx | 375 ++++++++++ .../ui/components/diagram/DiagramComposer.tsx | 135 ++++ .../ui/components/diagram/DiagramOverlay.tsx | 210 ++++++ .../ui/components/diagram/DiagramPopout.tsx | 69 ++ .../components/diagram/DiagramSourcePane.tsx | 244 +++++++ .../components/diagram/DiagramViewer.test.tsx | 567 +++++++++++++++ .../ui/components/diagram/DiagramViewer.tsx | 259 +++++++ packages/ui/components/diagram/index.ts | 35 + .../components/diagram/useDiagramComments.ts | 283 ++++++++ .../ui/components/diagram/useDiagramRender.ts | 91 +++ .../diagram/useDiagramSourceDraft.ts | 143 ++++ .../components/diagram/useDiagramViewport.ts | 153 ++++ packages/ui/components/mermaidSvg.ts | 33 - ...AnnotationHighlighter.diagramSkip.test.tsx | 99 +++ packages/ui/hooks/useAnnotationHighlighter.ts | 5 + packages/ui/package.json | 4 +- packages/ui/test-setup/diagramSvg.ts | 37 + .../01-flowchart-td-subgraphs.geometry.json | 483 +++++++++++++ .../diagrams/01-flowchart-td-subgraphs.svg | 1 + ...06-flowchart-review-decision.geometry.json | 160 ++++ .../diagrams/06-flowchart-review-decision.svg | 1 + .../diagrams/08-state-diagram.geometry.json | 464 ++++++++++++ .../fixtures/diagrams/08-state-diagram.svg | 1 + .../diagrams/09-class-diagram.geometry.json | 160 ++++ .../fixtures/diagrams/09-class-diagram.svg | 1 + .../diagrams/10-er-diagram.geometry.json | 198 +++++ .../fixtures/diagrams/10-er-diagram.svg | 1 + .../11-sequence-diagram.geometry.json | 236 ++++++ .../fixtures/diagrams/11-sequence-diagram.svg | 1 + .../15-requirement-diagram.geometry.json | 122 ++++ .../diagrams/15-requirement-diagram.svg | 4 + packages/ui/types.ts | 5 + packages/ui/utils/diagram-anchor-graphviz.ts | 141 ++++ packages/ui/utils/diagram-anchor.ts | 250 +++++++ packages/ui/utils/diagram-projection.ts | 66 ++ packages/ui/utils/diagram-render.ts | 485 +++++++++++++ packages/ui/utils/diagramAnchor.test.ts | 211 ++++++ .../ui/utils/diagramAnchorGraphviz.test.ts | 177 +++++ packages/ui/utils/graphviz.ts | 93 +++ .../ui/utils/parser.diagramExport.test.ts | 57 ++ packages/ui/utils/parser.ts | 13 + packages/ui/utils/sharing.multiTarget.test.ts | 25 + tests/entry-assets.test.ts | 5 +- 53 files changed, 6739 insertions(+), 1367 deletions(-) create mode 100644 packages/ui/components/DiagramBlock.anchor.test.tsx create mode 100644 packages/ui/components/DiagramBlock.tsx create mode 100644 packages/ui/components/diagram/DiagramCanvas.tsx create mode 100644 packages/ui/components/diagram/DiagramComposer.tsx create mode 100644 packages/ui/components/diagram/DiagramOverlay.tsx create mode 100644 packages/ui/components/diagram/DiagramPopout.tsx create mode 100644 packages/ui/components/diagram/DiagramSourcePane.tsx create mode 100644 packages/ui/components/diagram/DiagramViewer.test.tsx create mode 100644 packages/ui/components/diagram/DiagramViewer.tsx create mode 100644 packages/ui/components/diagram/index.ts create mode 100644 packages/ui/components/diagram/useDiagramComments.ts create mode 100644 packages/ui/components/diagram/useDiagramRender.ts create mode 100644 packages/ui/components/diagram/useDiagramSourceDraft.ts create mode 100644 packages/ui/components/diagram/useDiagramViewport.ts delete mode 100644 packages/ui/components/mermaidSvg.ts create mode 100644 packages/ui/hooks/useAnnotationHighlighter.diagramSkip.test.tsx create mode 100644 packages/ui/test-setup/diagramSvg.ts create mode 100644 packages/ui/test-setup/fixtures/diagrams/01-flowchart-td-subgraphs.geometry.json create mode 100644 packages/ui/test-setup/fixtures/diagrams/01-flowchart-td-subgraphs.svg create mode 100644 packages/ui/test-setup/fixtures/diagrams/06-flowchart-review-decision.geometry.json create mode 100644 packages/ui/test-setup/fixtures/diagrams/06-flowchart-review-decision.svg create mode 100644 packages/ui/test-setup/fixtures/diagrams/08-state-diagram.geometry.json create mode 100644 packages/ui/test-setup/fixtures/diagrams/08-state-diagram.svg create mode 100644 packages/ui/test-setup/fixtures/diagrams/09-class-diagram.geometry.json create mode 100644 packages/ui/test-setup/fixtures/diagrams/09-class-diagram.svg create mode 100644 packages/ui/test-setup/fixtures/diagrams/10-er-diagram.geometry.json create mode 100644 packages/ui/test-setup/fixtures/diagrams/10-er-diagram.svg create mode 100644 packages/ui/test-setup/fixtures/diagrams/11-sequence-diagram.geometry.json create mode 100644 packages/ui/test-setup/fixtures/diagrams/11-sequence-diagram.svg create mode 100644 packages/ui/test-setup/fixtures/diagrams/15-requirement-diagram.geometry.json create mode 100644 packages/ui/test-setup/fixtures/diagrams/15-requirement-diagram.svg create mode 100644 packages/ui/utils/diagram-anchor-graphviz.ts create mode 100644 packages/ui/utils/diagram-anchor.ts create mode 100644 packages/ui/utils/diagram-projection.ts create mode 100644 packages/ui/utils/diagram-render.ts create mode 100644 packages/ui/utils/diagramAnchor.test.ts create mode 100644 packages/ui/utils/diagramAnchorGraphviz.test.ts create mode 100644 packages/ui/utils/graphviz.ts create mode 100644 packages/ui/utils/parser.diagramExport.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45d8f052a..861754ef5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -109,6 +109,11 @@ jobs: packages/ui/components/MarkdownEditor.extensions.test.tsx packages/ui/components/ThemeProvider.favicon.test.tsx packages/ui/components/MermaidBlock.theme.test.tsx + packages/ui/components/DiagramBlock.anchor.test.tsx + packages/ui/components/diagram/DiagramViewer.test.tsx + packages/ui/utils/diagramAnchor.test.ts + packages/ui/utils/diagramAnchorGraphviz.test.ts + packages/ui/hooks/useAnnotationHighlighter.diagramSkip.test.tsx packages/ui/components/CommentPopover.skillReferences.test.tsx packages/ui/components/SkillReferenceMenu.placement.test.tsx packages/ui/components/sidebar/FileBrowser.test.ts diff --git a/bun.lock b/bun.lock index 4a1cdee4c..73345b201 100644 --- a/bun.lock +++ b/bun.lock @@ -316,7 +316,7 @@ "@plannotator/markdown-editor": "^0.4.0", "@plannotator/web-highlighter": "^0.8.1", "@tanstack/react-table": "^8.21.3", - "@viz-js/viz": "^3.29.0", + "@viz-js/viz": "3.30.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "diff": "^8.0.4", @@ -1181,7 +1181,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], @@ -2579,6 +2579,8 @@ "@textlint/linter-formatter/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@types/bun/bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "@vscode/vsce/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@vscode/vsce/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], @@ -2663,8 +2665,6 @@ "parse-semver/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - "plannotator-webview/@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], - "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], @@ -2797,8 +2797,6 @@ "normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "plannotator-webview/@types/bun/bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], - "rimraf/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], diff --git a/packages/ui/components/DiagramBlock.anchor.test.tsx b/packages/ui/components/DiagramBlock.anchor.test.tsx new file mode 100644 index 000000000..9ab0a3430 --- /dev/null +++ b/packages/ui/components/DiagramBlock.anchor.test.tsx @@ -0,0 +1,261 @@ +/** + * The bridge between a comment composed on a diagram part and an annotation + * on the document: a mermaid fence parsed from real markdown (so the fence's + * `startLine` is the parser's own), the captured Chromium svg through the + * host runtime slot, Chromium's geometry for the parts. + * + * What regresses if these fail: + * - the annotation the block mints does not carry `diagramAnchor`, the + * fence's `blockId`, the label as `originalText`, or a `sourceLine` that + * names the DOCUMENT line (the fence offset lost, so the export and an + * agent's grep land on the wrong line); + * - a stored annotation with a diagram anchor does not restore onto its + * node after a reload, or one whose part is gone is not reported through + * `onRestoreReport` (so the panel never shows the "Unanchored" chip); + * - the popout does not open the same viewer at full size, or Escape on the + * canvas closes it while a draft is open; + * - read-only (an archive) still opens a composer. + * + * DOM-gated (DOM_TESTS=1). + */ +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter'; +import { installInertDiagramSvgParser } from '../test-setup/diagramSvg'; +import { AnnotationType, type Annotation, type Block } from '../types'; +import { __setMermaidRuntimeLoaderForTests, setMermaidRuntime } from '../utils/mermaid'; +import { parseMarkdownToBlocks } from '../utils/parser'; +import { MermaidBlock } from './MermaidBlock'; + +const hasDom = typeof document !== 'undefined'; +const FIXTURES = join(import.meta.dir, '..', 'test-setup', 'fixtures', 'diagrams'); +const CAPTURE_ID = 'diagram-fixture'; +const SVG = readFileSync(join(FIXTURES, '06-flowchart-review-decision.svg'), 'utf8'); +const GEOMETRY = JSON.parse(readFileSync(join(FIXTURES, '06-flowchart-review-decision.geometry.json'), 'utf8')) as { + elements: Record; +}; +const CAPTURED = Object.entries(GEOMETRY.elements).map(([id, entry]) => [id.slice(CAPTURE_ID.length), entry] as const); + +const MARKDOWN = [ + '# Plan', + '', + 'Some prose before the diagram.', + '', + '```mermaid', + 'flowchart LR', + ' U([Reviewer]) --> D{Approve?}', + ' D -->|Yes| M[(Merge)]', + ' D -->|No| R[Revise]', + '```', + '', + 'Some prose after.', +].join('\n'); +/** `D` is declared on line 7 of the document (1-based). */ +const D_DOCUMENT_LINE = 7; + +let root: Root | null = null; +let host: HTMLElement | null = null; +let restoreParser: (() => void) | null = null; +// happy-dom defines the two geometry methods on SVGGraphicsElement, which +// would shadow a stub on SVGElement; install where the engine defines them. +const svgProto = (hasDom ? ((globalThis as { SVGGraphicsElement?: typeof SVGElement }).SVGGraphicsElement ?? SVGElement).prototype : {}) as unknown as Record; +const elementProto = (hasDom ? Element.prototype : {}) as unknown as Record; +const saved = { getBBox: svgProto['getBBox'], getScreenCTM: svgProto['getScreenCTM'] }; +const noop = (): void => {}; + +beforeAll(() => { + if (!hasDom) return; + restoreParser = installInertDiagramSvgParser(); + setMermaidRuntime( + { initialize: noop, render: (id: string) => Promise.resolve({ svg: SVG.replaceAll(CAPTURE_ID, id) }) } as unknown as Parameters[0], + 'host', + ); + elementProto['setPointerCapture'] ??= noop; + elementProto['releasePointerCapture'] ??= noop; + elementProto['hasPointerCapture'] ??= () => false; + svgProto['getBBox'] = function (this: Element) { + const found = CAPTURED.find(([suffix]) => this.id.endsWith(suffix)); + if (found === undefined) throw new Error(`no captured geometry for ${this.id}`); + return { ...found[1].bbox }; + }; + svgProto['getScreenCTM'] = function (this: Element) { + return CAPTURED.find(([suffix]) => this.id.endsWith(suffix))?.[1].ctm ?? null; + }; +}); + +afterAll(() => { + if (!hasDom) return; + svgProto['getBBox'] = saved.getBBox; + svgProto['getScreenCTM'] = saved.getScreenCTM; + __setMermaidRuntimeLoaderForTests(undefined); + restoreParser?.(); +}); + +afterEach(async () => { + if (root !== null) { + const finished = root; + await act(async () => { + finished.unmount(); + }); + root = null; + } + host?.remove(); + host = null; +}); + +function fence(): Block { + const block = parseMarkdownToBlocks(MARKDOWN).find((b) => b.type === 'code'); + if (block === undefined) throw new Error('no fence'); + return block; +} + +async function mount(element: React.ReactElement): Promise { + host = document.createElement('div'); + document.body.appendChild(host); + root = createRoot(host); + await act(async () => { + root!.render(element); + }); +} + +async function settle(ms = 25): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); +} + +async function waitFor(check: () => void, tries = 40): Promise { + let lastError: unknown; + for (let i = 0; i < tries; i += 1) { + try { + check(); + return; + } catch (error) { + lastError = error; + await settle(); + } + } + throw lastError; +} + +function pointer(type: string, target: Element): void { + const Ctor = (globalThis as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent; + target.dispatchEvent( + new Ctor(type, { bubbles: true, cancelable: true, clientX: 10, clientY: 10, button: 0, ...(Ctor !== MouseEvent ? { pointerId: 1 } : {}) } as MouseEventInit), + ); +} + +async function clickNodeD(scope: ParentNode): Promise { + const node = scope.querySelector('[id$="-flowchart-D-1"]'); + if (node === null) throw new Error('no node D'); + await act(async () => { + pointer('pointerdown', node); + pointer('pointerup', node); + }); +} + +async function typeAndSubmit(scope: ParentNode, text: string): Promise { + const textarea = scope.querySelector('[data-diagram-composer] textarea'); + if (textarea === null) throw new Error('no composer'); + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(textarea, text); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + }); +} + +describe.if(hasDom)('DiagramBlock: comments become annotations on the document', () => { + test('a comment on node D is an annotation on the fence with the anchor and the document line', async () => { + const block = fence(); + expect(block.startLine).toBe(5); + const added: Annotation[] = []; + await mount( added.push(ann)} />); + await waitFor(() => expect(host!.querySelector('[id$="-flowchart-D-1"]')).not.toBeNull()); + await clickNodeD(host!); + await waitFor(() => expect(host!.querySelector('[data-diagram-composer]')).not.toBeNull()); + expect(host!.querySelector('[data-diagram-composer]')!.textContent).toContain(`line ${D_DOCUMENT_LINE}`); + await typeAndSubmit(host!, 'Rename this step'); + await waitFor(() => expect(added).toHaveLength(1)); + const ann = added[0]!; + expect(ann.blockId).toBe(block.id); + expect(ann.type).toBe(AnnotationType.COMMENT); + expect(ann.text).toBe('Rename this step'); + expect(ann.originalText).toBe('Approve?'); + expect(ann.diagramAnchor).toEqual({ v: 1, family: 'flowchart', kind: 'node', id: 'D', label: 'Approve?', sourceLine: [D_DOCUMENT_LINE, D_DOCUMENT_LINE] }); + expect(ann.startMeta).toBeUndefined(); + }); + + test('stored annotations restore onto their nodes after a reload, and the gone ones are reported unanchored', async () => { + const block = fence(); + const reports: AnnotationRestoreReport[] = []; + const restored: Annotation = { + id: 'a1', + blockId: block.id, + startOffset: 0, + endOffset: 0, + type: AnnotationType.COMMENT, + text: 'kept', + originalText: 'Approve?', + createdA: 1, + diagramAnchor: { v: 1, family: 'flowchart', kind: 'node', id: 'D', label: 'Approve?', sourceLine: [7, 7] }, + }; + const gone: Annotation = { + ...restored, + id: 'a2', + originalText: 'Nowhere', + diagramAnchor: { v: 1, family: 'flowchart', kind: 'node', id: 'Gone', label: 'Nowhere', sourceLine: null }, + }; + const other: Annotation = { ...restored, id: 'a3', blockId: 'block-other' }; + await mount( reports.push(r)} />); + await waitFor(() => expect(host!.querySelector('[data-diagram-badge="a1"]')).not.toBeNull()); + expect(host!.querySelector('[data-diagram-badge="a2"]')).toBeNull(); + // Another fence's comment is not this block's. + expect(host!.querySelector('[data-diagram-badge="a3"]')).toBeNull(); + await waitFor(() => expect(reports.length).toBeGreaterThan(0)); + const last = reports[reports.length - 1]!; + expect([...last.attempted].sort()).toEqual(['a1', 'a2']); + expect(last.unanchored).toEqual(['a2']); + }); + + test('read-only opens no composer', async () => { + const block = fence(); + const added: Annotation[] = []; + await mount( added.push(ann)} readOnly />); + await waitFor(() => expect(host!.querySelector('[id$="-flowchart-D-1"]')).not.toBeNull()); + await clickNodeD(host!); + await settle(); + expect(host!.querySelector('[data-diagram-composer]')).toBeNull(); + expect(added).toEqual([]); + }); + + test('the popout is the same viewer at full size; a comment made there lands on the document; Escape walks the draft before the dialog', async () => { + const block = fence(); + const added: Annotation[] = []; + await mount( added.push(ann)} />); + await waitFor(() => expect(host!.querySelector('[data-diagram-expand]')).not.toBeNull()); + await act(async () => { + host!.querySelector('[data-diagram-expand]')!.click(); + }); + await waitFor(() => expect(document.querySelector('[data-diagram-popout]')).not.toBeNull()); + const popout = document.querySelector('[data-diagram-popout]')!; + await waitFor(() => expect(popout.querySelector('[id$="-flowchart-D-1"]')).not.toBeNull()); + // Two viewers over one fence carry two render ids. + expect(popout.querySelector('[id$="-flowchart-D-1"]')!.id).not.toBe(host!.querySelector('[id$="-flowchart-D-1"]')!.id); + await clickNodeD(popout); + await waitFor(() => expect(popout.querySelector('[data-diagram-composer]')).not.toBeNull()); + await act(async () => { + popout.querySelector('[data-diagram-composer] textarea')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + }); + await waitFor(() => expect(popout.querySelector('[data-diagram-composer]')).toBeNull()); + expect(document.querySelector('[data-diagram-popout]')).not.toBeNull(); + await clickNodeD(popout); + await waitFor(() => expect(popout.querySelector('[data-diagram-composer]')).not.toBeNull()); + await typeAndSubmit(popout, 'from the popout'); + await waitFor(() => expect(added).toHaveLength(1)); + expect(added[0]!.blockId).toBe(block.id); + expect(added[0]!.diagramAnchor?.id).toBe('D'); + }); +}); diff --git a/packages/ui/components/DiagramBlock.lazyRetry.test.tsx b/packages/ui/components/DiagramBlock.lazyRetry.test.tsx index a616f6f83..f3d171d0f 100644 --- a/packages/ui/components/DiagramBlock.lazyRetry.test.tsx +++ b/packages/ui/components/DiagramBlock.lazyRetry.test.tsx @@ -16,12 +16,21 @@ * * DOM-gated (DOM_TESTS=1). */ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test'; import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { Block } from '../types'; +import { installInertDiagramSvgParser } from '../test-setup/diagramSvg'; import { MermaidBlock, __setMermaidRuntimeLoaderForTests } from './MermaidBlock'; import { GraphvizBlock, __setVizLoaderForTests } from './GraphvizBlock'; + +// happy-dom cannot host DOMPurify: the render slot's parse step is the inert +// template parse for these tests (the scrub still runs). +let restoreParser: (() => void) | null = null; +beforeAll(() => { + if (hasDom) restoreParser = installInertDiagramSvgParser(); +}); +afterAll(() => restoreParser?.()); import { getMathRenderer, getMathRendererSource, @@ -39,7 +48,10 @@ const mermaidBlock: Block = { id: 'm1', type: 'code', language: 'mermaid', conte const dotBlock: Block = { id: 'g1', type: 'code', language: 'dot', content: 'digraph { A -> B }', order: 0, startLine: 1 }; const fakeMermaid = { initialize() {}, render: async () => ({ svg: SVG }) } as never; -const fakeViz = { renderString: async () => SVG } as never; +// The renderer slot drives the engine through `render()` (a value with the +// status and the errors, never a throw for a bad graph), so the stand-in +// answers that shape. +const fakeViz = { render: () => ({ status: 'success', output: SVG, errors: [] }) } as never; let root: Root | null = null; let host: HTMLElement | null = null; @@ -214,7 +226,7 @@ describe.each(cases)('$name lazy runtime', ({ install, runtime, element, source, }); test.skipIf(!hasDom)('a diagram syntax error keeps the existing panel without a Retry button', async () => { - const broken = { initialize() {}, render: async () => { throw new Error('Parse error'); }, renderString: async () => { throw new Error('Parse error'); } } as never; + const broken = { initialize() {}, render: () => { throw new Error('Parse error'); } } as never; let calls = 0; install(() => { calls += 1; return Promise.resolve(broken); }); const el = await mount(element); diff --git a/packages/ui/components/DiagramBlock.tsx b/packages/ui/components/DiagramBlock.tsx new file mode 100644 index 000000000..ce73ddbc3 --- /dev/null +++ b/packages/ui/components/DiagramBlock.tsx @@ -0,0 +1,317 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { diagramTargetText, type DiagramKind } from '@plannotator/core/diagram-anchor'; +import type { AnnotationRestoreReport } from '../hooks/useAnnotationHighlighter'; +import { AnnotationType, type Annotation, type Block } from '../types'; +import type { DiagramTheme } from '../utils/diagram-render'; +import { getIdentity } from '../utils/identity'; +import { createRuntimeRetryEpoch } from '../utils/runtimeRetry'; +import { svgContentSize } from './diagram/DiagramCanvas'; +import { DiagramPopout } from './diagram/DiagramPopout'; +import { DiagramViewer } from './diagram/DiagramViewer'; +import type { DiagramComment, DiagramCreateComment } from './diagram/useDiagramComments'; +import type { DiagramRenderState } from './diagram/useDiagramRender'; +import { useTheme } from './ThemeProvider'; + +/** + * A diagram fence in the document: the fence's language picks the engine + * (`MermaidBlock`, `GraphvizBlock`), and everything else is one code path + * through the renderer slot and `DiagramViewer` — the canvas with zoom, pan + * and fit, the comment overlay, and the same viewer at full size in the + * popout. What this block owns is the document side: the source fence under + * a status until the first render lands (never the error panel as a + * placeholder), the error panel with the source and a Retry for a failed + * engine load, the "Show source" toggle, and the bridge between a comment + * composed on a part and an `Annotation` on the document (`diagramAnchor` + * plus the fence's document lines), so it lists in the rail beside text + * comments, exports, drafts and restores after a reload. + */ + +/** One Retry re-attempts every block whose engine import failed (see utils/runtimeRetry). */ +const RETRY_EPOCHS: Record> = { + mermaid: createRuntimeRetryEpoch(), + graphviz: createRuntimeRetryEpoch(), +}; + +const LABELS: Record = { mermaid: 'Mermaid', graphviz: 'Graphviz' }; + +/** The inline box height from the diagram's aspect at a nominal width, so + * a wide flowchart is not letterboxed in a tall box and a tall state + * diagram is not squeezed into a short one; clamped so neither extreme + * takes the page. The canvas fits the diagram inside whatever it gets. */ +const NOMINAL_WIDTH_PX = 800; +const MIN_HEIGHT_PX = 16 * 16; +const MAX_HEIGHT_PX = 36 * 16; + +function inlineHeight(state: DiagramRenderState | null): string { + const size = state?.svgNode ? svgContentSize(state.svgNode) : null; + if (size === null) return 'min(65vh, 24rem)'; + const px = Math.round(size.height * (NOMINAL_WIDTH_PX / size.width)) + 48; + return `min(65vh, ${Math.min(MAX_HEIGHT_PX, Math.max(MIN_HEIGHT_PX, px))}px)`; +} + +function newAnnotationId(): string { + const c = globalThis.crypto; + if (c && typeof c.randomUUID === 'function') return c.randomUUID(); + return `ann-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +export interface DiagramBlockProps { + block: Block; + /** The document's annotations; the block keeps the ones anchored on this + * fence (`diagramAnchor` + its `blockId`). */ + annotations?: readonly Annotation[]; + selectedAnnotationId?: string | null; + onSelectAnnotation?: (id: string | null) => void; + /** A comment composed on a part becomes an annotation on the document. */ + onAddAnnotation?: (annotation: Annotation) => void; + readOnly?: boolean; + /** The block's restore verdict after every render: its own comments as + * `attempted`, the ones whose part is gone as `unanchored`, so the host's + * panel shows the same "Unanchored" chip a text comment gets. */ + onRestoreReport?: (report: AnnotationRestoreReport) => void; +} + +const NO_ANNOTATIONS: readonly Annotation[] = []; + +export const DiagramBlock: React.FC = ({ + kind, + block, + annotations = NO_ANNOTATIONS, + selectedAnnotationId = null, + onSelectAnnotation, + onAddAnnotation, + readOnly = false, + onRestoreReport, +}) => { + const label = LABELS[kind]; + const rootRef = useRef(null); + const [showSource, setShowSource] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [retryToken, setRetryToken] = useState(0); + const [renderState, setRenderState] = useState(null); + + // The (palette, mode) the diagram must follow: the same resolution the + // code fences use (see useFenceTheme). Outside a ThemeProvider the default + // context yields the Plannotator dark pair, and with no theme tokens on the + // document the renderer keeps the static config, so a host without the + // provider renders exactly as before. A key change re-runs the render, + // which is what re-themes an already rendered diagram. + const { colorTheme, resolvedMode } = useTheme(); + const theme = useMemo( + () => ({ colorTheme, mode: resolvedMode === 'light' ? 'light' : 'dark' }), + [colorTheme, resolvedMode], + ); + + // A sibling's Retry re-attempts this block too, but only while its own + // failure was the shared engine import; a healthy block or a diagram + // syntax error is left alone. + const runtimeUnavailableRef = useRef(false); + runtimeUnavailableRef.current = renderState?.error?.runtimeUnavailable ?? false; + useEffect( + () => + RETRY_EPOCHS[kind].subscribe(() => { + if (!runtimeUnavailableRef.current) return; + setRetryToken((token) => token + 1); + }), + [kind], + ); + + useEffect(() => { + setIsExpanded(false); + }, [block.content]); + + // The comments on this fence, in document order: the badge numbers. + const comments = useMemo( + () => + annotations + .filter((ann) => ann.diagramAnchor !== undefined && ann.blockId === block.id) + .map((ann) => ({ + id: ann.id, + anchor: ann.diagramAnchor!, + text: ann.text ?? '', + author: ann.author, + })), + [annotations, block.id], + ); + const commentIdsRef = useRef([]); + commentIdsRef.current = comments.map((c) => c.id); + + const selectedCommentId = useMemo( + () => (selectedAnnotationId !== null && comments.some((c) => c.id === selectedAnnotationId) ? selectedAnnotationId : null), + [comments, selectedAnnotationId], + ); + useEffect(() => { + if (selectedCommentId === null) return; + rootRef.current?.scrollIntoView?.({ block: 'nearest' }); + }, [selectedCommentId]); + + const handleCreate = useMemo(() => { + if (readOnly || onAddAnnotation === undefined) return undefined; + return (anchor, text) => { + onAddAnnotation({ + id: newAnnotationId(), + blockId: block.id, + startOffset: 0, + endOffset: 0, + type: AnnotationType.COMMENT, + text, + originalText: diagramTargetText(anchor), + createdA: Date.now(), + author: getIdentity(), + diagramAnchor: anchor, + }); + }; + }, [block.id, onAddAnnotation, readOnly]); + + const handleUnanchored = useCallback( + (ids: ReadonlySet) => { + onRestoreReport?.({ attempted: commentIdsRef.current, unanchored: [...ids] }); + }, + [onRestoreReport], + ); + + const svgReady = renderState?.svgNode != null; + + const renderFallback = useCallback( + (state: DiagramRenderState) => { + if (state.error !== null) { + return ( +
+
+ + + + {label} Error + {state.error.runtimeUnavailable && ( + + )} +
+
{state.error.message}
+
+              {block.content}
+            
+
+ ); + } + // First render still in flight (the engine import on the lazy path, + // then the render itself): the source stays readable under a quiet + // status line. A re-render for a theme change keeps the previous SVG, + // so this shows only before the first diagram lands. + return ( + <> +
+
+ + + ); + }, + [block, kind, label], + ); + + const viewerProps = { + kind, + source: block.content, + theme, + comments, + onCreateComment: handleCreate, + selectedCommentId, + onSelectComment: onSelectAnnotation, + sourceLineOffset: block.startLine, + retryToken, + }; + + return ( + <> +
+ {svgReady && !showSource && ( +
+ + +
+ )} + {showSource ? ( +
+ + +
+ ) : ( +
+ +
+ )} +
+ {isExpanded && svgReady && typeof document !== 'undefined' && ( + setIsExpanded(false)} + title={`${label} diagram`} + renderId={`${kind}-${block.id}-popout`} + dataAttributes={{ 'data-block-id': block.id }} + /> + )} + + ); +}; + +const InlineSource: React.FC<{ block: Block; kind: DiagramKind }> = ({ block, kind }) => ( +
+    {block.content}
+  
+); diff --git a/packages/ui/components/GraphvizBlock.tsx b/packages/ui/components/GraphvizBlock.tsx index 7d0044f42..e905d8a6c 100644 --- a/packages/ui/components/GraphvizBlock.tsx +++ b/packages/ui/components/GraphvizBlock.tsx @@ -1,601 +1,26 @@ -import React, { useRef, useState, useEffect, useCallback } from 'react'; -import { createPortal } from 'react-dom'; -import type { Viz } from '@viz-js/viz'; -import type { Block } from '../types'; -import { createRuntimeRetryEpoch } from '../utils/runtimeRetry'; +import React from 'react'; +import { __setGraphvizRuntimeLoaderForTests } from '../utils/graphviz'; +import { DiagramBlock, type DiagramBlockProps } from './DiagramBlock'; -interface ViewBox { - x: number; - y: number; - width: number; - height: number; -} - -const ZOOM_STEP = 0.25; -const MIN_ZOOM = 0.25; -const MAX_ZOOM = 8; - -/** - * The Graphviz engine (about 1.2 MB of Emscripten JS) is imported inside the - * render effect, not statically, so a host that bundles by route only fetches - * it when a dot fence is on the page. WASM instantiation was already deferred - * to first render; in Plannotator's single-file builds the import is inlined - * and resolves in a microtask ahead of a render that was already asynchronous. - */ -const loadVizInstance = (): Promise => import('@viz-js/viz').then((m) => m.instance()); - -let vizLoader = loadVizInstance; -let vizInstancePromise: Promise | null = null; +/** Test hook: stand in for the engine import and shorten the retry delay + * (the slot's, re-exported under the name the lazy-retry test uses). */ +export const __setVizLoaderForTests = __setGraphvizRuntimeLoaderForTests; /** - * Delay before the one automatic re-attempt after a failed engine import. - * Only chunking hosts can fail here (a single-file build never fetches). + * A dot fence. The engine comes from the slot in `utils/graphviz` (lazy on + * the first dot fence); the canvas, the comment overlay and the popout are + * `DiagramBlock`'s, one code path with `MermaidBlock`. */ -let runtimeRetryDelayMs = 750; - -/** - * Memoized engine. A rejected load is dropped from the memo so the next call - * (the automatic re-attempt, a later mount, or the Retry button) issues a - * fresh import() instead of replaying the cached rejection. - */ -function getVizInstance(): Promise { - if (!vizInstancePromise) { - const attempt = vizLoader().catch((err: unknown) => { - if (vizInstancePromise === attempt) vizInstancePromise = null; - throw err; - }); - vizInstancePromise = attempt; - } - return vizInstancePromise; -} - -/** Test hook: stand in for the engine import and shorten the retry delay. */ -export function __setVizLoaderForTests( - loader: (() => Promise) | undefined, - options?: { retryDelayMs?: number }, -): void { - vizLoader = loader ?? loadVizInstance; - vizInstancePromise = null; - runtimeRetryDelayMs = options?.retryDelayMs ?? 750; -} - -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -/** One Retry re-attempts every block whose engine import failed (see utils/runtimeRetry). */ -const vizRetryEpoch = createRuntimeRetryEpoch(); - -function parseViewBox(svgEl: SVGSVGElement): ViewBox | null { - const raw = svgEl.getAttribute('viewBox'); - if (!raw) return null; - - const values = raw - .trim() - .split(/[\s,]+/) - .map((value) => Number.parseFloat(value)); - - if (values.length !== 4 || values.some((value) => Number.isNaN(value))) { - return null; - } - - const [x, y, width, height] = values; - if (width <= 0 || height <= 0) return null; - return { x, y, width, height }; -} - -function parseViewBoxFromMarkup(markup: string): ViewBox | null { - const viewBoxMatch = markup.match(/viewBox\s*=\s*"([^"]+)"/i); - if (viewBoxMatch?.[1]) { - const values = viewBoxMatch[1] - .trim() - .split(/[\s,]+/) - .map((value) => Number.parseFloat(value)); - - if (values.length === 4 && values.every((value) => Number.isFinite(value))) { - const [x, y, width, height] = values; - if (width > 0 && height > 0) { - return { x, y, width, height }; - } - } - } - - const widthMatch = markup.match(/\bwidth\s*=\s*"([0-9.]+)(?:px|pt)?"/i); - const heightMatch = markup.match(/\bheight\s*=\s*"([0-9.]+)(?:px|pt)?"/i); - const width = widthMatch?.[1] ? Number.parseFloat(widthMatch[1]) : NaN; - const height = heightMatch?.[1] ? Number.parseFloat(heightMatch[1]) : NaN; - if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { - return { x: 0, y: 0, width, height }; - } - - return null; -} - -function applyView(svgEl: SVGSVGElement, base: ViewBox, zoom: number, pan: { x: number; y: number }): void { - const zoomedWidth = base.width / zoom; - const zoomedHeight = base.height / zoom; - const centerX = base.x + base.width / 2; - const centerY = base.y + base.height / 2; - const vbX = centerX - zoomedWidth / 2 + pan.x; - const vbY = centerY - zoomedHeight / 2 + pan.y; - svgEl.setAttribute('viewBox', `${vbX} ${vbY} ${zoomedWidth} ${zoomedHeight}`); -} - -function fitBoundsToContainer(bounds: ViewBox, containerRect: DOMRect): ViewBox { - const containerWidth = Math.max(containerRect.width, 1); - const containerHeight = Math.max(containerRect.height, 1); - const contentRatio = bounds.width / bounds.height; - const containerRatio = containerWidth / containerHeight; - - if (contentRatio > containerRatio) { - const targetHeight = bounds.width / containerRatio; - const extra = (targetHeight - bounds.height) / 2; - return { - x: bounds.x, - y: bounds.y - extra, - width: bounds.width, - height: targetHeight, - }; - } - - const targetWidth = bounds.height * containerRatio; - const extra = (targetWidth - bounds.width) / 2; - return { - x: bounds.x - extra, - y: bounds.y, - width: targetWidth, - height: bounds.height, - }; -} - -export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { - const containerRef = useRef(null); - const [svg, setSvg] = useState(''); - const [error, setError] = useState(null); - // True when the failure was the engine import itself (a chunking host's - // fetch), which is the only failure a Retry can change; a dot syntax error - // keeps the panel exactly as it always was. - const [runtimeUnavailable, setRuntimeUnavailable] = useState(false); - const [retryToken, setRetryToken] = useState(0); - const [showSource, setShowSource] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - // A sibling's Retry re-attempts this block too, but only while its own - // failure was the shared engine import; a healthy block or a dot syntax - // error is left alone. - const runtimeUnavailableRef = useRef(runtimeUnavailable); - runtimeUnavailableRef.current = runtimeUnavailable; - useEffect(() => vizRetryEpoch.subscribe(() => { - if (!runtimeUnavailableRef.current) return; - setError(null); - setRetryToken((token) => token + 1); - }), []); - - const zoomLevelRef = useRef(1); - const isDraggingRef = useRef(false); - const naturalBoundsRef = useRef(null); - const baseViewBoxRef = useRef(null); - const panOffsetRef = useRef({ x: 0, y: 0 }); - const dragStartRef = useRef({ x: 0, y: 0 }); - const panStartRef = useRef({ x: 0, y: 0 }); - - const zoomInBtnRef = useRef(null); - const zoomOutBtnRef = useRef(null); - const zoomDisplayRef = useRef(null); - - const updateZoom = useCallback((newZoom: number) => { - zoomLevelRef.current = newZoom; - - if (containerRef.current && baseViewBoxRef.current) { - const svgEl = containerRef.current.querySelector('svg'); - if (svgEl instanceof SVGSVGElement) { - applyView(svgEl, baseViewBoxRef.current, newZoom, panOffsetRef.current); - } - } - - if (zoomInBtnRef.current) zoomInBtnRef.current.disabled = newZoom >= MAX_ZOOM; - if (zoomOutBtnRef.current) zoomOutBtnRef.current.disabled = newZoom <= MIN_ZOOM; - if (zoomDisplayRef.current) { - const show = Math.abs(newZoom - 1) > 0.001; - zoomDisplayRef.current.textContent = show ? `${Math.round(newZoom * 100)}%` : ''; - zoomDisplayRef.current.hidden = !show; - } - }, []); - - const fitToCurrentViewport = useCallback(() => { - if (!containerRef.current || !naturalBoundsRef.current) return; - - const svgEl = containerRef.current.querySelector('svg'); - if (!(svgEl instanceof SVGSVGElement)) return; - - const fitted = fitBoundsToContainer(naturalBoundsRef.current, containerRef.current.getBoundingClientRect()); - baseViewBoxRef.current = fitted; - panOffsetRef.current = { x: 0, y: 0 }; - updateZoom(1); - applyView(svgEl, fitted, 1, { x: 0, y: 0 }); - }, [updateZoom]); - - useEffect(() => { - let cancelled = false; - - const renderDiagram = async () => { - let viz: Viz; - try { - try { - viz = await getVizInstance(); - } catch { - // Transient chunk failure on a chunking host: one automatic - // re-attempt with a fresh import() after a short delay. In a - // single-file build the first await never rejects, so this branch - // is unreachable there and the success path is unchanged. - await wait(runtimeRetryDelayMs); - if (cancelled) return; - viz = await getVizInstance(); - } - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to render diagram'); - setRuntimeUnavailable(true); - setSvg(''); - } - return; - } - try { - const renderedSvg = await viz.renderString(block.content, { format: 'svg' }); - const cleaned = renderedSvg - .replace(/ width="[^"]*"/, ' width="100%"') - .replace(/ height="[^"]*"/, ' height="100%"') - .replace(/ style="[^"]*"/, '') - .replace(/]*fill="white"[^>]*\/>/, '') - .replace(/fill="black"/g, 'fill="var(--foreground)"') - .replace(/fill="#000000"/g, 'fill="var(--foreground)"') - .replace(/stroke="black"/g, 'stroke="var(--muted-foreground)"') - .replace(/stroke="#000000"/g, 'stroke="var(--muted-foreground)"') - .replace(/fill="lightgrey"/g, 'fill="var(--muted)"') - .replace(/fill="lightgray"/g, 'fill="var(--muted)"'); - - if (!cancelled) { - naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned); - setSvg(cleaned); - setError(null); - setRuntimeUnavailable(false); - } - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to render diagram'); - setRuntimeUnavailable(false); - setSvg(''); - } - } - }; - - renderDiagram(); - - return () => { - cancelled = true; - }; - }, [block.content, retryToken]); - - useEffect(() => { - zoomLevelRef.current = 1; - naturalBoundsRef.current = null; - baseViewBoxRef.current = null; - panOffsetRef.current = { x: 0, y: 0 }; - setIsExpanded(false); - }, [block.content]); - - useEffect(() => { - if (showSource) { - setIsExpanded(false); - return; - } - - zoomLevelRef.current = 1; - panOffsetRef.current = { x: 0, y: 0 }; - baseViewBoxRef.current = null; - }, [showSource]); - - useEffect(() => { - if (!isExpanded) return undefined; - - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setIsExpanded(false); - } - }; - - window.addEventListener('keydown', handleKeyDown); - - return () => { - document.body.style.overflow = previousOverflow; - window.removeEventListener('keydown', handleKeyDown); - }; - }, [isExpanded]); - - useEffect(() => { - if (!svg || showSource || !containerRef.current) return; - - const svgEl = containerRef.current.querySelector('svg'); - if (!(svgEl instanceof SVGSVGElement)) return; - - svgEl.style.maxWidth = 'none'; - svgEl.style.width = '100%'; - svgEl.style.height = '100%'; - svgEl.style.display = 'block'; - svgEl.style.filter = 'none'; - svgEl.style.willChange = 'auto'; - svgEl.setAttribute('width', '100%'); - svgEl.setAttribute('height', '100%'); - svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet'); - - let cancelled = false; - - const applyInitialView = () => { - if (cancelled) return; - - try { - const base = naturalBoundsRef.current ?? parseViewBox(svgEl); - - if (!base) return; - - naturalBoundsRef.current = base; - fitToCurrentViewport(); - } catch { - setError('Failed to measure diagram bounds'); - setSvg(''); - } - }; - - const raf = requestAnimationFrame(() => requestAnimationFrame(applyInitialView)); - const timer = window.setTimeout(applyInitialView, 120); - - return () => { - cancelled = true; - cancelAnimationFrame(raf); - window.clearTimeout(timer); - }; - }, [fitToCurrentViewport, isExpanded, showSource, svg]); - - useEffect(() => { - if (showSource || !containerRef.current) return; - - const container = containerRef.current; - const handleWheel = (e: WheelEvent) => { - e.preventDefault(); - const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; - const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomLevelRef.current + delta)); - updateZoom(newZoom); - }; - - container.addEventListener('wheel', handleWheel, { passive: false }); - return () => container.removeEventListener('wheel', handleWheel); - }, [showSource, isExpanded, updateZoom]); - - const handleZoomIn = useCallback(() => { - updateZoom(Math.min(zoomLevelRef.current + ZOOM_STEP, MAX_ZOOM)); - }, [updateZoom]); - - const handleZoomOut = useCallback(() => { - updateZoom(Math.max(zoomLevelRef.current - ZOOM_STEP, MIN_ZOOM)); - }, [updateZoom]); - - const handleFitToScreen = useCallback(() => { - fitToCurrentViewport(); - }, [fitToCurrentViewport]); - - useEffect(() => { - if (showSource || !containerRef.current || !naturalBoundsRef.current) return; - if (typeof ResizeObserver === 'undefined') return; - - const observer = new ResizeObserver(() => { - if (Math.abs(zoomLevelRef.current - 1) > 0.001) return; - fitToCurrentViewport(); - }); - - observer.observe(containerRef.current); - return () => observer.disconnect(); - }, [fitToCurrentViewport, isExpanded, showSource, svg]); - - const handleMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - event.preventDefault(); - isDraggingRef.current = true; - dragStartRef.current = { x: event.clientX, y: event.clientY }; - panStartRef.current = { ...panOffsetRef.current }; - if (containerRef.current) containerRef.current.style.cursor = 'grabbing'; - }, []); - - const handleMouseMove = useCallback((event: React.MouseEvent) => { - if (!isDraggingRef.current || !containerRef.current || !baseViewBoxRef.current) return; - - const svgEl = containerRef.current.querySelector('svg'); - if (!(svgEl instanceof SVGSVGElement)) return; - - const rect = svgEl.getBoundingClientRect(); - const base = baseViewBoxRef.current; - const zoom = zoomLevelRef.current; - const scaleX = (base.width / zoom) / rect.width; - const scaleY = (base.height / zoom) / rect.height; - - const dx = event.clientX - dragStartRef.current.x; - const dy = event.clientY - dragStartRef.current.y; - - panOffsetRef.current = { - x: panStartRef.current.x - dx * scaleX, - y: panStartRef.current.y - dy * scaleY, - }; - - applyView(svgEl, base, zoom, panOffsetRef.current); - }, []); - - const stopDragging = useCallback(() => { - if (!isDraggingRef.current) return; - isDraggingRef.current = false; - if (containerRef.current) containerRef.current.style.cursor = 'grab'; - }, []); - - if (error) { - return ( -
-
- - - - Graphviz Error - {runtimeUnavailable && ( - - )} -
-
{error}
-
-          {block.content}
-        
-
- ); - } - - const controls = ( -
- - - {!showSource && svg && ( - <> -
- - - - - - - -
- -
- ); - - const inlineSource = ( -
-      {block.content}
-    
- ); - - const naturalHeight = naturalBoundsRef.current - ? `min(65vh, ${Math.min(36 * 16, Math.max(4 * 16, Math.round(naturalBoundsRef.current.height * (800 / naturalBoundsRef.current.width))))}px)` - : 'min(65vh, 36rem)'; - - const diagramBody = ( -
- ); - - return ( - <> -
- {!isExpanded && controls} - {showSource || !svg ? inlineSource : !isExpanded ? diagramBody :
} -
- - {!showSource && svg && isExpanded && typeof document !== 'undefined' && createPortal( -
-
-
- Graphviz diagram - -
-
- {controls} - {diagramBody} -
-
-
, - document.body - )} - - ); -}; +export const GraphvizBlock = React.memo( + (props: DiagramBlockProps) => , + (prev, next) => + prev.block.id === next.block.id && + prev.block.content === next.block.content && + prev.block.startLine === next.block.startLine && + prev.annotations === next.annotations && + prev.selectedAnnotationId === next.selectedAnnotationId && + prev.onSelectAnnotation === next.onSelectAnnotation && + prev.onAddAnnotation === next.onAddAnnotation && + prev.readOnly === next.readOnly && + prev.onRestoreReport === next.onRestoreReport, +); diff --git a/packages/ui/components/MermaidBlock.test.ts b/packages/ui/components/MermaidBlock.test.ts index fbb23be6c..f4a81ad3b 100644 --- a/packages/ui/components/MermaidBlock.test.ts +++ b/packages/ui/components/MermaidBlock.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from 'bun:test'; -import { normalizeMermaidSvgMarkup } from './mermaidSvg'; import { MERMAID_CONFIG } from './MermaidBlock'; describe('MERMAID_CONFIG', () => { @@ -11,61 +10,3 @@ describe('MERMAID_CONFIG', () => { expect(MERMAID_CONFIG.startOnLoad).toBe(false); }); }); - -describe('normalizeMermaidSvgMarkup', () => { - test('replaces natural max-width with max-width:none', () => { - const input = ''; - const expected = - ''; - - expect(normalizeMermaidSvgMarkup(input)).toBe(expected); - }); - - test('adds preserveAspectRatio when missing', () => { - const input = ''; - const expected = - ''; - - expect(normalizeMermaidSvgMarkup(input)).toBe(expected); - }); - - test('adds height=100% when missing', () => { - const input = ''; - const expected = - ''; - - expect(normalizeMermaidSvgMarkup(input)).toBe(expected); - }); - - test('preserves existing preserveAspectRatio and height', () => { - const input = ''; - const expected = - ''; - - expect(normalizeMermaidSvgMarkup(input)).toBe(expected); - }); - - test('injects style attribute when mermaid omits one', () => { - const input = ''; - const expected = - ''; - - expect(normalizeMermaidSvgMarkup(input)).toBe(expected); - }); - - test('only normalizes the root tag', () => { - const input = - ''; - const expected = - ''; - - expect(normalizeMermaidSvgMarkup(input)).toBe(expected); - }); - - test('leaves non-svg input unchanged', () => { - const input = 'plain text'; - const expected = 'plain text'; - - expect(normalizeMermaidSvgMarkup(input)).toBe(expected); - }); -}); diff --git a/packages/ui/components/MermaidBlock.theme.test.tsx b/packages/ui/components/MermaidBlock.theme.test.tsx index fa898ca05..42f83bad9 100644 --- a/packages/ui/components/MermaidBlock.theme.test.tsx +++ b/packages/ui/components/MermaidBlock.theme.test.tsx @@ -18,10 +18,11 @@ * * DOM-gated (DOM_TESTS=1). */ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import type { Block } from '../types'; +import { installInertDiagramSvgParser } from '../test-setup/diagramSvg'; import { MermaidBlock, __setMermaidRuntimeLoaderForTests } from './MermaidBlock'; import { ThemeProvider, useTheme } from './ThemeProvider'; import { __resetMermaidThemeForTests } from '../utils/mermaidTheme'; @@ -29,6 +30,14 @@ import { resetStorageBackend, setStorageBackend } from '../utils/storage'; const hasDom = typeof document !== 'undefined'; +// happy-dom cannot host DOMPurify: the render slot's parse step is the inert +// template parse for these tests (the scrub still runs). +let restoreParser: (() => void) | null = null; +beforeAll(() => { + if (hasDom) restoreParser = installInertDiagramSvgParser(); +}); +afterAll(() => restoreParser?.()); + const block: Block = { id: 'themeSweep', type: 'code', language: 'mermaid', content: 'flowchart LR\n A --> B', order: 0, startLine: 1 }; const SVG = ''; diff --git a/packages/ui/components/MermaidBlock.tsx b/packages/ui/components/MermaidBlock.tsx index 9e606e882..343e56d29 100644 --- a/packages/ui/components/MermaidBlock.tsx +++ b/packages/ui/components/MermaidBlock.tsx @@ -1,674 +1,27 @@ -import React, { useRef, useState, useEffect, useCallback } from 'react'; -import { createPortal } from 'react-dom'; -import type { Mermaid } from 'mermaid'; -import type { Block } from '../types'; -import { normalizeMermaidSvgMarkup } from './mermaidSvg'; -import { - MERMAID_CONFIG, - getMermaidRetryDelayMs, - loadMermaidRuntime, - __setMermaidRuntimeLoaderForTests, -} from '../utils/mermaid'; -import { loadMathRenderer } from '../utils/math'; -import { hasMermaidMath } from '../utils/mermaid-math-slot'; -import { applyMermaidTheme, mermaidThemeKey } from '../utils/mermaidTheme'; -import { createRuntimeRetryEpoch } from '../utils/runtimeRetry'; -import { useTheme } from './ThemeProvider'; - -/** One Retry re-attempts every block whose runtime import failed (see utils/runtimeRetry). */ -const mermaidRetryEpoch = createRuntimeRetryEpoch(); +import React from 'react'; +import { MERMAID_CONFIG, __setMermaidRuntimeLoaderForTests } from '../utils/mermaid'; +import { DiagramBlock, type DiagramBlockProps } from './DiagramBlock'; // Re-exported: the config pin test and the lazy-retry test import them from here. export { MERMAID_CONFIG, __setMermaidRuntimeLoaderForTests }; /** - * The runtime comes from the slot in utils/mermaid: loaded lazily on the - * first diagram (Plannotator's own path since Mermaid 12), or already filled - * by a host that imported utils/mermaid-eager. See that module for the retry - * contract. Until the first render lands the block shows the source fence - * under a "Rendering diagram" status; the error panel appears only for a - * failure, never as a placeholder. - */ -const getMermaid = loadMermaidRuntime; - -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -interface ViewBox { - x: number; - y: number; - width: number; - height: number; -} - -const ZOOM_STEP = 0.25; -const MIN_ZOOM = 0.25; -const MAX_ZOOM = 8; - -function parseViewBox(svgEl: SVGSVGElement): ViewBox | null { - const raw = svgEl.getAttribute('viewBox'); - if (!raw) return null; - - const values = raw - .trim() - .split(/[\s,]+/) - .map((value) => Number.parseFloat(value)); - - if (values.length !== 4 || values.some((value) => Number.isNaN(value))) { - return null; - } - - const [x, y, width, height] = values; - if (width <= 0 || height <= 0) return null; - return { x, y, width, height }; -} - -// Parse base viewBox from Mermaid SVG markup before DOM mount -function parseViewBoxFromMarkup(markup: string): ViewBox | null { - const viewBoxMatch = markup.match(/viewBox\s*=\s*"([^"]+)"/i); - if (viewBoxMatch?.[1]) { - const values = viewBoxMatch[1] - .trim() - .split(/[\s,]+/) - .map((value) => Number.parseFloat(value)); - - if (values.length === 4 && values.every((value) => Number.isFinite(value))) { - const [x, y, width, height] = values; - if (width > 0 && height > 0) { - return { x, y, width, height }; - } - } - } - - const widthMatch = markup.match(/\bwidth\s*=\s*"([0-9.]+)(?:px)?"/i); - const heightMatch = markup.match(/\bheight\s*=\s*"([0-9.]+)(?:px)?"/i); - const width = widthMatch?.[1] ? Number.parseFloat(widthMatch[1]) : NaN; - const height = heightMatch?.[1] ? Number.parseFloat(heightMatch[1]) : NaN; - if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { - return { x: 0, y: 0, width, height }; - } - - return null; -} - -// Apply calculated viewBox from zoom and pan state -function applyView(svgEl: SVGSVGElement, base: ViewBox, zoom: number, pan: { x: number; y: number }): void { - const zoomedWidth = base.width / zoom; - const zoomedHeight = base.height / zoom; - const centerX = base.x + base.width / 2; - const centerY = base.y + base.height / 2; - const vbX = centerX - zoomedWidth / 2 + pan.x; - const vbY = centerY - zoomedHeight / 2 + pan.y; - svgEl.setAttribute('viewBox', `${vbX} ${vbY} ${zoomedWidth} ${zoomedHeight}`); -} - -// Compute a fitted base viewBox for the current container ratio -function fitBoundsToContainer(bounds: ViewBox, containerRect: DOMRect): ViewBox { - const containerWidth = Math.max(containerRect.width, 1); - const containerHeight = Math.max(containerRect.height, 1); - const contentRatio = bounds.width / bounds.height; - const containerRatio = containerWidth / containerHeight; - - if (contentRatio > containerRatio) { - const targetHeight = bounds.width / containerRatio; - const extra = (targetHeight - bounds.height) / 2; - return { - x: bounds.x, - y: bounds.y - extra, - width: bounds.width, - height: targetHeight, - }; - } - - const targetWidth = bounds.height * containerRatio; - const extra = (targetWidth - bounds.width) / 2; - return { - x: bounds.x - extra, - y: bounds.y, - width: targetWidth, - height: bounds.height, - }; -} - -/** - * Renders a mermaid diagram block with zoom controls. + * A mermaid fence. The engine, the canvas, the comment overlay and the + * popout are `DiagramBlock`'s (one code path with `GraphvizBlock`); this + * file keeps the fence's name and the test hooks. The runtime comes from the + * slot in `utils/mermaid`: loaded lazily on the first diagram, or already + * filled by a host that imported `utils/mermaid-eager`. */ -const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => { - const containerRef = useRef(null); - const expandedOverlayRef = useRef(null); - const [svg, setSvg] = useState(''); - const [error, setError] = useState(null); - // True when the failure was the runtime import itself (a chunking host's - // fetch), which is the only failure a Retry can change; a diagram syntax - // error keeps the panel exactly as it always was. - const [runtimeUnavailable, setRuntimeUnavailable] = useState(false); - const [retryToken, setRetryToken] = useState(0); - const [showSource, setShowSource] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - // The (palette, mode) the diagram must follow: the same resolution the - // code fences use (see useFenceTheme). Outside a ThemeProvider the default - // context yields the Plannotator dark pair, and with no theme tokens on the - // document `applyMermaidTheme` keeps the static config, so a host without - // the provider renders exactly as before. A key change re-runs the render - // effect below, which is what re-themes an already rendered diagram. - const { colorTheme, resolvedMode } = useTheme(); - const themeKey = mermaidThemeKey(colorTheme, resolvedMode === 'light' ? 'light' : 'dark'); - // A sibling's Retry re-attempts this block too, but only while its own - // failure was the shared runtime import; a healthy block or a diagram - // syntax error is left alone. - const runtimeUnavailableRef = useRef(runtimeUnavailable); - runtimeUnavailableRef.current = runtimeUnavailable; - useEffect(() => mermaidRetryEpoch.subscribe(() => { - if (!runtimeUnavailableRef.current) return; - setError(null); - setRetryToken((token) => token + 1); - }), []); - - // All zoom/pan state as refs to avoid re-renders - const zoomLevelRef = useRef(1); - const isDraggingRef = useRef(false); - const naturalBoundsRef = useRef(null); - const baseViewBoxRef = useRef(null); - const panOffsetRef = useRef({ x: 0, y: 0 }); - const dragStartRef = useRef({ x: 0, y: 0 }); - const panStartRef = useRef({ x: 0, y: 0 }); - - // UI refs for zoom controls - const zoomInBtnRef = useRef(null); - const zoomOutBtnRef = useRef(null); - const zoomDisplayRef = useRef(null); - - // Update zoom level, viewBox, and UI without React re-render - const updateZoom = useCallback((newZoom: number) => { - zoomLevelRef.current = newZoom; - - if (containerRef.current && baseViewBoxRef.current) { - const svgEl = containerRef.current.querySelector('svg'); - if (svgEl instanceof SVGSVGElement) { - applyView(svgEl, baseViewBoxRef.current, newZoom, panOffsetRef.current); - } - } - - if (zoomInBtnRef.current) zoomInBtnRef.current.disabled = newZoom >= MAX_ZOOM; - if (zoomOutBtnRef.current) zoomOutBtnRef.current.disabled = newZoom <= MIN_ZOOM; - if (zoomDisplayRef.current) { - const show = Math.abs(newZoom - 1) > 0.001; - zoomDisplayRef.current.textContent = show ? `${Math.round(newZoom * 100)}%` : ''; - zoomDisplayRef.current.hidden = !show; - } - }, []); - - const fitToCurrentViewport = useCallback(() => { - if (!containerRef.current || !naturalBoundsRef.current) return; - - const svgEl = containerRef.current.querySelector('svg'); - if (!(svgEl instanceof SVGSVGElement)) return; - - const fitted = fitBoundsToContainer(naturalBoundsRef.current, containerRef.current.getBoundingClientRect()); - baseViewBoxRef.current = fitted; - panOffsetRef.current = { x: 0, y: 0 }; - updateZoom(1); - applyView(svgEl, fitted, 1, { x: 0, y: 0 }); - }, [updateZoom]); - - useEffect(() => { - let cancelled = false; - - // Render mermaid diagram - const renderDiagram = async () => { - let mermaid: Mermaid; - try { - try { - mermaid = await getMermaid(); - } catch { - // Transient chunk failure on a chunking host: one automatic - // re-attempt with a fresh import() after a short delay. In a - // single-file build the first await never rejects, so this branch - // is unreachable there and the success path is unchanged. - await wait(getMermaidRetryDelayMs()); - if (cancelled) return; - mermaid = await getMermaid(); - } - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to render diagram'); - setRuntimeUnavailable(true); - setSvg(''); - } - return; - } - try { - // A `$$` label makes Mermaid render KaTeX. On a host that redirects - // Mermaid's `katex` import to `utils/mermaid-math-slot` the label is - // typeset through the math slot, which must be filled by then: warm - // it with the registered loader first. A filled slot (Plannotator's - // eager entry) resolves at once; a load failure is left to the - // render, whose error panel names it with the source. - if (hasMermaidMath(block.content)) { - try { - await loadMathRenderer(); - } catch { - // Reported by the render below. - } - if (cancelled) return; - } - // Global initialize, once per (palette, mode) change, before render. - applyMermaidTheme(mermaid, themeKey); - const id = `mermaid-${block.id}`; - const { svg: renderedSvg } = await mermaid.render(id, block.content); - if (!cancelled) { - const normalizedSvg = normalizeMermaidSvgMarkup(renderedSvg); - naturalBoundsRef.current = parseViewBoxFromMarkup(normalizedSvg); - setSvg(normalizedSvg); - setError(null); - setRuntimeUnavailable(false); - } - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : 'Failed to render diagram'); - setRuntimeUnavailable(false); - setSvg(''); - } - } - }; - - renderDiagram(); - - return () => { - cancelled = true; - }; - }, [block.content, block.id, retryToken, themeKey]); - - // Reset zoom and pan when content changes - useEffect(() => { - zoomLevelRef.current = 1; - naturalBoundsRef.current = null; - baseViewBoxRef.current = null; - panOffsetRef.current = { x: 0, y: 0 }; - setIsExpanded(false); - }, [block.content]); - - // Reset zoom and pan when switching from source back to diagram - useEffect(() => { - if (showSource) { - setIsExpanded(false); - return; - } - - zoomLevelRef.current = 1; - panOffsetRef.current = { x: 0, y: 0 }; - baseViewBoxRef.current = null; - }, [showSource]); - - useEffect(() => { - if (!isExpanded) return undefined; - - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setIsExpanded(false); - } - }; - - window.addEventListener('keydown', handleKeyDown); - - return () => { - document.body.style.overflow = previousOverflow; - window.removeEventListener('keydown', handleKeyDown); - }; - }, [isExpanded]); - - // Compute base viewBox from rendered SVG and apply initial view - useEffect(() => { - if (!svg || showSource || !containerRef.current) return; - - const svgEl = containerRef.current.querySelector('svg'); - if (!(svgEl instanceof SVGSVGElement)) return; - - svgEl.style.maxWidth = 'none'; - svgEl.style.width = '100%'; - svgEl.style.height = '100%'; - svgEl.style.display = 'block'; - svgEl.style.filter = 'none'; - svgEl.style.willChange = 'auto'; - svgEl.setAttribute('width', '100%'); - svgEl.setAttribute('height', '100%'); - svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet'); - - let cancelled = false; - - const applyInitialView = () => { - if (cancelled) return; - - try { - const base = naturalBoundsRef.current ?? parseViewBox(svgEl); - - if (!base) return; - - naturalBoundsRef.current = base; - fitToCurrentViewport(); - } catch { - setError('Failed to measure diagram bounds'); - setSvg(''); - } - }; - - const raf = requestAnimationFrame(() => requestAnimationFrame(applyInitialView)); - const timer = window.setTimeout(applyInitialView, 120); - - return () => { - cancelled = true; - cancelAnimationFrame(raf); - window.clearTimeout(timer); - }; - }, [fitToCurrentViewport, isExpanded, showSource, svg]); - - const applyWheelZoomDelta = useCallback((deltaY: number) => { - if (Math.abs(deltaY) < 0.1) { - return; - } - - const delta = deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; - const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomLevelRef.current + delta)); - updateZoom(newZoom); - }, [updateZoom]); - - useEffect(() => { - if (showSource || !containerRef.current) return; - - const container = containerRef.current; - const handleWheel = (event: WheelEvent) => { - if (Math.abs(event.deltaY) < 0.1) return; - event.preventDefault(); - applyWheelZoomDelta(event.deltaY); - }; - - container.addEventListener('wheel', handleWheel, { passive: false }); - - return () => { - container.removeEventListener('wheel', handleWheel); - }; - }, [applyWheelZoomDelta, showSource]); - - useEffect(() => { - if (showSource || !isExpanded) return; - - const handleExpandedPinchWheel = (event: WheelEvent) => { - if (!event.ctrlKey && !event.metaKey) return; - if (!expandedOverlayRef.current) return; - - const eventTarget = event.target; - if (!(eventTarget instanceof Node) || !expandedOverlayRef.current.contains(eventTarget)) return; - - event.preventDefault(); - event.stopPropagation(); - applyWheelZoomDelta(event.deltaY); - }; - - window.addEventListener('wheel', handleExpandedPinchWheel, { passive: false, capture: true }); - - return () => { - window.removeEventListener('wheel', handleExpandedPinchWheel, { capture: true }); - }; - }, [applyWheelZoomDelta, isExpanded, showSource]); - - const handleZoomIn = useCallback(() => { - updateZoom(Math.min(zoomLevelRef.current + ZOOM_STEP, MAX_ZOOM)); - }, [updateZoom]); - - const handleZoomOut = useCallback(() => { - updateZoom(Math.max(zoomLevelRef.current - ZOOM_STEP, MIN_ZOOM)); - }, [updateZoom]); - - const handleFitToScreen = useCallback(() => { - fitToCurrentViewport(); - }, [fitToCurrentViewport]); - - useEffect(() => { - if (showSource || !containerRef.current || !naturalBoundsRef.current) return; - if (typeof ResizeObserver === 'undefined') return; - - const observer = new ResizeObserver(() => { - if (Math.abs(zoomLevelRef.current - 1) > 0.001) return; - fitToCurrentViewport(); - }); - - observer.observe(containerRef.current); - return () => observer.disconnect(); - }, [fitToCurrentViewport, isExpanded, showSource, svg]); - - // Drag-to-pan handlers (all ref-based to avoid re-renders) - const handleMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - event.preventDefault(); - isDraggingRef.current = true; - dragStartRef.current = { x: event.clientX, y: event.clientY }; - panStartRef.current = { ...panOffsetRef.current }; - if (containerRef.current) containerRef.current.style.cursor = 'grabbing'; - }, []); - - const handleMouseMove = useCallback((event: React.MouseEvent) => { - if (!isDraggingRef.current || !containerRef.current || !baseViewBoxRef.current) return; - - const svgEl = containerRef.current.querySelector('svg'); - if (!(svgEl instanceof SVGSVGElement)) return; - - const rect = svgEl.getBoundingClientRect(); - const base = baseViewBoxRef.current; - const zoom = zoomLevelRef.current; - const scaleX = (base.width / zoom) / rect.width; - const scaleY = (base.height / zoom) / rect.height; - - const dx = event.clientX - dragStartRef.current.x; - const dy = event.clientY - dragStartRef.current.y; - - panOffsetRef.current = { - x: panStartRef.current.x - dx * scaleX, - y: panStartRef.current.y - dy * scaleY, - }; - - applyView(svgEl, base, zoom, panOffsetRef.current); - }, []); - - const stopDragging = useCallback(() => { - if (!isDraggingRef.current) return; - isDraggingRef.current = false; - if (containerRef.current) containerRef.current.style.cursor = 'grab'; - }, []); - - if (error) { - return ( -
-
- - - - Mermaid Error - {runtimeUnavailable && ( - - )} -
-
{error}
-
-          {block.content}
-        
-
- ); - } - - const controls = ( - /* Controls container */ -
- {/* Toggle source/diagram button */} - - - {!showSource && svg && ( - <> - {/* Diagram interaction controls */} -
- {/* Expand/exit expanded button */} - - - {/* Zoom in button */} - - - {/* Fit to view button */} - - - {/* Zoom out button */} - -
- - {/* Zoom percentage badge */} -
- ); - - const inlineSource = ( -
-      {block.content}
-    
- ); - - // First render still in flight (the runtime import on the lazy path, then - // the render itself): the source stays readable under a quiet status line. - // A re-render for a theme change keeps the previous SVG, so this shows only - // before the first diagram lands. - const pendingSource = ( - <> -
-
- {inlineSource} - - ); - - const diagramBody = ( -
- ); - - return ( - <> -
- {!isExpanded && controls} - {showSource ? inlineSource : !svg ? pendingSource : !isExpanded ? diagramBody :
} -
- - {!showSource && svg && isExpanded && typeof document !== 'undefined' && createPortal( -
-
-
- Mermaid diagram - -
-
- {controls} - {diagramBody} -
-
-
, - document.body - )} - - ); -}; - export const MermaidBlock = React.memo( - MermaidBlockImpl, + (props: DiagramBlockProps) => , (prev, next) => prev.block.id === next.block.id && - prev.block.content === next.block.content, + prev.block.content === next.block.content && + prev.block.startLine === next.block.startLine && + prev.annotations === next.annotations && + prev.selectedAnnotationId === next.selectedAnnotationId && + prev.onSelectAnnotation === next.onSelectAnnotation && + prev.onAddAnnotation === next.onAddAnnotation && + prev.readOnly === next.readOnly && + prev.onRestoreReport === next.onRestoreReport, ); diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index b094f0466..380aad625 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -1147,9 +1147,27 @@ export const Viewer = forwardRef(({ ); })() ) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? ( - + ) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? ( - + ) : group.block.type === 'table' ? ( ` that the + * render slot's sanitizer strips, so the canvas owns every click and there + * is no armed switch. + * + * The render slot hands over a sanitized svg NODE, not markup: the wrapper + * mounts it with `replaceChildren` once per render, so no html string ever + * crosses into the app DOM here. The overlay (a sibling of the wrapper, + * unscaled) is the caller's, rendered through `overlay` with the live + * viewport so rings reproject on every change. + */ + +/** A numeric svg length: `206`, `206pt`, `206px`. */ +function svgLength(value: string | null): number { + if (value === null) return Number.NaN; + return Number.parseFloat(value); +} + +/** The svg's intrinsic size, from its viewBox (Mermaid and Graphviz always + * write one), else its `width` / `height` attributes (a `pt` or `px` suffix + * is accepted, as Graphviz writes them). */ +export function svgContentSize(svg: SVGSVGElement): ContentSize | null { + const viewBox = svg.getAttribute('viewBox'); + if (viewBox !== null) { + const parts = viewBox + .trim() + .split(/[\s,]+/u) + .map(Number); + const width = parts[2]; + const height = parts[3]; + if ( + parts.length === 4 && + width !== undefined && + height !== undefined && + Number.isFinite(width) && + Number.isFinite(height) && + width > 0 && + height > 0 + ) { + return { width, height }; + } + } + const width = svgLength(svg.getAttribute('width')); + const height = svgLength(svg.getAttribute('height')); + return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ? { width, height } : null; +} + +/** One arrow-key press pans this far (the diagram moves WITH the arrow, as + * a scroll would); Shift multiplies it by five. */ +export const KEY_PAN_PX = 40; + +export interface DiagramCanvasHandle { + readonly viewport: Viewport; + readonly hostRef: React.RefObject; + readonly panIntoView: ReturnType['panIntoView']; +} + +/** What an Escape on the canvas did: `consumed` closed something the viewer + * owns (a draft, a selection) and the key goes no further; `pass` lets it + * reach whatever holds the canvas (a popout's close). */ +export type DiagramEscapeOutcome = 'consumed' | 'pass'; + +export function DiagramCanvas({ + svgNode, + targetSelector, + dimmed, + onSvgRoot, + onHoverElement, + onClickElement, + onEscape, + overlay, + children, + autoFocus, + className, +}: { + /** The last good render's sanitized svg root, or null before the first. */ + svgNode: SVGSVGElement | null; + /** The engine's selector of every element the pointer can address + * (the finder's, through the renderer slot). */ + targetSelector: string; + /** A parse error keeps the last render under the strip, dimmed. */ + dimmed: boolean; + /** The mounted svg root after each injection (null on unmount). */ + onSvgRoot: (root: SVGSVGElement | null) => void; + onHoverElement: (element: Element | null) => void; + onClickElement: (element: Element | null, shiftKey: boolean) => void; + onEscape: () => DiagramEscapeOutcome; + /** The overlay layer, given the live viewport and host. */ + overlay: (handle: DiagramCanvasHandle) => ReactNode; + children?: ReactNode; + /** Take the keyboard on mount so `+`, `-`, `0` and Escape work at once + * (a popout). Off in the document flow, where it would steal the focus + * from the reader. */ + autoFocus?: boolean; + className?: string; +}) { + const hostRef = useRef(null); + const wrapperRef = useRef(null); + const [content, setContent] = useState(null); + const { viewport, fit, zoomBy, panBy, panIntoView } = useDiagramViewport(hostRef, content); + + // Mount the sanitized node once per render and size the wrapper to the + // diagram's own box so the transform scales real pixels. + useLayoutEffect(() => { + const wrapper = wrapperRef.current; + if (wrapper === null) return; + if (svgNode === null) { + wrapper.replaceChildren(); + setContent(null); + onSvgRoot(null); + return; + } + wrapper.replaceChildren(svgNode); + const size = svgContentSize(svgNode); + if (size !== null) { + svgNode.style.width = '100%'; + svgNode.style.height = '100%'; + svgNode.style.maxWidth = 'none'; + } + setContent(size); + onSvgRoot(svgNode); + return () => onSvgRoot(null); + }, [onSvgRoot, svgNode]); + + useEffect(() => { + if (autoFocus) hostRef.current?.focus(); + }, [autoFocus]); + + // Wheel zoom needs a non-passive listener (React's onWheel is passive, + // so preventDefault there cannot stop the page from scrolling). + useEffect(() => { + const host = hostRef.current; + if (host === null) return; + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + zoomBy(Math.exp(-event.deltaY * WHEEL_ZOOM_SENSITIVITY), event.clientX, event.clientY); + }; + host.addEventListener('wheel', onWheel, { passive: false }); + return () => host.removeEventListener('wheel', onWheel); + }, [zoomBy]); + + const targetUnder = useCallback( + (event: ReactPointerEvent): Element | null => { + const host = hostRef.current; + const target = event.target; + if (host === null || !(target instanceof Element)) return null; + const wrapper = wrapperRef.current; + if (wrapper === null || !wrapper.contains(target)) return null; + // A pointer on an edge's widened hit path means the visible edge + // right before it (the render slot inserts the clone as its next + // sibling), so the finder sees the element that carries the id. + const hit = target.closest(`[${DIAGRAM_HIT_ATTR}]`); + const node: Element = hit !== null && hit.previousElementSibling !== null ? hit.previousElementSibling : target; + return node.closest(targetSelector); + }, + [targetSelector], + ); + + // Press bookkeeping: where the pointer went down and whether it became a + // pan. Pointer capture keeps the pan alive past the host's edge. + const pressRef = useRef<{ + id: number; + x: number; + y: number; + lastX: number; + lastY: number; + panning: boolean; + } | null>(null); + const [panning, setPanning] = useState(false); + + const onPointerDown = useCallback((event: ReactPointerEvent) => { + if (event.button !== 0) return; + pressRef.current = { + id: event.pointerId, + x: event.clientX, + y: event.clientY, + lastX: event.clientX, + lastY: event.clientY, + panning: false, + }; + }, []); + + const onPointerMove = useCallback( + (event: ReactPointerEvent) => { + const press = pressRef.current; + if (press === null || press.id !== event.pointerId) { + // Nothing highlights on a plain mouse-over (owner feedback: a hover + // ring reads as messy and fights the pan hand). The pre-click + // affordance exists only under the platform modifier — Cmd on + // macOS, Ctrl elsewhere — the same held-key gesture the code + // review's token cards use. + onHoverElement(isModKeyHeld(event) ? targetUnder(event) : null); + return; + } + if (!press.panning) { + const travelled = Math.hypot(event.clientX - press.x, event.clientY - press.y); + if (travelled < DRAG_THRESHOLD_PX) return; + press.panning = true; + setPanning(true); + onHoverElement(null); + if (typeof event.currentTarget.setPointerCapture === 'function') { + event.currentTarget.setPointerCapture(event.pointerId); + } + } + panBy(event.clientX - press.lastX, event.clientY - press.lastY); + press.lastX = event.clientX; + press.lastY = event.clientY; + }, + [onHoverElement, panBy, targetUnder], + ); + + const endPress = useCallback( + (event: ReactPointerEvent, click: boolean) => { + const press = pressRef.current; + if (press === null || press.id !== event.pointerId) return; + pressRef.current = null; + if (press.panning) { + setPanning(false); + const target = event.currentTarget; + if (typeof target.hasPointerCapture === 'function' && target.hasPointerCapture(event.pointerId)) { + target.releasePointerCapture(event.pointerId); + } + return; + } + if (click) onClickElement(targetUnder(event), event.shiftKey); + }, + [onClickElement, targetUnder], + ); + + const onKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + // Keys act on the canvas itself, never on the composer's textarea. + if (event.target !== event.currentTarget) return; + switch (event.key) { + case '+': + case '=': + zoomBy(ZOOM_STEP); + break; + case '-': + case '_': + zoomBy(1 / ZOOM_STEP); + break; + case '0': + fit(); + break; + case 'ArrowLeft': + panBy(event.shiftKey ? KEY_PAN_PX * 5 : KEY_PAN_PX, 0); + break; + case 'ArrowRight': + panBy(event.shiftKey ? -KEY_PAN_PX * 5 : -KEY_PAN_PX, 0); + break; + case 'ArrowUp': + panBy(0, event.shiftKey ? KEY_PAN_PX * 5 : KEY_PAN_PX); + break; + case 'ArrowDown': + panBy(0, event.shiftKey ? -KEY_PAN_PX * 5 : -KEY_PAN_PX); + break; + case 'Escape': { + if (onEscape() !== 'consumed') return; + event.stopPropagation(); + break; + } + default: + return; + } + event.preventDefault(); + }, + [fit, onEscape, panBy, zoomBy], + ); + + const handle = useMemo(() => ({ viewport, hostRef, panIntoView }), [panIntoView, viewport]); + + return ( +
endPress(event, true)} + onPointerCancel={(event) => endPress(event, false)} + onPointerLeave={() => onHoverElement(null)} + onKeyDown={onKeyDown} + > +
+ {overlay(handle)} + {children} +
+ + + +
+
+ ); +} diff --git a/packages/ui/components/diagram/DiagramComposer.tsx b/packages/ui/components/diagram/DiagramComposer.tsx new file mode 100644 index 000000000..75fef2b61 --- /dev/null +++ b/packages/ui/components/diagram/DiagramComposer.tsx @@ -0,0 +1,135 @@ +import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; +import { diagramTargetName, diagramTargetText } from '@plannotator/core/diagram-anchor'; +import { cn } from '../../lib/utils'; +import type { ScreenRect } from '../../utils/diagram-projection'; +import { Button } from '../ui/button'; +import type { DiagramComposerDraft } from './useDiagramComments'; + +/** + * The inline composer beside the ring: the part's label as the context + * line, a textarea, Cancel and Comment. Enter saves, Shift+Enter breaks a + * line and Esc discards, and none of the three is spelled on screen (owner + * ruling; the markdown composer shows no hint either). It lives here + * because the markdown composer is bound to web-highlighter's selection and + * the html composer to the sandbox bridge; neither reaches an app-owned svg. + */ + +/** The composer's width; it sits to the right of the ring and flips left + * when the host is too narrow there. */ +const COMPOSER_WIDTH_PX = 288; + +export function DiagramComposer({ + draft, + anchorRect, + hostWidth, + sourceDirty, + submitting, + error, + disabledReason, + onSubmit, + onCancel, +}: { + draft: DiagramComposerDraft; + anchorRect: ScreenRect; + hostWidth: number; + sourceDirty: boolean; + submitting: boolean; + /** The last submit's failure, shown under the textarea. */ + error: string | null; + /** When set, commenting is off for this viewer and the composer says + * why instead of offering a textarea. */ + disabledReason?: string; + onSubmit: (body: string) => void; + onCancel: () => void; +}) { + const [text, setText] = useState(''); + const textareaRef = useRef(null); + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + const target = draft.primary.target; + const unsavedPart = draft.sourceLine === null && sourceDirty; + const canWrite = disabledReason === undefined && !unsavedPart; + const rightFits = anchorRect.left + anchorRect.width + 12 + COMPOSER_WIDTH_PX <= hostWidth; + const left = rightFits ? anchorRect.left + anchorRect.width + 12 : Math.max(0, anchorRect.left - COMPOSER_WIDTH_PX - 12); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + // Ours: the canvas and whatever holds it (a popout) must not also + // act on this Escape. + event.preventDefault(); + event.stopPropagation(); + onCancel(); + return; + } + if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault(); + if (canWrite && text.trim() !== '') onSubmit(text); + } + }; + + return ( +
event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + onKeyDown={(event) => { + // Keys inside the composer never reach the canvas's zoom keys. + event.stopPropagation(); + }} + > +
+ On {diagramTargetText(target)} ({diagramTargetName(target)}) + {draft.sourceLine !== null && ` · line ${draft.sourceLine[0]}`} + {draft.additional.length > 0 && ` · +${draft.additional.length} more`} +
+ {disabledReason === undefined ? ( + <> + {unsavedPart && ( +

This part is only in your unsaved draft. Save the diagram to comment on it.

+ )} +