@plannotator/ui 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/types.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import type { DiagramAnchor } from '@plannotator/core/diagram-anchor';
2
+
3
+ export type { DiagramAnchor } from '@plannotator/core/diagram-anchor';
4
+
1
5
  export enum AnnotationType {
2
6
  DELETION = 'DELETION',
3
7
  COMMENT = 'COMMENT',
@@ -84,6 +88,7 @@ export interface Annotation {
84
88
  htmlAnchor?: HtmlElementAnchor; // raw-HTML pinpoint: serialized element anchor for reliable restoration
85
89
  elementContext?: HtmlElementContext; // raw-HTML / live-app pinpoint: bounded agent-facing description of the primary element (never used by restore)
86
90
  htmlAdditionalTargets?: HtmlAnnotationTarget[]; // raw-HTML shift-click multi-select: extra elements this one comment covers (primary stays htmlAnchor/originalText)
91
+ diagramAnchor?: DiagramAnchor; // a comment on a rendered diagram part (Mermaid / Graphviz fence): the part's own id, label and document source line; the highlighter skips it and the diagram overlay restores it (see @plannotator/core/diagram-anchor)
87
92
  // web-highlighter metadata for cross-element selections
88
93
  startMeta?: AnnotationTextMeta;
89
94
  endMeta?: AnnotationTextMeta;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The Graphviz finder: the ONE function that owns the id grammar of the
3
+ * svg `@viz-js/viz` emits, beside the Mermaid codec in `diagram-anchor.ts`.
4
+ * The anchor it writes is the same `DiagramAnchor` shape, family `graphviz`:
5
+ *
6
+ * { family: "graphviz", kind: "node", id: "Validate", label: "Validate" }
7
+ * { family: "graphviz", kind: "edge", from: "Order", to: "Validate", label: "" }
8
+ * { family: "graphviz", kind: "cluster", id: "cluster_0", label: "group" }
9
+ *
10
+ * Graphviz writes every part as `<g id="nodeN" class="node"><title>NAME
11
+ * </title>...`, `<g id="edgeN" class="edge"><title>A-&gt;B</title>` and
12
+ * `<g id="clustN" class="cluster"><title>cluster_0</title>`. The `nodeN`,
13
+ * `edgeN` and `clustN` ids are declaration-order counters and shift when a
14
+ * part is inserted mid-source (inserting `A -> Z` before `B -> C` moves C
15
+ * from node3 to node4), so the anchor is the DOT NAME from the group's
16
+ * `<title>`, never the id. Edges carry both ends in the title (`A->B` in a
17
+ * digraph, `A--B` in a graph); a port stays on its end (`A:p`) exactly as
18
+ * the title spells it. Restore order is the codec's: (1) the part whose
19
+ * name matches, (2) a node whose label text equals the stored label, (3)
20
+ * the source line as a gutter mark (the pane's job), (4) none: unanchored
21
+ * but listed.
22
+ *
23
+ * Pure: no React, no DOM globals beyond the Element the caller hands in.
24
+ */
25
+ import { diagramWholeSourceLines, lineMentions, sameTarget, type DiagramTarget } from '@plannotator/core/diagram-anchor';
26
+ import type { DiagramFinder } from './diagram-anchor';
27
+
28
+ /** The selector of every element the pointer can address in a Graphviz svg. */
29
+ export const GRAPHVIZ_TARGET_SELECTOR = 'g.node, g.edge, g.cluster';
30
+
31
+ /** The group's own `<title>` (a direct child; nested groups carry their
32
+ * own), which is the DOT name of the part. Entities (`&#45;&gt;`) come back
33
+ * decoded by the parser. */
34
+ function titleOf(el: Element): string | null {
35
+ for (const child of Array.from(el.children)) {
36
+ if (child.tagName.toLowerCase() === 'title') {
37
+ return (child.textContent ?? '').trim();
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** The text the part shows: every `<text>` under it, in order. A node
44
+ * with no label attribute shows its name; a record label shows its fields. */
45
+ function labelOf(el: Element): string {
46
+ return Array.from(el.querySelectorAll('text'))
47
+ .map((text) => text.textContent ?? '')
48
+ .join(' ')
49
+ .replace(/\s+/gu, ' ')
50
+ .trim();
51
+ }
52
+
53
+ /** An edge title is `tail->head` (digraph) or `tail--head` (graph). Split
54
+ * at the FIRST operator: an unquoted DOT id never contains one, and a
55
+ * quoted name that does is not worth a grammar of its own. */
56
+ function splitEdgeTitle(title: string): { from: string; to: string } | null {
57
+ const m = /^(.*?)(?:->|--)(.*)$/u.exec(title);
58
+ if (m === null) return null;
59
+ const from = (m[1] ?? '').trim();
60
+ const to = (m[2] ?? '').trim();
61
+ if (from === '' || to === '') return null;
62
+ return { from, to };
63
+ }
64
+
65
+ /**
66
+ * Describe the rendered element under the pointer as a target, or null when
67
+ * it is not one of the three groups (the graph root, the background, a
68
+ * label group). `renderId` is unused: Graphviz ids carry no prefix and the
69
+ * finder never reads them.
70
+ */
71
+ export function graphvizTargetFromElement(_svg: Element, el: Element): DiagramTarget | null {
72
+ const title = titleOf(el);
73
+ if (title === null || title === '') return null;
74
+ if (el.classList.contains('node')) {
75
+ return { family: 'graphviz', kind: 'node', id: title, label: labelOf(el) };
76
+ }
77
+ if (el.classList.contains('edge')) {
78
+ const ends = splitEdgeTitle(title);
79
+ if (ends === null) return null;
80
+ return { family: 'graphviz', kind: 'edge', ...ends, label: labelOf(el) };
81
+ }
82
+ if (el.classList.contains('cluster')) {
83
+ return { family: 'graphviz', kind: 'cluster', id: title, label: labelOf(el) };
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Step (1): the group whose title names the target. Step (2): a node whose
90
+ * label equals the stored label. Null when neither holds.
91
+ */
92
+ export function graphvizFindTarget(svg: Element, target: DiagramTarget): Element | null {
93
+ if (target.kind === 'diagram') return svg;
94
+ for (const el of Array.from(svg.querySelectorAll(GRAPHVIZ_TARGET_SELECTOR))) {
95
+ const candidate = graphvizTargetFromElement(svg, el);
96
+ if (candidate !== null && sameTarget(candidate, target)) return el;
97
+ }
98
+ if (target.kind === 'node' && target.label !== '') {
99
+ // Only while the label names ONE node (see the Mermaid finder).
100
+ const matches = Array.from(svg.querySelectorAll('g.node')).filter((el) => labelOf(el) === target.label);
101
+ if (matches.length === 1) return matches[0] ?? null;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * The 1-based line range that declares the part: DOT statements are one
108
+ * per line in the common case, so a node is the first line that names it
109
+ * as a whole token (its declaration, or the first edge that mentions it),
110
+ * an edge the first line that names both ends, a cluster its `subgraph`
111
+ * line. A quoted name (`"my node"`) matches inside its quotes. Null when
112
+ * the part is not found as a token, which is what a node that exists only
113
+ * in an unsaved draft reads as until it is saved.
114
+ */
115
+ export function graphvizSourceLine(source: string, target: DiagramTarget): readonly [number, number] | null {
116
+ if (target.kind === 'diagram') return diagramWholeSourceLines(source);
117
+ const lines = source.split('\n');
118
+ const matches = (line: string): boolean => {
119
+ if (target.kind === 'edge') {
120
+ return (
121
+ target.from !== undefined &&
122
+ target.to !== undefined &&
123
+ lineMentions(line, target.from) &&
124
+ lineMentions(line, target.to)
125
+ );
126
+ }
127
+ if (target.id === undefined) return false;
128
+ if (target.kind === 'cluster') {
129
+ return /\bsubgraph\b/u.test(line) && lineMentions(line, target.id);
130
+ }
131
+ return lineMentions(line, target.id);
132
+ };
133
+ const index = lines.findIndex(matches);
134
+ return index === -1 ? null : [index + 1, index + 1];
135
+ }
136
+
137
+ /** The Graphviz finder behind the renderer slot's `graphviz` entry. */
138
+ export const GRAPHVIZ_FINDER: DiagramFinder = {
139
+ targetSelector: GRAPHVIZ_TARGET_SELECTOR,
140
+ targetFromElement: (svg, el) => graphvizTargetFromElement(svg, el),
141
+ findTarget: (svg, target) => graphvizFindTarget(svg, target),
142
+ sourceLine: graphvizSourceLine,
143
+ };
@@ -0,0 +1,401 @@
1
+ /**
2
+ * The Mermaid anchor codec: the ONE module that owns Mermaid's rendered id
3
+ * grammar. The pure half (types, the wire parser, `sameTarget`, the names,
4
+ * `diagramSourceLine`) lives in `@plannotator/core/diagram-anchor` and is
5
+ * re-exported here so a host imports one module; this file adds the DOM
6
+ * walkers that read a rendered svg.
7
+ *
8
+ * The anchor is the Mermaid id (edges: from and to), never the rendered
9
+ * element id whole (`flowchart-B-3`: the trailing counter moves when nodes
10
+ * are added) and never geometry (ELK and dagre place the same node at
11
+ * different coordinates). Restore order: (1) the element whose id suffix
12
+ * matches the family's pattern for the id, (2) a node whose label text
13
+ * equals the stored label, (3) the source line as a gutter mark in the
14
+ * Source pane (the pane's job, not this file's), (4) none: unanchored but
15
+ * listed. The id patterns below are the ones captured from the real
16
+ * rendered SVG (see HANDOFF.md "Mermaid 12"), byte-identical between
17
+ * 11.17.2 and 12.0.0.
18
+ *
19
+ * Pure: no React, no DOM globals beyond the Element the caller hands in, so
20
+ * the codec runs the same in the browser and in happy-dom over captured SVGs.
21
+ */
22
+ import {
23
+ diagramSourceLine,
24
+ sameTarget,
25
+ type DiagramFamily,
26
+ type DiagramTarget,
27
+ } from '@plannotator/core/diagram-anchor';
28
+
29
+ export * from '@plannotator/core/diagram-anchor';
30
+
31
+ /**
32
+ * The finder seam (the renderer slot pairs one with each engine): everything
33
+ * the viewer needs to know about one engine's rendered id grammar. The
34
+ * mermaid finder is this file; the Graphviz finder is
35
+ * `diagram-anchor-graphviz.ts`. The viewer never branches on the kind itself.
36
+ */
37
+ export interface DiagramFinder {
38
+ /** The selector of every element the pointer can address. */
39
+ readonly targetSelector: string;
40
+ /** Describe the rendered element under the pointer, or null when it is
41
+ * not one the engine addresses. */
42
+ targetFromElement(svg: Element, el: Element, renderId: string): DiagramTarget | null;
43
+ /** Restore steps (1) and (2): the element that names the target, else a
44
+ * node whose label equals the stored label; null when neither holds. */
45
+ findTarget(svg: Element, target: DiagramTarget, renderId: string): Element | null;
46
+ /** Restore step (3): the 1-based line range that declares the part in
47
+ * the source text, or null when the part is not found as a token. */
48
+ sourceLine(source: string, target: DiagramTarget): readonly [number, number] | null;
49
+ }
50
+
51
+ /** The family a rendered svg declares (`aria-roledescription`), mapped to the
52
+ * id grammar this file knows. Families without element ids (sequence,
53
+ * gitGraph, pie) are `other`: a comment there is diagram-level. */
54
+ export function diagramFamilyOf(svg: Element): DiagramFamily {
55
+ const role = svg.getAttribute('aria-roledescription') ?? '';
56
+ if (role.startsWith('flowchart')) return 'flowchart';
57
+ if (role === 'stateDiagram') return 'state';
58
+ if (role === 'classDiagram') return 'class';
59
+ if (role === 'er') return 'er';
60
+ if (role === 'requirement') return 'requirement';
61
+ if (role === 'sequence') return 'sequence';
62
+ return 'other';
63
+ }
64
+
65
+ /** `render(id, …)` prefixes every element id with `${id}-`; the marker
66
+ * defs use `${id}_` and are never targets. */
67
+ function idSuffix(elementId: string, renderId: string): string | null {
68
+ const prefix = `${renderId}-`;
69
+ return elementId.startsWith(prefix) ? elementId.slice(prefix.length) : null;
70
+ }
71
+
72
+ function stripCounter(suffix: string): string | null {
73
+ const m = /^(.+)-(\d+)$/u.exec(suffix);
74
+ return m === null ? null : (m[1] ?? null);
75
+ }
76
+
77
+ /** The node ids a rendered flowchart or class diagram declares on its
78
+ * `g.node` elements (`flowchart-{id}-{n}`, `classId-{Name}-{n}`), read once
79
+ * per walk so an edge stem can be split against them. */
80
+ export function nodeIdsOf(svg: Element, family: DiagramFamily, renderId: string): ReadonlySet<string> {
81
+ const ids = new Set<string>();
82
+ const pattern =
83
+ family === 'flowchart' ? /^flowchart-(.+)-\d+$/u : family === 'class' ? /^classId-(.+)-\d+$/u : null;
84
+ if (pattern === null) return ids;
85
+ for (const node of svg.querySelectorAll('g.node')) {
86
+ const suffix = idSuffix(node.id, renderId);
87
+ const id = suffix === null ? undefined : pattern.exec(suffix)?.[1];
88
+ if (id !== undefined) ids.add(id);
89
+ }
90
+ return ids;
91
+ }
92
+
93
+ /**
94
+ * Split an edge stem `{from}_{to}` where either end may itself carry
95
+ * underscores (`user_login_check_auth` from `user_login --> check_auth`):
96
+ * the split where both halves are node ids the svg declares, longest
97
+ * `from` first; else the split whose `from` is a declared node; else the
98
+ * first underscore (an end the svg does not list, which a rendered edge
99
+ * never has).
100
+ */
101
+ export function splitEdgeStem(
102
+ stem: string,
103
+ nodeIds: ReadonlySet<string>,
104
+ ): { from: string; to: string } | null {
105
+ const candidates: Array<{ from: string; to: string }> = [];
106
+ for (let at = stem.indexOf('_'); at !== -1; at = stem.indexOf('_', at + 1)) {
107
+ if (at > 0 && at < stem.length - 1) {
108
+ candidates.push({ from: stem.slice(0, at), to: stem.slice(at + 1) });
109
+ }
110
+ }
111
+ const longestFrom = (a: { from: string }, b: { from: string }) => b.from.length - a.from.length;
112
+ const both = candidates.filter((c) => nodeIds.has(c.from) && nodeIds.has(c.to)).sort(longestFrom)[0];
113
+ if (both !== undefined) return both;
114
+ const fromOnly = candidates.filter((c) => nodeIds.has(c.from)).sort(longestFrom)[0];
115
+ if (fromOnly !== undefined) return fromOnly;
116
+ return candidates[0] ?? null;
117
+ }
118
+
119
+ /** The label a rendered part shows: the htmlLabels span, else the svg text. */
120
+ function partLabel(el: Element): string {
121
+ const span = el.querySelector('.nodeLabel, .label');
122
+ const text = (span ?? el.querySelector('text'))?.textContent ?? '';
123
+ return text.replace(/\s+/gu, ' ').trim();
124
+ }
125
+
126
+ /** The edge label Mermaid renders in a sibling group keyed by the edge's
127
+ * element id (`g.edgeLabels > g.edgeLabel > g.label[data-id]`; the
128
+ * `data-id` sits on the inner `g.label`), empty when the edge has none. */
129
+ function edgeLabel(svg: Element, elementSuffix: string): string {
130
+ const group = svg.querySelector(`.label[data-id="${cssEscape(elementSuffix)}"]`);
131
+ return group === null ? '' : (group.textContent ?? '').replace(/\s+/gu, ' ').trim();
132
+ }
133
+
134
+ function cssEscape(value: string): string {
135
+ return value.replace(/["\\]/gu, '\\$&');
136
+ }
137
+
138
+ /** The sequence family's addressable elements. Mermaid gives them classes,
139
+ * never ids: actor boxes and lifelines carry the actor's `name`, a message
140
+ * is its text plus its line, a note its rect plus its text, a loop / alt /
141
+ * opt frame the group that holds its `loopLine`s and its label. */
142
+ const SEQUENCE_TARGET_SELECTOR =
143
+ 'rect.actor, text.actor, line.actor-line, text.messageText, line.messageLine0, line.messageLine1, path.messageLine0, path.messageLine1, rect.note, text.noteText, line.loopLine, polygon.labelBox, text.labelText, text.loopText';
144
+
145
+ /** The selector of every element the pointer can address. An edge LABEL is
146
+ * one too: it is painted over its edge and is where a person clicks an edge,
147
+ * so it resolves to that edge. */
148
+ export const DIAGRAM_TARGET_SELECTOR = `g.node, g.cluster, g.statediagram-cluster, path.flowchart-link, path.transition, path.relation, path.relationshipLine, g.edgeLabel, ${SEQUENCE_TARGET_SELECTOR}`;
149
+
150
+ function cleanText(value: string | null | undefined): string {
151
+ return (value ?? '').replace(/\s+/gu, ' ').trim();
152
+ }
153
+
154
+ function sequenceMessageLines(svg: Element): Element[] {
155
+ return Array.from(svg.querySelectorAll('.messageLine0, .messageLine1'));
156
+ }
157
+
158
+ /** The frames of a sequence diagram: the distinct groups that hold
159
+ * `loopLine`s, in document order. */
160
+ function sequenceFrames(svg: Element): Element[] {
161
+ const frames: Element[] = [];
162
+ for (const line of Array.from(svg.querySelectorAll('line.loopLine'))) {
163
+ const group = line.parentElement;
164
+ if (group !== null && !frames.includes(group)) frames.push(group);
165
+ }
166
+ return frames;
167
+ }
168
+
169
+ function sequenceActorLabel(svg: Element, name: string): string {
170
+ for (const rect of Array.from(svg.querySelectorAll('rect.actor'))) {
171
+ if (rect.getAttribute('name') !== name) continue;
172
+ const text = rect.parentElement?.querySelector('text.actor');
173
+ if (text) return cleanText(text.textContent);
174
+ }
175
+ return name;
176
+ }
177
+
178
+ function sequenceMessageTarget(svg: Element, index: number): DiagramTarget | null {
179
+ const lines = sequenceMessageLines(svg);
180
+ const line = lines[index];
181
+ if (line === undefined) return null;
182
+ const texts = Array.from(svg.querySelectorAll('text.messageText'));
183
+ // Texts and lines pair by document order; when the counts differ (a
184
+ // renderer that splits a message text) the label is left empty rather
185
+ // than guessed.
186
+ const label = texts.length === lines.length ? cleanText(texts[index]?.textContent) : '';
187
+ const from = line.getAttribute('data-from');
188
+ const to = line.getAttribute('data-to');
189
+ return {
190
+ family: 'sequence',
191
+ kind: 'edge',
192
+ id: `msg-${index + 1}`,
193
+ ...(from !== null && to !== null ? { from, to } : {}),
194
+ label,
195
+ };
196
+ }
197
+
198
+ function sequenceTargetFromElement(svg: Element, el: Element): DiagramTarget | null {
199
+ const cl = el.classList;
200
+ if (cl.contains('actor') || cl.contains('actor-line')) {
201
+ const name = el.getAttribute('name') ?? el.parentElement?.querySelector('rect.actor[name]')?.getAttribute('name') ?? null;
202
+ if (name === null || name === '') return null;
203
+ return { family: 'sequence', kind: 'node', id: name, label: sequenceActorLabel(svg, name) };
204
+ }
205
+ if (cl.contains('messageText')) {
206
+ const texts = Array.from(svg.querySelectorAll('text.messageText'));
207
+ if (texts.length !== sequenceMessageLines(svg).length) return null;
208
+ return sequenceMessageTarget(svg, texts.indexOf(el));
209
+ }
210
+ if (cl.contains('messageLine0') || cl.contains('messageLine1')) {
211
+ return sequenceMessageTarget(svg, sequenceMessageLines(svg).indexOf(el));
212
+ }
213
+ if (cl.contains('note') || cl.contains('noteText')) {
214
+ const group = el.parentElement;
215
+ const notes = Array.from(svg.querySelectorAll('rect.note'));
216
+ const index = notes.findIndex((note) => note === el || note.parentElement === group);
217
+ if (index === -1) return null;
218
+ return { family: 'sequence', kind: 'node', id: `note-${index + 1}`, label: cleanText(group?.querySelector('text.noteText')?.textContent) };
219
+ }
220
+ if (cl.contains('loopLine') || cl.contains('labelBox') || cl.contains('labelText') || cl.contains('loopText')) {
221
+ const index = sequenceFrames(svg).indexOf(el.parentElement as Element);
222
+ if (index === -1) return null;
223
+ const group = el.parentElement;
224
+ const label = cleanText(`${group?.querySelector('text.labelText')?.textContent ?? ''} ${group?.querySelector('text.loopText')?.textContent ?? ''}`);
225
+ return { family: 'sequence', kind: 'cluster', id: `frame-${index + 1}`, label };
226
+ }
227
+ return null;
228
+ }
229
+
230
+ /** The element that stands for a sequence part by its id alone. */
231
+ function sequenceElementById(svg: Element, target: DiagramTarget): Element | null {
232
+ if (target.id === undefined) return null;
233
+ const ordinal = /^(msg|note|frame)-(\d+)$/u.exec(target.id);
234
+ if (ordinal === null) {
235
+ if (target.kind !== 'node') return null;
236
+ const rects = Array.from(svg.querySelectorAll('rect.actor')).filter((rect) => rect.getAttribute('name') === target.id);
237
+ return rects.find((rect) => rect.classList.contains('actor-top')) ?? rects[0] ?? null;
238
+ }
239
+ const index = Number(ordinal[2]) - 1;
240
+ if (ordinal[1] === 'msg') return target.kind === 'edge' ? (sequenceMessageLines(svg)[index] ?? null) : null;
241
+ if (ordinal[1] === 'note') return target.kind === 'node' ? (svg.querySelectorAll('rect.note')[index] ?? null) : null;
242
+ return target.kind === 'cluster' ? (sequenceFrames(svg)[index] ?? null) : null;
243
+ }
244
+
245
+ /**
246
+ * Sequence restore. Actors restore by name. Messages, notes and frames have
247
+ * only ordinals for ids, and an ordinal moves when a statement is inserted
248
+ * above it, so the label is checked too: the part at the ordinal when its
249
+ * label still matches, else the ONE part that carries the stored label,
250
+ * else the part at the ordinal (its text was edited in place).
251
+ */
252
+ function findSequenceTarget(svg: Element, target: DiagramTarget): Element | null {
253
+ const byId = sequenceElementById(svg, target);
254
+ const isOrdinal = target.id !== undefined && /^(msg|note|frame)-\d+$/u.test(target.id);
255
+ if (!isOrdinal) {
256
+ if (byId !== null) return byId;
257
+ if (target.kind !== 'node' || target.label === '') return null;
258
+ const named = Array.from(svg.querySelectorAll('rect.actor.actor-top, rect.actor')).filter(
259
+ (rect) => sequenceActorLabel(svg, rect.getAttribute('name') ?? '') === target.label,
260
+ );
261
+ const names = new Set(named.map((rect) => rect.getAttribute('name')));
262
+ return names.size === 1 ? (named.find((rect) => rect.classList.contains('actor-top')) ?? named[0] ?? null) : null;
263
+ }
264
+ const labelOf = (el: Element): string => sequenceTargetFromElement(svg, el.matches('g') ? (el.querySelector('line.loopLine') ?? el) : el)?.label ?? '';
265
+ if (byId !== null && (target.label === '' || labelOf(byId) === target.label)) return byId;
266
+ if (target.label !== '') {
267
+ const pool =
268
+ target.kind === 'edge' ? sequenceMessageLines(svg) : target.kind === 'node' ? Array.from(svg.querySelectorAll('rect.note')) : sequenceFrames(svg);
269
+ const matches = pool.filter((el) => labelOf(el) === target.label);
270
+ if (matches.length === 1) return matches[0] ?? null;
271
+ }
272
+ return byId;
273
+ }
274
+
275
+ /**
276
+ * Describe the rendered element under the pointer as a target, or null when
277
+ * the element is not one this family addresses (a marker, a label group, a
278
+ * pseudo-state, the background). The caller hands the nearest ancestor that
279
+ * matches DIAGRAM_TARGET_SELECTOR.
280
+ */
281
+ export function targetFromElement(
282
+ svg: Element,
283
+ el: Element,
284
+ renderId: string,
285
+ /** The family's declared node ids, when the caller walks many elements
286
+ * (findDiagramTarget); read from the svg otherwise. */
287
+ nodeIds?: ReadonlySet<string>,
288
+ ): DiagramTarget | null {
289
+ const family = diagramFamilyOf(svg);
290
+ if (family === 'sequence') return sequenceTargetFromElement(svg, el);
291
+ if (el.classList.contains('edgeLabel')) {
292
+ // An edge label names its edge through `data-id` (the edge's element
293
+ // id without the render prefix); it IS that edge to the pointer.
294
+ const dataId = (el.matches('[data-id]') ? el : el.querySelector('[data-id]'))?.getAttribute('data-id') ?? null;
295
+ if (dataId === null) return null;
296
+ const edge = svg.querySelector(`[id="${cssEscape(`${renderId}-${dataId}`)}"]`);
297
+ return edge === null || edge === el ? null : targetFromElement(svg, edge, renderId, nodeIds);
298
+ }
299
+ const suffix = idSuffix(el.id, renderId);
300
+ if (suffix === null) return null;
301
+ const isPath = el.tagName.toLowerCase() === 'path';
302
+ const declared = () => nodeIds ?? nodeIdsOf(svg, family, renderId);
303
+ switch (family) {
304
+ case 'flowchart': {
305
+ if (isPath) {
306
+ const stem = /^(L_.+)_\d+$/u.exec(suffix)?.[1];
307
+ if (stem === undefined) return null;
308
+ const pair = splitEdgeStem(stem.slice(2), declared());
309
+ if (pair === null) return null;
310
+ return { family, kind: 'edge', ...pair, label: edgeLabel(svg, suffix) };
311
+ }
312
+ if (el.classList.contains('cluster')) {
313
+ return { family, kind: 'cluster', id: suffix, label: partLabel(el) };
314
+ }
315
+ const node = /^flowchart-(.+)-\d+$/u.exec(suffix)?.[1];
316
+ if (node === undefined) return null;
317
+ return { family, kind: 'node', id: node, label: partLabel(el) };
318
+ }
319
+ case 'state': {
320
+ if (isPath) {
321
+ if (!/^edge\d+$/u.test(suffix)) return null;
322
+ return { family, kind: 'edge', id: suffix, label: edgeLabel(svg, suffix) };
323
+ }
324
+ const state = /^state-(.+)-\d+$/u.exec(suffix)?.[1];
325
+ if (state === undefined || /_(?:start|end)$/u.test(state)) return null;
326
+ return { family, kind: 'node', id: state, label: partLabel(el) };
327
+ }
328
+ case 'class': {
329
+ if (isPath) {
330
+ const stem = /^id_(.+)_\d+$/u.exec(suffix)?.[1];
331
+ if (stem === undefined) return null;
332
+ const pair = splitEdgeStem(stem, declared());
333
+ if (pair === null) return null;
334
+ return { family, kind: 'edge', ...pair, label: edgeLabel(svg, suffix) };
335
+ }
336
+ const name = /^classId-(.+)-\d+$/u.exec(suffix)?.[1];
337
+ if (name === undefined) return null;
338
+ return { family, kind: 'node', id: name, label: partLabel(el) };
339
+ }
340
+ case 'er': {
341
+ if (isPath) {
342
+ const m = /^id_entity-(.+)-\d+_entity-(.+)-\d+_\d+$/u.exec(suffix);
343
+ if (m === null) return null;
344
+ return { family, kind: 'edge', from: m[1] ?? '', to: m[2] ?? '', label: edgeLabel(svg, suffix) };
345
+ }
346
+ const entity = /^entity-(.+)-\d+$/u.exec(suffix)?.[1];
347
+ if (entity === undefined) return null;
348
+ return { family, kind: 'node', id: entity, label: partLabel(el) };
349
+ }
350
+ case 'requirement': {
351
+ if (isPath) {
352
+ const stem = stripCounter(suffix);
353
+ if (stem === null) return null;
354
+ return { family, kind: 'edge', id: stem, label: edgeLabel(svg, suffix) };
355
+ }
356
+ if (!el.classList.contains('node')) return null;
357
+ return { family, kind: 'node', id: suffix, label: partLabel(el) };
358
+ }
359
+ // (`sequence` returned above: its parts carry classes, not ids.)
360
+ case 'other':
361
+ // A Graphviz svg never reaches this codec: its finder is
362
+ // diagram-anchor-graphviz.ts, paired by the renderer slot.
363
+ case 'graphviz':
364
+ return null;
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Step (1): the element whose id suffix names the target. Step (2): a node
370
+ * whose label equals the stored label. Null when neither holds; the caller
371
+ * treats that as unanchored (the gutter mark from `sourceLine` is the
372
+ * Source pane's concern).
373
+ */
374
+ export function findDiagramTarget(svg: Element, target: DiagramTarget, renderId: string): Element | null {
375
+ if (target.kind === 'diagram') return svg;
376
+ const family = diagramFamilyOf(svg);
377
+ if (family === 'sequence') return findSequenceTarget(svg, target);
378
+ const nodeIds = nodeIdsOf(svg, family, renderId);
379
+ for (const el of Array.from(svg.querySelectorAll(DIAGRAM_TARGET_SELECTOR))) {
380
+ // A label resolves to its edge; the edge itself is the element.
381
+ if (el.classList.contains('edgeLabel')) continue;
382
+ const candidate = targetFromElement(svg, el, renderId, nodeIds);
383
+ if (candidate !== null && sameTarget(candidate, target)) return el;
384
+ }
385
+ if (target.kind === 'node' && target.label !== '') {
386
+ // Step (2) holds only while the label names ONE node: with two nodes
387
+ // carrying it, the first match is a coin toss onto the wrong node, so
388
+ // the restore falls through to the source line instead.
389
+ const matches = Array.from(svg.querySelectorAll('g.node')).filter((el) => partLabel(el) === target.label);
390
+ if (matches.length === 1) return matches[0] ?? null;
391
+ }
392
+ return null;
393
+ }
394
+
395
+ /** The mermaid finder: this file's grammar behind the seam. */
396
+ export const MERMAID_FINDER: DiagramFinder = {
397
+ targetSelector: DIAGRAM_TARGET_SELECTOR,
398
+ targetFromElement: (svg, el, renderId) => targetFromElement(svg, el, renderId),
399
+ findTarget: findDiagramTarget,
400
+ sourceLine: diagramSourceLine,
401
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The overlay projection: the canvas scales a wrapper around the rendered
3
+ * svg with a CSS transform; the overlay is a sibling of that wrapper,
4
+ * unscaled, so rings and badges keep constant pixel size at every zoom.
5
+ * Each target's box is read in svg user units (`getBBox`) and pushed
6
+ * through `getScreenCTM`, which carries the svg viewBox scaling and every
7
+ * ancestor transform, the wrapper's CSS zoom included. Minus the canvas
8
+ * host's own rect, that is the ring's rectangle in overlay pixels.
9
+ *
10
+ * Pure over two browser APIs; happy-dom has neither, so the tests install
11
+ * both on the captured fixture's elements with the numbers a real Chromium
12
+ * measured (test-setup/fixtures/diagrams/*.geometry.json).
13
+ */
14
+
15
+ export interface ScreenRect {
16
+ readonly left: number;
17
+ readonly top: number;
18
+ readonly width: number;
19
+ readonly height: number;
20
+ }
21
+
22
+ interface HostRect {
23
+ readonly left: number;
24
+ readonly top: number;
25
+ }
26
+
27
+ export function projectElement(el: Element, host: HostRect): ScreenRect | null {
28
+ const graphic = el as SVGGraphicsElement;
29
+ if (typeof graphic.getBBox !== 'function' || typeof graphic.getScreenCTM !== 'function') {
30
+ return null;
31
+ }
32
+ let box: DOMRect;
33
+ try {
34
+ box = graphic.getBBox();
35
+ } catch {
36
+ // A detached or display:none element throws in some engines.
37
+ return null;
38
+ }
39
+ const m = graphic.getScreenCTM();
40
+ if (m === null) return null;
41
+ const corners = [
42
+ [box.x, box.y],
43
+ [box.x + box.width, box.y],
44
+ [box.x, box.y + box.height],
45
+ [box.x + box.width, box.y + box.height],
46
+ ] as const;
47
+ let minX = Number.POSITIVE_INFINITY;
48
+ let minY = Number.POSITIVE_INFINITY;
49
+ let maxX = Number.NEGATIVE_INFINITY;
50
+ let maxY = Number.NEGATIVE_INFINITY;
51
+ for (const [x, y] of corners) {
52
+ const sx = m.a * x + m.c * y + m.e;
53
+ const sy = m.b * x + m.d * y + m.f;
54
+ minX = Math.min(minX, sx);
55
+ minY = Math.min(minY, sy);
56
+ maxX = Math.max(maxX, sx);
57
+ maxY = Math.max(maxY, sy);
58
+ }
59
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) return null;
60
+ return {
61
+ left: minX - host.left,
62
+ top: minY - host.top,
63
+ width: maxX - minX,
64
+ height: maxY - minY,
65
+ };
66
+ }