@plannotator/ui 0.39.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/HANDOFF.md +140 -10
- package/README.md +9 -5
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DiagramBlock.tsx +376 -0
- package/components/GraphvizBlock.tsx +22 -597
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +17 -630
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +67 -3
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/diagram/DiagramCanvas.tsx +431 -0
- package/components/diagram/DiagramComposer.tsx +135 -0
- package/components/diagram/DiagramOverlay.tsx +215 -0
- package/components/diagram/DiagramPopout.tsx +70 -0
- package/components/diagram/DiagramSourcePane.tsx +244 -0
- package/components/diagram/DiagramViewer.tsx +276 -0
- package/components/diagram/anchorClaims.ts +71 -0
- package/components/diagram/index.ts +36 -0
- package/components/diagram/useDiagramComments.ts +341 -0
- package/components/diagram/useDiagramRender.ts +91 -0
- package/components/diagram/useDiagramSourceDraft.ts +143 -0
- package/components/diagram/useDiagramViewport.ts +156 -0
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +121 -9
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +133 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- package/hooks/useAnnotationHighlighter.ts +469 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +6 -4
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/types.ts +5 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/diagram-anchor-graphviz.ts +143 -0
- package/utils/diagram-anchor.ts +401 -0
- package/utils/diagram-projection.ts +66 -0
- package/utils/diagram-render.ts +668 -0
- package/utils/graphviz.ts +93 -0
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +36 -7
- package/utils/terminalToolsAnnouncement.ts +76 -0
- package/components/mermaidSvg.ts +0 -33
|
@@ -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
|
+
}
|