@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.
@@ -0,0 +1,341 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ buildDiagramAnchorValue,
4
+ diagramFirstSourceLine,
5
+ type DiagramAnchor,
6
+ type DiagramFamily,
7
+ type DiagramTarget,
8
+ } from '@plannotator/core/diagram-anchor';
9
+ import type { DiagramFinder } from '../../utils/diagram-anchor';
10
+
11
+ /**
12
+ * Diagram comments: the html-annotation grammar adapted to an app-owned
13
+ * svg. Hover outlines a part, click opens the composer at it, Enter saves,
14
+ * shift-click adds targets to the one draft (up to the host's cap); a saved
15
+ * comment paints as a ring and a numbered badge, numbers from the
16
+ * `comments` array order. Restore runs the engine's finder
17
+ * (`finder.findTarget`, from the renderer slot) per comment after every
18
+ * render (the svg is replaced, so element references are re-resolved), and
19
+ * the ids that resolve to nothing are reported as the unanchored set,
20
+ * exactly as the html viewer reports its own.
21
+ *
22
+ * The host's storage never enters here: `comments` come in as the adapter
23
+ * shape and a new comment leaves through `onCreateComment(anchor, text,
24
+ * additionalTargets)`.
25
+ */
26
+
27
+ /** One comment the host holds on this diagram (the adapter's input shape). */
28
+ export interface DiagramComment {
29
+ readonly id: string;
30
+ readonly anchor: DiagramAnchor;
31
+ /** Shift-click targets the one comment also covers; absent for most. */
32
+ readonly additionalTargets?: readonly DiagramTarget[];
33
+ readonly text: string;
34
+ readonly author?: string;
35
+ readonly resolved?: boolean;
36
+ }
37
+
38
+ /** The adapter's output: a comment the person composed on a part. The
39
+ * anchor's `sourceLine` already carries the host's document-line offset. */
40
+ export type DiagramCreateComment = (
41
+ anchor: DiagramAnchor,
42
+ text: string,
43
+ additionalTargets: readonly DiagramTarget[],
44
+ ) => void | Promise<unknown>;
45
+
46
+ export interface ResolvedDiagramComment {
47
+ readonly id: string;
48
+ /** Array position in the host's list, 1-based. */
49
+ readonly number: number;
50
+ readonly element: Element | null;
51
+ readonly additional: readonly Element[];
52
+ readonly label: string;
53
+ readonly resolved: boolean;
54
+ /** The comment is on the WHOLE diagram: its ring is the content bounds
55
+ * and its badge sits top-left. */
56
+ readonly whole: boolean;
57
+ }
58
+
59
+ export interface DiagramHover {
60
+ readonly element: Element;
61
+ readonly target: DiagramTarget;
62
+ }
63
+
64
+ export interface DiagramComposerDraft {
65
+ readonly primary: DiagramHover;
66
+ readonly additional: readonly DiagramHover[];
67
+ /** The primary target's line in the SAVED text, offset into the host's
68
+ * document; null when the part only exists in an unsaved draft (the
69
+ * composer says so). */
70
+ readonly sourceLine: readonly [number, number] | null;
71
+ }
72
+
73
+ export function useDiagramComments({
74
+ finder,
75
+ svgRoot,
76
+ renderId,
77
+ renderVersion,
78
+ comments,
79
+ savedSource,
80
+ sourceLineOffset,
81
+ sourceDirty,
82
+ maxAdditionalTargets,
83
+ canCreate,
84
+ onCreateComment,
85
+ onSelectComment,
86
+ onUnanchoredChange,
87
+ onResolutionChange,
88
+ familyOf,
89
+ }: {
90
+ /** The engine's id grammar (mermaid or graphviz), from the renderer
91
+ * slot: the one place the kind reaches this hook. */
92
+ readonly finder: DiagramFinder;
93
+ readonly svgRoot: SVGSVGElement | null;
94
+ readonly renderId: string | null;
95
+ readonly renderVersion: number;
96
+ readonly comments: readonly DiagramComment[];
97
+ /** The baseline (saved) diagram text the anchor's `sourceLine` counts in. */
98
+ readonly savedSource: string;
99
+ /** Lines to add so a written `sourceLine` names DOCUMENT lines: 0 when
100
+ * the document IS the diagram, the fence's opening line for a fence. */
101
+ readonly sourceLineOffset: number;
102
+ readonly sourceDirty: boolean;
103
+ readonly maxAdditionalTargets: number;
104
+ /** Whether a click may open the composer at all. */
105
+ readonly canCreate: boolean;
106
+ readonly onCreateComment: DiagramCreateComment | undefined;
107
+ readonly onSelectComment: ((id: string | null) => void) | undefined;
108
+ readonly onUnanchoredChange: ((ids: ReadonlySet<string>) => void) | undefined;
109
+ /** Every comment's verdict (true: its part is in this render), whenever
110
+ * any verdict or the comment list changes. `onUnanchoredChange` only
111
+ * speaks when the unanchored SET changes, which says nothing about a new
112
+ * comment that resolved. */
113
+ readonly onResolutionChange: ((resolution: ReadonlyMap<string, boolean>) => void) | undefined;
114
+ /** The family a whole-diagram comment records (the engine's, from the
115
+ * rendered svg). */
116
+ readonly familyOf: (svg: Element) => DiagramFamily;
117
+ }): {
118
+ readonly resolved: readonly ResolvedDiagramComment[];
119
+ readonly hover: DiagramHover | null;
120
+ readonly composer: DiagramComposerDraft | null;
121
+ readonly submitting: boolean;
122
+ /** The last submit's failure, shown in the composer; cleared on retry. */
123
+ readonly submitError: string | null;
124
+ readonly setHoverElement: (element: Element | null) => void;
125
+ /** A click without a drag on the canvas: opens or extends the draft. */
126
+ readonly clickElement: (element: Element | null, shiftKey: boolean) => void;
127
+ /** The canvas's priority rule over everything under the pointer: a node,
128
+ * then an edge, then a cluster. */
129
+ readonly pickTarget: (candidates: readonly Element[]) => Element | null;
130
+ readonly submit: (body: string) => Promise<void>;
131
+ readonly cancel: () => void;
132
+ } {
133
+ const [hover, setHover] = useState<DiagramHover | null>(null);
134
+ const [composer, setComposer] = useState<DiagramComposerDraft | null>(null);
135
+ const [submitting, setSubmitting] = useState(false);
136
+ const [submitError, setSubmitError] = useState<string | null>(null);
137
+
138
+ const composerOpenRef = useRef(false);
139
+ composerOpenRef.current = composer !== null;
140
+
141
+ // Resolve every comment's parts against the current render. The
142
+ // renderVersion dependency is the re-resolution after a re-render: the
143
+ // elements are new objects even when the ids did not change.
144
+ const resolved = useMemo<readonly ResolvedDiagramComment[]>(() => {
145
+ void renderVersion;
146
+ return comments.map((comment, index) => {
147
+ const anchor = comment.anchor;
148
+ if (svgRoot === null || renderId === null) {
149
+ return {
150
+ id: comment.id,
151
+ number: index + 1,
152
+ element: null,
153
+ additional: [],
154
+ label: anchor.label,
155
+ resolved: comment.resolved === true,
156
+ whole: anchor.kind === 'diagram',
157
+ };
158
+ }
159
+ const element = finder.findTarget(svgRoot, anchor, renderId);
160
+ const additional = (comment.additionalTargets ?? [])
161
+ .map((target) => finder.findTarget(svgRoot, target, renderId))
162
+ .filter((el): el is Element => el !== null);
163
+ return {
164
+ id: comment.id,
165
+ number: index + 1,
166
+ element,
167
+ additional,
168
+ label: anchor.label,
169
+ resolved: comment.resolved === true,
170
+ whole: anchor.kind === 'diagram',
171
+ };
172
+ });
173
+ }, [comments, finder, renderId, renderVersion, svgRoot]);
174
+
175
+ const lastResolutionRef = useRef<string | null>(null);
176
+ useEffect(() => {
177
+ if (svgRoot === null || onResolutionChange === undefined) return;
178
+ const key = resolved.map((entry) => `${entry.id}:${entry.element === null ? 0 : 1}`).join('\n');
179
+ if (key === lastResolutionRef.current) return;
180
+ lastResolutionRef.current = key;
181
+ onResolutionChange(new Map(resolved.map((entry) => [entry.id, entry.element !== null])));
182
+ }, [onResolutionChange, resolved, svgRoot]);
183
+
184
+ // The unanchored set: comments whose primary part is gone from this
185
+ // render. Reported only when the membership changes, so the host's state
186
+ // does not churn per render.
187
+ const lastReportedRef = useRef<string | null>(null);
188
+ useEffect(() => {
189
+ if (svgRoot === null || onUnanchoredChange === undefined) return;
190
+ const ids = new Set(resolved.filter((entry) => entry.element === null).map((entry) => entry.id));
191
+ const key = [...ids].sort().join('\n');
192
+ if (key === lastReportedRef.current) return;
193
+ lastReportedRef.current = key;
194
+ onUnanchoredChange(ids);
195
+ }, [onUnanchoredChange, resolved, svgRoot]);
196
+
197
+ // A re-render drops stale element references from the draft and the
198
+ // hover; the composer re-opens at the same part when it still exists.
199
+ useEffect(() => {
200
+ setHover(null);
201
+ setComposer((current) => {
202
+ if (current === null || svgRoot === null || renderId === null) return null;
203
+ const primary = finder.findTarget(svgRoot, current.primary.target, renderId);
204
+ if (primary === null) return null;
205
+ return {
206
+ ...current,
207
+ primary: { element: primary, target: current.primary.target },
208
+ additional: current.additional.flatMap((extra) => {
209
+ const el = finder.findTarget(svgRoot, extra.target, renderId);
210
+ return el === null ? [] : [{ element: el, target: extra.target }];
211
+ }),
212
+ };
213
+ });
214
+ }, [finder, renderId, renderVersion, svgRoot]);
215
+
216
+ const describe = useCallback(
217
+ (element: Element | null): DiagramHover | null => {
218
+ if (element === null || svgRoot === null || renderId === null) return null;
219
+ const target = finder.targetFromElement(svgRoot, element, renderId);
220
+ if (target === null) return null;
221
+ // The part's ONE element: an edge label resolves to its edge, a
222
+ // sequence actor's bottom box to the same actor as its top box.
223
+ return { element: finder.findTarget(svgRoot, target, renderId) ?? element, target };
224
+ },
225
+ [finder, renderId, svgRoot],
226
+ );
227
+
228
+ const pickTarget = useCallback(
229
+ (candidates: readonly Element[]): Element | null => {
230
+ if (svgRoot === null || renderId === null) return candidates[0] ?? null;
231
+ const kinds = candidates.map((el) => finder.targetFromElement(svgRoot, el, renderId)?.kind ?? null);
232
+ for (const kind of ['node', 'edge', 'cluster'] as const) {
233
+ const at = kinds.indexOf(kind);
234
+ if (at !== -1) return candidates[at] ?? null;
235
+ }
236
+ return null;
237
+ },
238
+ [finder, renderId, svgRoot],
239
+ );
240
+
241
+ const setHoverElement = useCallback(
242
+ (element: Element | null) => {
243
+ setHover((current) => {
244
+ if (element === null) return current === null ? current : null;
245
+ if (current !== null && current.element === element) return current;
246
+ return describe(element);
247
+ });
248
+ },
249
+ [describe],
250
+ );
251
+
252
+ const clickElement = useCallback(
253
+ (element: Element | null, shiftKey: boolean) => {
254
+ let part = describe(element);
255
+ if (part === null) {
256
+ // The background, a marker, a part no family addresses. A click
257
+ // there closes an open draft; with none open it comments on the
258
+ // WHOLE diagram, so a click never does nothing (a sequence note
259
+ // the codec misses, a gitGraph, a pie, anything future).
260
+ if (shiftKey) return;
261
+ if (composerOpenRef.current || !canCreate || svgRoot === null) {
262
+ setComposer(null);
263
+ return;
264
+ }
265
+ part = {
266
+ element: svgRoot,
267
+ target: { family: familyOf(svgRoot), kind: 'diagram', label: diagramFirstSourceLine(savedSource) },
268
+ };
269
+ }
270
+ if (!canCreate) return;
271
+ setSubmitError(null);
272
+ setComposer((current) => {
273
+ if (shiftKey && current !== null && part.target.kind !== 'diagram' && current.primary.target.kind !== 'diagram') {
274
+ const already =
275
+ current.primary.element === part.element || current.additional.some((extra) => extra.element === part.element);
276
+ if (already || current.additional.length >= maxAdditionalTargets) {
277
+ return current;
278
+ }
279
+ return { ...current, additional: [...current.additional, part] };
280
+ }
281
+ const line = finder.sourceLine(savedSource, part.target);
282
+ return {
283
+ primary: part,
284
+ additional: [],
285
+ sourceLine: line === null ? null : [line[0] + sourceLineOffset, line[1] + sourceLineOffset],
286
+ };
287
+ });
288
+ },
289
+ [canCreate, describe, familyOf, finder, maxAdditionalTargets, savedSource, sourceLineOffset, svgRoot],
290
+ );
291
+
292
+ const cancel = useCallback(() => {
293
+ setComposer(null);
294
+ setSubmitError(null);
295
+ }, []);
296
+
297
+ const submit = useCallback(
298
+ async (body: string) => {
299
+ if (composer === null || submitting || onCreateComment === undefined) return;
300
+ if (composer.sourceLine === null && sourceDirty) {
301
+ setSubmitError('Save the draft before commenting on a part that only exists in it.');
302
+ return;
303
+ }
304
+ const text = body.trim();
305
+ if (text === '') return;
306
+ setSubmitting(true);
307
+ setSubmitError(null);
308
+ try {
309
+ await onCreateComment(
310
+ buildDiagramAnchorValue(composer.primary.target, composer.sourceLine),
311
+ text,
312
+ composer.additional.map((extra) => extra.target),
313
+ );
314
+ setComposer(null);
315
+ } catch (error) {
316
+ // The draft stays open with its text; the person retries.
317
+ setSubmitError(error instanceof Error && error.message ? error.message : "Couldn't save the comment. Try again.");
318
+ } finally {
319
+ setSubmitting(false);
320
+ }
321
+ },
322
+ [composer, onCreateComment, sourceDirty, submitting],
323
+ );
324
+
325
+ // Selecting through a badge is the host's selection, so the panel and
326
+ // the ring agree.
327
+ void onSelectComment;
328
+
329
+ return {
330
+ resolved,
331
+ hover,
332
+ composer,
333
+ submitting,
334
+ submitError,
335
+ setHoverElement,
336
+ clickElement,
337
+ pickTarget,
338
+ submit,
339
+ cancel,
340
+ };
341
+ }
@@ -0,0 +1,91 @@
1
+ import { useEffect, useState } from 'react';
2
+ import {
3
+ renderDiagram,
4
+ type DiagramKind,
5
+ type DiagramRenderError,
6
+ type DiagramRenderResult,
7
+ type DiagramTheme,
8
+ } from '../../utils/diagram-render';
9
+
10
+ function isRenderError(result: DiagramRenderResult): result is DiagramRenderError {
11
+ return result.ok === false;
12
+ }
13
+
14
+ export interface DiagramRenderState {
15
+ /** The last GOOD render's sanitized svg root, kept (dimmed) under a
16
+ * parse error. The hook never holds markup: the render slot sanitizes
17
+ * into a node and the canvas mounts it. */
18
+ readonly svgNode: SVGSVGElement | null;
19
+ /** The render id the current svg was rendered with (the id prefix the
20
+ * anchor finder strips). */
21
+ readonly renderId: string | null;
22
+ /** Increments per successful render: the overlay re-resolves elements. */
23
+ readonly renderVersion: number;
24
+ readonly error: {
25
+ readonly message: string;
26
+ readonly line: number | null;
27
+ /** The engine could not be loaded: a Retry can change this one. */
28
+ readonly runtimeUnavailable: boolean;
29
+ } | null;
30
+ readonly pending: boolean;
31
+ }
32
+
33
+ let renderCounter = 0;
34
+
35
+ /**
36
+ * Render `source` through the renderer slot whenever it, the kind, the
37
+ * theme or `retryToken` changes. Each render gets its own id
38
+ * (`diagram-<documentId>-<n>`): Mermaid removes any existing element with
39
+ * the id it is asked to render into, so reusing one id would tear the
40
+ * displayed svg out of the page mid-render.
41
+ */
42
+ export function useDiagramRender(
43
+ kind: DiagramKind,
44
+ documentId: string,
45
+ source: string,
46
+ theme: DiagramTheme,
47
+ options?: { readonly retryToken?: number },
48
+ ): DiagramRenderState {
49
+ const retryToken = options?.retryToken ?? 0;
50
+ const [state, setState] = useState<DiagramRenderState>({
51
+ svgNode: null,
52
+ renderId: null,
53
+ renderVersion: 0,
54
+ error: null,
55
+ pending: true,
56
+ });
57
+
58
+ useEffect(() => {
59
+ let cancelled = false;
60
+ renderCounter += 1;
61
+ const renderId = `diagram-${documentId.replace(/[^\w-]/gu, '')}-${renderCounter}`;
62
+ // A new attempt after a failed engine load (a Retry) shows the pending
63
+ // state again rather than the stale error; a parse error stays on
64
+ // screen until the new result lands, so a typing burst does not blink.
65
+ setState((current) => {
66
+ const error = current.error?.runtimeUnavailable ? null : current.error;
67
+ return current.pending && error === current.error ? current : { ...current, error, pending: true };
68
+ });
69
+ void renderDiagram(kind, renderId, source, theme).then((result: DiagramRenderResult) => {
70
+ if (cancelled) return;
71
+ if (isRenderError(result)) {
72
+ const error = { message: result.message, line: result.line, runtimeUnavailable: result.runtimeUnavailable };
73
+ setState((current) => ({ ...current, error, pending: false }));
74
+ return;
75
+ }
76
+ const svgNode = result.svgNode;
77
+ setState((current) => ({
78
+ svgNode,
79
+ renderId,
80
+ renderVersion: current.renderVersion + 1,
81
+ error: null,
82
+ pending: false,
83
+ }));
84
+ });
85
+ return () => {
86
+ cancelled = true;
87
+ };
88
+ }, [documentId, kind, source, theme, retryToken]);
89
+
90
+ return state;
91
+ }
@@ -0,0 +1,143 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ /**
4
+ * The draft-and-save model behind the Source pane: the pane's buffer is
5
+ * LOCAL until Save. The canvas re-renders from the draft after a short
6
+ * debounce (the live preview); Save hands the whole text to the host's
7
+ * `onSave` and makes one save point on the host's side; Discard drops the
8
+ * draft. A source that changed underneath (the host's `source` prop moved
9
+ * while the draft was dirty, or `onSave` answered `stale`) shows "Changed
10
+ * since you opened" with Reload before Save is allowed again; Reload
11
+ * adopts the newer text as the baseline and keeps a dirty draft's text (no
12
+ * three-way merge).
13
+ *
14
+ * No continuous save exists here on purpose: a diagram mid-edit is a
15
+ * broken diagram, and every keystroke saved would ship broken versions.
16
+ */
17
+
18
+ /** The canvas re-renders the draft this long after the last keystroke. The
19
+ * runtime re-lays out the whole diagram per render, so a typing burst is
20
+ * one render, not one per key. A design constant, not a limit on the
21
+ * person. */
22
+ export const PREVIEW_DEBOUNCE_MS = 250;
23
+
24
+ /** What the host answers a Save with. `ok` means the text is the new
25
+ * baseline; `stale` means someone else saved first and the host hands back
26
+ * what is there now, which Reload adopts. Any other failure is a thrown
27
+ * error, shown as the save error with its message. */
28
+ export type SaveResult = { readonly status: 'ok' } | { readonly status: 'stale'; readonly currentSource: string };
29
+
30
+ export interface DiagramSourceDraft {
31
+ /** The buffer's text. */
32
+ readonly draft: string;
33
+ /** The debounced draft the canvas renders. */
34
+ readonly preview: string;
35
+ /** The last saved text: the anchor writer's source. */
36
+ readonly baseline: string;
37
+ readonly dirty: boolean;
38
+ readonly saving: boolean;
39
+ /** The source moved under the draft (the prop, or a stale answer). */
40
+ readonly stale: boolean;
41
+ readonly saveError: string | null;
42
+ readonly setDraft: (next: string) => void;
43
+ readonly save: () => Promise<void>;
44
+ readonly discard: () => void;
45
+ readonly reload: () => void;
46
+ }
47
+
48
+ export function useDiagramSourceDraft({
49
+ source,
50
+ editable,
51
+ onSave,
52
+ }: {
53
+ /** The host's current text for the diagram. */
54
+ readonly source: string;
55
+ readonly editable: boolean;
56
+ readonly onSave: ((source: string) => Promise<SaveResult>) | undefined;
57
+ }): DiagramSourceDraft {
58
+ const [baseline, setBaseline] = useState(source);
59
+ const [draft, setDraftState] = useState(source);
60
+ const [preview, setPreview] = useState(source);
61
+ const [saving, setSaving] = useState(false);
62
+ const [saveError, setSaveError] = useState<string | null>(null);
63
+ // The newer text the host holds, when it differs from the baseline: a
64
+ // prop that moved under a dirty draft, or a stale save's answer.
65
+ const [newer, setNewer] = useState<string | null>(null);
66
+ const dirty = draft !== baseline;
67
+ const dirtyRef = useRef(dirty);
68
+ dirtyRef.current = dirty;
69
+
70
+ // A new source (the host's refresh, our own save echoed back) is adopted
71
+ // when the draft is clean; a dirty draft keeps its text and the strip
72
+ // asks for Reload.
73
+ useEffect(() => {
74
+ if (dirtyRef.current) {
75
+ setBaseline((current) => {
76
+ if (current !== source) setNewer(source);
77
+ return current;
78
+ });
79
+ return;
80
+ }
81
+ setBaseline(source);
82
+ setDraftState(source);
83
+ setPreview(source);
84
+ setNewer(null);
85
+ }, [source]);
86
+
87
+ // The live preview: one render per typing burst.
88
+ useEffect(() => {
89
+ if (draft === preview) return;
90
+ const timer = setTimeout(() => setPreview(draft), PREVIEW_DEBOUNCE_MS);
91
+ return () => clearTimeout(timer);
92
+ }, [draft, preview]);
93
+
94
+ const setDraft = useCallback(
95
+ (next: string) => {
96
+ if (!editable) return;
97
+ setDraftState(next);
98
+ setSaveError(null);
99
+ },
100
+ [editable],
101
+ );
102
+
103
+ const stale = newer !== null && newer !== baseline;
104
+
105
+ const save = useCallback(async () => {
106
+ if (!editable || saving || !dirtyRef.current || onSave === undefined) return;
107
+ const text = draft;
108
+ setSaving(true);
109
+ setSaveError(null);
110
+ try {
111
+ const result = await onSave(text);
112
+ if (result.status === 'stale') {
113
+ setNewer(result.currentSource);
114
+ } else {
115
+ setBaseline(text);
116
+ setNewer(null);
117
+ }
118
+ } catch (error) {
119
+ setSaveError(error instanceof Error && error.message ? error.message : 'The diagram could not be saved.');
120
+ } finally {
121
+ setSaving(false);
122
+ }
123
+ }, [draft, editable, onSave, saving]);
124
+
125
+ const discard = useCallback(() => {
126
+ setDraftState(baseline);
127
+ setPreview(baseline);
128
+ setSaveError(null);
129
+ }, [baseline]);
130
+
131
+ const reload = useCallback(() => {
132
+ if (newer === null) return;
133
+ setBaseline(newer);
134
+ setSaveError(null);
135
+ if (!dirtyRef.current) {
136
+ setDraftState(newer);
137
+ setPreview(newer);
138
+ }
139
+ setNewer(null);
140
+ }, [newer]);
141
+
142
+ return { draft, preview, baseline, dirty, saving, stale, saveError, setDraft, save, discard, reload };
143
+ }