@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,276 @@
1
+ import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
2
+ import type { DiagramKind } from '@plannotator/core/diagram-anchor';
3
+ import { cn } from '../../lib/utils';
4
+ import { diagramFamilyOf } from '../../utils/diagram-anchor';
5
+ import { diagramFinder, type DiagramTheme } from '../../utils/diagram-render';
6
+ import { DiagramCanvas, type DiagramCanvasHandle, type DiagramEscapeOutcome } from './DiagramCanvas';
7
+ import { DiagramComposer } from './DiagramComposer';
8
+ import { DiagramOverlay } from './DiagramOverlay';
9
+ import { DiagramSourcePane } from './DiagramSourcePane';
10
+ import { useDiagramComments, type DiagramComment, type DiagramCreateComment } from './useDiagramComments';
11
+ import { useDiagramRender, type DiagramRenderState } from './useDiagramRender';
12
+ import { useDiagramSourceDraft, type SaveResult } from './useDiagramSourceDraft';
13
+
14
+ /**
15
+ * The one diagram viewer: the canvas with its overlay, the Source pane on
16
+ * its LEFT (stacked under it on the phone) when the host can save, the
17
+ * parse-error strip over a dimmed last-good render. It takes the diagram
18
+ * source and the host's comments on it, and reports a new comment through
19
+ * `onCreateComment`; the comments rail, storage and identity are the
20
+ * host's. Plannotator's fence blocks render through it in the document
21
+ * (`components/DiagramBlock`) and again at full size in the popout; a host
22
+ * with its own document store renders it wherever a diagram lives.
23
+ */
24
+ export interface DiagramViewerProps {
25
+ readonly kind: DiagramKind;
26
+ /** The diagram text. With `onSave` this is the saved baseline the pane's
27
+ * draft starts from; without it, what the canvas renders. */
28
+ readonly source: string;
29
+ readonly theme: DiagramTheme;
30
+ readonly comments: readonly DiagramComment[];
31
+ /** A comment composed on a part. Absent: clicks open nothing. */
32
+ readonly onCreateComment?: DiagramCreateComment;
33
+ /** Save the pane's text. Absent: there is no Source pane. */
34
+ readonly onSave?: (source: string) => Promise<SaveResult>;
35
+ /** With `onSave`: show the pane read-only (no edit access). */
36
+ readonly readOnlySource?: boolean;
37
+ /** Whether the Source pane is open (the host owns the toggle). */
38
+ readonly sourceOpen?: boolean;
39
+ readonly selectedCommentId?: string | null;
40
+ readonly onSelectComment?: (id: string | null) => void;
41
+ /** The comments whose part is gone from the current render, once per
42
+ * membership change, after every render. */
43
+ readonly onUnanchoredChange?: (ids: ReadonlySet<string>) => void;
44
+ /** Every comment's verdict (true: its part is in this render), whenever
45
+ * a verdict or the comment list changes. */
46
+ readonly onResolutionChange?: (resolution: ReadonlyMap<string, boolean>) => void;
47
+ /** Escape with no composer open and nothing selected: a popout closes. */
48
+ readonly onDismiss?: () => void;
49
+ /** A stable prefix for the rendered element ids; two viewers over one
50
+ * document need two. */
51
+ readonly renderId?: string;
52
+ /** Lines to add so a comment's `sourceLine` names DOCUMENT lines: 0 when
53
+ * the document is the diagram, the fence's opening line for a fence. */
54
+ readonly sourceLineOffset?: number;
55
+ /** Shift-click targets one comment may also cover. Default 0: a comment
56
+ * covers one part. */
57
+ readonly maxAdditionalTargets?: number;
58
+ /** Commenting is off, and this is why (shown in place of the composer). */
59
+ readonly commentingDisabledReason?: string;
60
+ /** Bumped by the host's Retry after a failed engine load. */
61
+ readonly retryToken?: number;
62
+ /** Every render state change (the host sizes itself, shows its own
63
+ * pending or error chrome). */
64
+ readonly onRenderState?: (state: DiagramRenderState) => void;
65
+ /** What to show while there is no svg yet (the first render pending, or
66
+ * the first render failed). Default: a quiet status line. */
67
+ readonly renderFallback?: (state: DiagramRenderState) => ReactNode;
68
+ /** Take the keyboard on mount (a popout). */
69
+ readonly autoFocus?: boolean;
70
+ readonly className?: string;
71
+ /** Classes for the canvas host. The canvas lets a finger scroll the page
72
+ * past it (`touch-action: pan-y`); a viewer that owns the whole screen
73
+ * passes `touch-none`. */
74
+ readonly canvasClassName?: string;
75
+ }
76
+
77
+ export function DiagramViewer({
78
+ kind,
79
+ source,
80
+ theme,
81
+ comments,
82
+ onCreateComment,
83
+ onSave,
84
+ readOnlySource = false,
85
+ sourceOpen = false,
86
+ selectedCommentId = null,
87
+ onSelectComment,
88
+ onUnanchoredChange,
89
+ onResolutionChange,
90
+ onDismiss,
91
+ renderId = 'diagram',
92
+ sourceLineOffset = 0,
93
+ maxAdditionalTargets = 0,
94
+ commentingDisabledReason,
95
+ retryToken,
96
+ onRenderState,
97
+ renderFallback,
98
+ autoFocus,
99
+ className,
100
+ canvasClassName,
101
+ }: DiagramViewerProps) {
102
+ const finder = diagramFinder(kind);
103
+ const familyOf = useCallback((svg: Element) => (kind === 'graphviz' ? ('graphviz' as const) : diagramFamilyOf(svg)), [kind]);
104
+ const hasPane = onSave !== undefined;
105
+ const editable = hasPane && !readOnlySource;
106
+
107
+ const draft = useDiagramSourceDraft({ source, editable, onSave });
108
+ const rendered = hasPane ? draft.preview : source;
109
+ const savedSource = hasPane ? draft.baseline : source;
110
+ const sourceDirty = hasPane && draft.dirty;
111
+
112
+ const render = useDiagramRender(kind, renderId, rendered, theme, { retryToken });
113
+ useEffect(() => {
114
+ onRenderState?.(render);
115
+ }, [onRenderState, render]);
116
+
117
+ const [svgRoot, setSvgRoot] = useState<SVGSVGElement | null>(null);
118
+ const onSvgRoot = useCallback((root: SVGSVGElement | null) => setSvgRoot(root), []);
119
+
120
+ const canCreate = onCreateComment !== undefined || commentingDisabledReason !== undefined;
121
+ const commentsState = useDiagramComments({
122
+ finder,
123
+ svgRoot,
124
+ renderId: render.renderId,
125
+ renderVersion: render.renderVersion,
126
+ comments,
127
+ savedSource,
128
+ sourceLineOffset,
129
+ sourceDirty,
130
+ maxAdditionalTargets,
131
+ canCreate,
132
+ onCreateComment,
133
+ onSelectComment,
134
+ onUnanchoredChange,
135
+ onResolutionChange,
136
+ familyOf,
137
+ });
138
+
139
+ // The selected comment's source line for the pane's gutter mark. The
140
+ // anchor names DOCUMENT lines; the pane shows the diagram text.
141
+ const markedLines = useMemo(() => {
142
+ if (selectedCommentId === null) return null;
143
+ const line = comments.find((entry) => entry.id === selectedCommentId)?.anchor.sourceLine;
144
+ if (line === null || line === undefined) return null;
145
+ return [line[0] - sourceLineOffset, line[1] - sourceLineOffset] as const;
146
+ }, [comments, selectedCommentId, sourceLineOffset]);
147
+
148
+ const onEscape = useCallback((): DiagramEscapeOutcome => {
149
+ if (commentsState.composer !== null) {
150
+ commentsState.cancel();
151
+ return 'consumed';
152
+ }
153
+ if (selectedCommentId !== null && onSelectComment !== undefined) {
154
+ onSelectComment(null);
155
+ return 'consumed';
156
+ }
157
+ onDismiss?.();
158
+ return 'pass';
159
+ }, [commentsState, onDismiss, onSelectComment, selectedCommentId]);
160
+
161
+ // Closing the pane only closes an open composer so the layout shift does
162
+ // not strand it; the draft survives a reopen.
163
+ const cancelComposer = commentsState.cancel;
164
+ useEffect(() => {
165
+ if (!sourceOpen) cancelComposer();
166
+ }, [cancelComposer, sourceOpen]);
167
+
168
+ const overlay = useCallback(
169
+ (handle: DiagramCanvasHandle) => (
170
+ <DiagramOverlay
171
+ handle={handle}
172
+ resolved={commentsState.resolved}
173
+ hover={commentsState.hover}
174
+ composer={commentsState.composer}
175
+ selectedCommentId={selectedCommentId}
176
+ onSelectComment={onSelectComment}
177
+ renderComposer={(anchorRect) =>
178
+ commentsState.composer === null ? null : (
179
+ <DiagramComposer
180
+ key={`${commentsState.composer.primary.target.id ?? ''}:${commentsState.composer.primary.target.from ?? ''}:${commentsState.composer.primary.target.to ?? ''}`}
181
+ draft={commentsState.composer}
182
+ anchorRect={anchorRect}
183
+ hostWidth={handle.hostRef.current?.getBoundingClientRect().width ?? 0}
184
+ sourceDirty={sourceDirty}
185
+ submitting={commentsState.submitting}
186
+ error={commentsState.submitError}
187
+ disabledReason={commentingDisabledReason}
188
+ onSubmit={(text) => void commentsState.submit(text)}
189
+ onCancel={commentsState.cancel}
190
+ />
191
+ )
192
+ }
193
+ />
194
+ ),
195
+ [commentingDisabledReason, commentsState, onSelectComment, selectedCommentId, sourceDirty],
196
+ );
197
+
198
+ const showFallback = render.svgNode === null;
199
+
200
+ return (
201
+ // `annotation-exclude`: the document's text highlighter never enters a
202
+ // diagram, so a text restore can never wrap a <mark> inside the svg.
203
+ <div data-diagram-viewer="" className={cn('annotation-exclude flex h-full min-h-0 w-full flex-col md:flex-row', className)}>
204
+ {/* The pane comes FIRST in the row (owner ruling: the source sits on
205
+ the left of the diagram while editing). On the phone the row is a
206
+ column and the pane stays stacked UNDER the canvas, which
207
+ `order-last` keeps while `md:order-first` puts it left from `md`. */}
208
+ {hasPane && sourceOpen && (
209
+ <DiagramSourcePane
210
+ draft={draft}
211
+ editable={editable}
212
+ markedLines={markedLines}
213
+ className="order-last min-h-0 shrink-0 basis-2/5 border-t border-border md:order-first md:w-80 md:basis-auto md:border-r md:border-t-0"
214
+ />
215
+ )}
216
+ <div className="relative min-h-0 min-w-0 flex-1">
217
+ {showFallback ? (
218
+ <div data-diagram-fallback="" className="h-full min-h-0 w-full">
219
+ {renderFallback !== undefined ? (
220
+ renderFallback(render)
221
+ ) : (
222
+ <div className="flex h-full items-center justify-center p-4 text-xs text-muted-foreground" aria-live="polite">
223
+ {render.error !== null
224
+ ? `The diagram does not parse${render.error.line === null ? '' : ` (line ${render.error.line})`}: ${render.error.message}`
225
+ : render.pending
226
+ ? 'Rendering diagram…'
227
+ : 'Nothing to render yet.'}
228
+ </div>
229
+ )}
230
+ </div>
231
+ ) : (
232
+ <DiagramCanvas
233
+ svgNode={render.svgNode}
234
+ targetSelector={finder.targetSelector}
235
+ pickTarget={commentsState.pickTarget}
236
+ className={canvasClassName}
237
+ dimmed={render.error !== null}
238
+ onSvgRoot={onSvgRoot}
239
+ onHoverElement={commentsState.setHoverElement}
240
+ onClickElement={commentsState.clickElement}
241
+ onEscape={onEscape}
242
+ overlay={overlay}
243
+ autoFocus={autoFocus}
244
+ >
245
+ {render.error !== null && (
246
+ <div
247
+ data-diagram-parse-error=""
248
+ role="alert"
249
+ className="absolute inset-x-3 top-3 z-10 rounded-md border border-warning/40 bg-card/95 px-3 py-2 text-xs shadow-lg backdrop-blur"
250
+ >
251
+ <div className="font-medium text-foreground">
252
+ {render.error.line === null ? 'The diagram does not parse' : `The diagram does not parse (line ${render.error.line})`}
253
+ </div>
254
+ <div className="mt-0.5 text-muted-foreground">
255
+ {render.error.message}
256
+ {' The last good render stays below.'}
257
+ </div>
258
+ </div>
259
+ )}
260
+ {/* The chip earns its place only while the pane is closed (the
261
+ draft survives a close); with the pane open its header carries
262
+ the one "Draft · unsaved". */}
263
+ {editable && draft.dirty && !sourceOpen && (
264
+ <span
265
+ data-diagram-preview-state=""
266
+ className="absolute left-3 top-3 z-10 rounded-sm border border-border bg-card/85 px-1.5 py-0.5 text-[10px] text-muted-foreground backdrop-blur"
267
+ >
268
+ Draft preview · unsaved
269
+ </span>
270
+ )}
271
+ </DiagramCanvas>
272
+ )}
273
+ </div>
274
+ </div>
275
+ );
276
+ }
@@ -0,0 +1,71 @@
1
+ import { createContext } from 'react';
2
+
3
+ /**
4
+ * Who shows a diagram comment that names no diagram block of the document.
5
+ *
6
+ * A comment composed in a block carries that block's `blockId`. One that
7
+ * arrives through `POST /api/external-annotations` carries `blockId:
8
+ * "external"`, and one whose fence was deleted carries an id that no longer
9
+ * exists; neither says which diagram it belongs to, but its anchor does.
10
+ * Every diagram block tries such a comment against its own render and
11
+ * reports the verdict here; the FIRST block in document order whose finder
12
+ * resolves it shows it, and when every block has answered and none did, it
13
+ * is unanchored (listed in the rail with the chip, never silently dropped).
14
+ * A block that has not answered yet keeps the verdict pending, so nothing is
15
+ * called unanchored while a diagram is still rendering.
16
+ */
17
+ export class DiagramAnchorClaims {
18
+ private readonly reports = new Map<string, Map<string, boolean>>();
19
+ private readonly listeners = new Set<() => void>();
20
+ private version = 0;
21
+
22
+ /** The diagram blocks of the document, in document order. */
23
+ constructor(readonly blockIds: readonly string[]) {}
24
+
25
+ /** A block's verdicts for the unowned comments it tried. Verdicts for ids
26
+ * it no longer tries are kept: a block stops trying a comment once another
27
+ * block owns it, and that must not read as "could not resolve". */
28
+ report(blockId: string, results: ReadonlyMap<string, boolean>): void {
29
+ let mine = this.reports.get(blockId);
30
+ let changed = false;
31
+ if (mine === undefined) {
32
+ mine = new Map();
33
+ this.reports.set(blockId, mine);
34
+ changed = true;
35
+ }
36
+ for (const [id, resolved] of results) {
37
+ if (mine.get(id) !== resolved) {
38
+ mine.set(id, resolved);
39
+ changed = true;
40
+ }
41
+ }
42
+ if (!changed) return;
43
+ this.version += 1;
44
+ for (const listener of [...this.listeners]) listener();
45
+ }
46
+
47
+ /** The block that shows the comment; `null` when every block answered and
48
+ * none resolved it (unanchored); `undefined` while an earlier block has
49
+ * not answered yet. */
50
+ owner(annotationId: string): string | null | undefined {
51
+ for (const blockId of this.blockIds) {
52
+ const verdict = this.reports.get(blockId)?.get(annotationId);
53
+ if (verdict === undefined) return undefined;
54
+ if (verdict) return blockId;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ readonly subscribe = (listener: () => void): (() => void) => {
60
+ this.listeners.add(listener);
61
+ return () => {
62
+ this.listeners.delete(listener);
63
+ };
64
+ };
65
+
66
+ readonly getVersion = (): number => this.version;
67
+ }
68
+
69
+ /** Provided by `Viewer` with the document's diagram block ids; a diagram
70
+ * block rendered on its own makes a one-block coordinator for itself. */
71
+ export const DiagramAnchorClaimsContext = createContext<DiagramAnchorClaims | null>(null);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The diagram engine's host surface (0.41.0): the one viewer over the
3
+ * renderer slot, its canvas, overlay, composer and Source pane, and the
4
+ * hooks and types a host wires them with. The renderer slot itself, the
5
+ * runtime slots and the anchor codecs live under `utils/` (`diagram-render`,
6
+ * `graphviz`, `mermaid`, `diagram-anchor`, `diagram-anchor-graphviz`,
7
+ * `diagram-projection`).
8
+ */
9
+ export { DiagramViewer, type DiagramViewerProps } from './DiagramViewer';
10
+ export { DiagramPopout } from './DiagramPopout';
11
+ export { DiagramCanvas, svgContentSize, KEY_PAN_PX, type DiagramCanvasHandle, type DiagramEscapeOutcome } from './DiagramCanvas';
12
+ export { DiagramOverlay } from './DiagramOverlay';
13
+ export { DiagramComposer } from './DiagramComposer';
14
+ export { DiagramSourcePane } from './DiagramSourcePane';
15
+ export {
16
+ useDiagramComments,
17
+ type DiagramComment,
18
+ type DiagramComposerDraft,
19
+ type DiagramCreateComment,
20
+ type DiagramHover,
21
+ type ResolvedDiagramComment,
22
+ } from './useDiagramComments';
23
+ export { useDiagramRender, type DiagramRenderState } from './useDiagramRender';
24
+ export { useDiagramSourceDraft, PREVIEW_DEBOUNCE_MS, type DiagramSourceDraft, type SaveResult } from './useDiagramSourceDraft';
25
+ export {
26
+ useDiagramViewport,
27
+ DRAG_THRESHOLD_PX,
28
+ TOUCH_DRAG_THRESHOLD_PX,
29
+ FIT_PADDING_PX,
30
+ WHEEL_ZOOM_SENSITIVITY,
31
+ ZOOM_MAX,
32
+ ZOOM_MIN,
33
+ ZOOM_STEP,
34
+ type ContentSize,
35
+ type Viewport,
36
+ } from './useDiagramViewport';