@plannotator/ui 0.40.0 → 0.41.1

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.
Files changed (35) hide show
  1. package/HANDOFF.md +110 -5
  2. package/README.md +6 -2
  3. package/components/DiagramBlock.tsx +363 -0
  4. package/components/GraphvizBlock.tsx +22 -597
  5. package/components/MermaidBlock.tsx +17 -664
  6. package/components/Viewer.tsx +60 -5
  7. package/components/diagram/DiagramCanvas.tsx +427 -0
  8. package/components/diagram/DiagramComposer.tsx +135 -0
  9. package/components/diagram/DiagramOverlay.tsx +215 -0
  10. package/components/diagram/DiagramPending.tsx +60 -0
  11. package/components/diagram/DiagramPopout.tsx +72 -0
  12. package/components/diagram/DiagramSourcePane.tsx +244 -0
  13. package/components/diagram/DiagramViewer.tsx +285 -0
  14. package/components/diagram/anchorClaims.ts +71 -0
  15. package/components/diagram/diagramControls.ts +41 -0
  16. package/components/diagram/index.ts +39 -0
  17. package/components/diagram/svgContentSize.ts +47 -0
  18. package/components/diagram/useDiagramComments.ts +341 -0
  19. package/components/diagram/useDiagramRender.ts +91 -0
  20. package/components/diagram/useDiagramSourceDraft.ts +143 -0
  21. package/components/diagram/useDiagramViewport.ts +156 -0
  22. package/components/html-viewer/bridge-script.asset.js +25 -0
  23. package/components/html-viewer/bridge-script.lite.ts +1 -1
  24. package/components/html-viewer/bridge-script.ts +37 -0
  25. package/hooks/useAnnotationHighlighter.ts +5 -0
  26. package/package.json +5 -3
  27. package/styles.css +1 -1
  28. package/types.ts +5 -0
  29. package/utils/diagram-anchor-graphviz.ts +143 -0
  30. package/utils/diagram-anchor.ts +401 -0
  31. package/utils/diagram-projection.ts +66 -0
  32. package/utils/diagram-render.ts +668 -0
  33. package/utils/graphviz.ts +93 -0
  34. package/utils/parser.ts +15 -1
  35. package/components/mermaidSvg.ts +0 -33
@@ -1,5 +1,5 @@
1
1
  import { generateId } from '../utils/generateId';
2
- import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
2
+ import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback, lazy, Suspense } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
4
  import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
5
5
  import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
@@ -18,6 +18,20 @@ import { useValidatedCodePaths } from '../hooks/useValidatedCodePaths';
18
18
  import { AnnotationToolbar } from './AnnotationToolbar';
19
19
  import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker';
20
20
 
21
+ /**
22
+ * The diagram engine — the renderer slot, the canvas, the comment overlay,
23
+ * the popout, and (through the viewer) CodeMirror — is loaded by the first
24
+ * diagram fence in the document and by nothing else. A markdown document
25
+ * with no diagram never reaches for it; a chunked host that statically
26
+ * imports this Viewer pays none of it on a plain document read.
27
+ *
28
+ * The Suspense fallback is the SAME pending state the block itself shows
29
+ * while its engine loads (`DiagramPending`), inside the same boxes, so the
30
+ * source fence paints once and the two waits read as one.
31
+ */
32
+ const MermaidBlock = lazy(async () => ({ default: (await import('./MermaidBlock')).MermaidBlock }));
33
+ const GraphvizBlock = lazy(async () => ({ default: (await import('./GraphvizBlock')).GraphvizBlock }));
34
+
21
35
  // Debug error boundary to catch silent toolbar crashes
22
36
  class ToolbarErrorBoundary extends React.Component<
23
37
  { children: React.ReactNode },
@@ -40,8 +54,8 @@ import { CommentPopover, type CommentAskAIHandler } from './CommentPopover';
40
54
  import { TaterSpriteSitting } from './TaterSpriteSitting';
41
55
  import { AttachmentsButton } from './AttachmentsButton';
42
56
  import { MessagesIcon } from './icons/MessagesIcon';
43
- import { GraphvizBlock } from './GraphvizBlock';
44
- import { MermaidBlock } from './MermaidBlock';
57
+ import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims';
58
+ import { DiagramBlockPending } from './diagram/DiagramPending';
45
59
  import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
46
60
  import { getIdentity } from '../utils/identity';
47
61
  import { type QuickLabel } from '../utils/quickLabels';
@@ -1042,8 +1056,28 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1042
1056
  </>
1043
1057
  );
1044
1058
 
1059
+ // The document's diagram blocks, in order: a diagram comment that names
1060
+ // none of them (an external POST, a deleted fence) is resolved by anchor
1061
+ // against each, first resolver wins (see diagram/anchorClaims).
1062
+ const diagramBlockKey = blocks
1063
+ .filter((b) => b.type === 'code' && (isMermaidLanguage(b.language) || isGraphvizLanguage(b.language)))
1064
+ .map((b) => b.id)
1065
+ .join('\n');
1066
+ const diagramClaims = useMemo(
1067
+ () => new DiagramAnchorClaims(diagramBlockKey === '' ? [] : diagramBlockKey.split('\n')),
1068
+ [diagramBlockKey],
1069
+ );
1070
+ // With no diagram in the document nobody can resolve a diagram comment:
1071
+ // it is unanchored, and the highlighter (which skips it) will not say so.
1072
+ useEffect(() => {
1073
+ if (diagramBlockKey !== '' || onRestoreReport === undefined) return;
1074
+ const ids = annotations.filter((ann) => ann.diagramAnchor !== undefined).map((ann) => ann.id);
1075
+ if (ids.length > 0) onRestoreReport({ attempted: ids, unanchored: ids });
1076
+ }, [annotations, diagramBlockKey, onRestoreReport]);
1077
+
1045
1078
  return (
1046
1079
  <CodePathValidationContext.Provider value={codePathValidation}>
1080
+ <DiagramAnchorClaimsContext.Provider value={diagramClaims}>
1047
1081
  <div className="relative z-50 w-full" style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}>
1048
1082
  {taterMode && <TaterSpriteSitting />}
1049
1083
  <article
@@ -1147,9 +1181,29 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1147
1181
  );
1148
1182
  })()
1149
1183
  ) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
1150
- <MermaidBlock key={group.block.id} block={group.block} />
1184
+ <Suspense key={group.block.id} fallback={<DiagramBlockPending block={group.block} kind="mermaid" />}>
1185
+ <MermaidBlock
1186
+ block={group.block}
1187
+ annotations={annotations}
1188
+ selectedAnnotationId={selectedAnnotationId}
1189
+ onSelectAnnotation={onSelectAnnotation}
1190
+ onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1191
+ readOnly={readOnly}
1192
+ onRestoreReport={onRestoreReport}
1193
+ />
1194
+ </Suspense>
1151
1195
  ) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
1152
- <GraphvizBlock key={group.block.id} block={group.block} />
1196
+ <Suspense key={group.block.id} fallback={<DiagramBlockPending block={group.block} kind="graphviz" />}>
1197
+ <GraphvizBlock
1198
+ block={group.block}
1199
+ annotations={annotations}
1200
+ selectedAnnotationId={selectedAnnotationId}
1201
+ onSelectAnnotation={onSelectAnnotation}
1202
+ onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1203
+ readOnly={readOnly}
1204
+ onRestoreReport={onRestoreReport}
1205
+ />
1206
+ </Suspense>
1153
1207
  ) : group.block.type === 'table' ? (
1154
1208
  <TableBlock
1155
1209
  key={group.block.id}
@@ -1444,6 +1498,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1444
1498
  document.body
1445
1499
  )}
1446
1500
  </div>
1501
+ </DiagramAnchorClaimsContext.Provider>
1447
1502
  </CodePathValidationContext.Provider>
1448
1503
  );
1449
1504
  });
@@ -0,0 +1,427 @@
1
+ import { Maximize2, Minus, Plus } from 'lucide-react';
2
+ import React, {
3
+ useCallback,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type KeyboardEvent as ReactKeyboardEvent,
10
+ type PointerEvent as ReactPointerEvent,
11
+ type ReactNode,
12
+ } from 'react';
13
+ import { cn } from '../../lib/utils';
14
+ import { diagramHitSource } from '../../utils/diagram-render';
15
+ import { isMac, isModKeyHeld } from '../../utils/platform';
16
+ import { Button } from '../ui/button';
17
+ import { isDiagramControlEvent } from './diagramControls';
18
+ import { svgContentSize } from './svgContentSize';
19
+ import {
20
+ DRAG_THRESHOLD_PX,
21
+ TOUCH_DRAG_THRESHOLD_PX,
22
+ useDiagramViewport,
23
+ WHEEL_ZOOM_SENSITIVITY,
24
+ ZOOM_STEP,
25
+ type ContentSize,
26
+ type Viewport,
27
+ } from './useDiagramViewport';
28
+
29
+ /**
30
+ * The canvas: the rendered svg inside a transformed wrapper, edge to edge in
31
+ * its host. Wheel and pinch zoom about the pointer, drag pans, `+` `-` `0`
32
+ * (fit) and the arrow keys on the keyboard, a small control in the corner.
33
+ * Click-to-select, drag-to-pan: a press that does not travel the drag
34
+ * threshold (4 px for a mouse or pen, 10 px for a finger) is a click and
35
+ * opens the composer; one that travels is a pan and never opens it. Nothing
36
+ * highlights on a plain mouse-over (the owner removed hover targeting: it
37
+ * read as messy and fought the pan hand); the only pre-click affordance is
38
+ * the ring under the pointer while the platform modifier is held, and it
39
+ * disarms on the key's release, on any other key, and on window blur.
40
+ *
41
+ * What a click means is decided over EVERYTHING under the pointer
42
+ * (`elementsFromPoint`), never the topmost element alone: every edge has an
43
+ * invisible 14 px hit path in a layer above the diagram (the render slot's
44
+ * `widenEdgeHitAreas`), so where an edge meets a node both are under the
45
+ * pointer, and the caller's `pickTarget` takes the node first, then an edge,
46
+ * then a cluster.
47
+ *
48
+ * In the document flow the canvas lets a finger scroll the page vertically
49
+ * (`touch-action: pan-y`); a full-size host that owns the screen passes
50
+ * `touch-none` through `className`. Under strict Mermaid disables click
51
+ * callbacks, and a `click A "https://..."` link binding still renders an
52
+ * `<a href>` that the render slot's sanitizer strips, so the canvas owns
53
+ * every click and there is no armed switch.
54
+ *
55
+ * Chrome painted OVER the canvas (the zoom strip, the composer, the source
56
+ * pane, the popout's header) is never a diagram target: a pointer event
57
+ * whose composed path contains a control resolves nothing, opens no
58
+ * composer and starts no pan (`diagramControls.ts`). Without that the
59
+ * `elementsFromPoint` walk stepped past the control to the part behind it,
60
+ * so pressing Zoom out over a node opened the composer on that node.
61
+ *
62
+ * The render slot hands over a sanitized svg NODE, not markup: the wrapper
63
+ * mounts it with `replaceChildren` once per render, so no html string ever
64
+ * crosses into the app DOM here. The overlay (a sibling of the wrapper,
65
+ * unscaled) is the caller's, rendered through `overlay` with the live
66
+ * viewport so rings reproject on every change.
67
+ */
68
+
69
+ // `svgContentSize` is the canvas's, but the document's fence block needs it
70
+ // without the canvas: it lives in its own dependency-free module and is
71
+ // re-exported here so every published path keeps resolving.
72
+ export { svgContentSize } from './svgContentSize';
73
+
74
+ /** One arrow-key press pans this far (the diagram moves WITH the arrow, as
75
+ * a scroll would); Shift multiplies it by five. */
76
+ export const KEY_PAN_PX = 40;
77
+
78
+ export interface DiagramCanvasHandle {
79
+ readonly viewport: Viewport;
80
+ readonly hostRef: React.RefObject<HTMLDivElement | null>;
81
+ readonly panIntoView: ReturnType<typeof useDiagramViewport>['panIntoView'];
82
+ }
83
+
84
+ /** What an Escape on the canvas did: `consumed` closed something the viewer
85
+ * owns (a draft, a selection) and the key goes no further; `pass` lets it
86
+ * reach whatever holds the canvas (a popout's close). */
87
+ export type DiagramEscapeOutcome = 'consumed' | 'pass';
88
+
89
+ export function DiagramCanvas({
90
+ svgNode,
91
+ targetSelector,
92
+ pickTarget,
93
+ dimmed,
94
+ onSvgRoot,
95
+ onHoverElement,
96
+ onClickElement,
97
+ onEscape,
98
+ overlay,
99
+ children,
100
+ autoFocus,
101
+ className,
102
+ }: {
103
+ /** The last good render's sanitized svg root, or null before the first. */
104
+ svgNode: SVGSVGElement | null;
105
+ /** The engine's selector of every element the pointer can address
106
+ * (the finder's, through the renderer slot). */
107
+ targetSelector: string;
108
+ /** Choose among the addressable elements under the pointer, topmost
109
+ * first (the viewer's rule: a node, then an edge, then a cluster).
110
+ * Default: the topmost. */
111
+ pickTarget?: (candidates: readonly Element[]) => Element | null;
112
+ /** A parse error keeps the last render under the strip, dimmed. */
113
+ dimmed: boolean;
114
+ /** The mounted svg root after each injection (null on unmount). */
115
+ onSvgRoot: (root: SVGSVGElement | null) => void;
116
+ onHoverElement: (element: Element | null) => void;
117
+ onClickElement: (element: Element | null, shiftKey: boolean) => void;
118
+ onEscape: () => DiagramEscapeOutcome;
119
+ /** The overlay layer, given the live viewport and host. */
120
+ overlay: (handle: DiagramCanvasHandle) => ReactNode;
121
+ children?: ReactNode;
122
+ /** Take the keyboard on mount so `+`, `-`, `0` and Escape work at once
123
+ * (a popout). Off in the document flow, where it would steal the focus
124
+ * from the reader. */
125
+ autoFocus?: boolean;
126
+ /** `touch-none` for a host that owns the whole screen (a popout); the
127
+ * default lets a finger scroll the page past an inline diagram. */
128
+ className?: string;
129
+ }) {
130
+ const hostRef = useRef<HTMLDivElement | null>(null);
131
+ const wrapperRef = useRef<HTMLDivElement | null>(null);
132
+ const [content, setContent] = useState<ContentSize | null>(null);
133
+ const { viewport, fit, zoomBy, panBy, panIntoView } = useDiagramViewport(hostRef, content);
134
+
135
+ // Mount the sanitized node once per render and size the wrapper to the
136
+ // diagram's own box so the transform scales real pixels.
137
+ useLayoutEffect(() => {
138
+ const wrapper = wrapperRef.current;
139
+ if (wrapper === null) return;
140
+ if (svgNode === null) {
141
+ wrapper.replaceChildren();
142
+ setContent(null);
143
+ onSvgRoot(null);
144
+ return;
145
+ }
146
+ wrapper.replaceChildren(svgNode);
147
+ const size = svgContentSize(svgNode);
148
+ if (size !== null) {
149
+ svgNode.style.width = '100%';
150
+ svgNode.style.height = '100%';
151
+ svgNode.style.maxWidth = 'none';
152
+ }
153
+ setContent(size);
154
+ onSvgRoot(svgNode);
155
+ return () => onSvgRoot(null);
156
+ }, [onSvgRoot, svgNode]);
157
+
158
+ useEffect(() => {
159
+ if (autoFocus) hostRef.current?.focus();
160
+ }, [autoFocus]);
161
+
162
+ // Wheel zoom needs a non-passive listener (React's onWheel is passive,
163
+ // so preventDefault there cannot stop the page from scrolling).
164
+ useEffect(() => {
165
+ const host = hostRef.current;
166
+ if (host === null) return;
167
+ const onWheel = (event: WheelEvent) => {
168
+ // A sub-pixel delta (a trackpad coming to rest, a horizontal swipe)
169
+ // is neither a zoom nor a reason to hold the page's scroll.
170
+ if (Math.abs(event.deltaY) < 0.1) return;
171
+ event.preventDefault();
172
+ zoomBy(Math.exp(-event.deltaY * WHEEL_ZOOM_SENSITIVITY), event.clientX, event.clientY);
173
+ };
174
+ host.addEventListener('wheel', onWheel, { passive: false });
175
+ return () => host.removeEventListener('wheel', onWheel);
176
+ }, [zoomBy]);
177
+
178
+ const targetUnder = useCallback(
179
+ (event: ReactPointerEvent): Element | null => {
180
+ const wrapper = wrapperRef.current;
181
+ if (wrapper === null) return null;
182
+ // The zoom strip and the composer are painted OVER the canvas and are
183
+ // not in the svg, so the walk below would step past them to whatever
184
+ // part sits underneath. A press on chrome addresses no part.
185
+ if (isDiagramControlEvent(event)) return null;
186
+ // Everything under the pointer, topmost first. Without a layout
187
+ // engine (happy-dom) the event's own target is all there is.
188
+ const doc = wrapper.ownerDocument;
189
+ const stack =
190
+ typeof doc.elementsFromPoint === 'function' ? doc.elementsFromPoint(event.clientX, event.clientY) : [];
191
+ const under = stack.length > 0 ? stack : event.target instanceof Element ? [event.target] : [];
192
+ const candidates: Element[] = [];
193
+ for (const el of under) {
194
+ if (!wrapper.contains(el)) continue;
195
+ // A hit path stands for the visible edge it was made from.
196
+ const node = diagramHitSource(el) ?? el;
197
+ const target = node.closest(targetSelector);
198
+ if (target !== null && wrapper.contains(target) && !candidates.includes(target)) candidates.push(target);
199
+ }
200
+ if (candidates.length === 0) return null;
201
+ return pickTarget ? pickTarget(candidates) : (candidates[0] ?? null);
202
+ },
203
+ [pickTarget, targetSelector],
204
+ );
205
+
206
+ // The modifier-gated ring disarms the way the token hover cards do: on
207
+ // the modifier's release, on any other key while it is held (Cmd+C is a
208
+ // copy, not a question about the part under the pointer), and on blur.
209
+ useEffect(() => {
210
+ const win = hostRef.current?.ownerDocument.defaultView;
211
+ if (!win) return;
212
+ const modKey = isMac ? 'Meta' : 'Control';
213
+ const onKeyUp = (event: KeyboardEvent) => {
214
+ if (event.key === modKey) onHoverElement(null);
215
+ };
216
+ const onKeyDown = (event: KeyboardEvent) => {
217
+ if (event.key !== modKey) onHoverElement(null);
218
+ };
219
+ const onBlur = () => onHoverElement(null);
220
+ win.addEventListener('keyup', onKeyUp);
221
+ win.addEventListener('keydown', onKeyDown);
222
+ win.addEventListener('blur', onBlur);
223
+ return () => {
224
+ win.removeEventListener('keyup', onKeyUp);
225
+ win.removeEventListener('keydown', onKeyDown);
226
+ win.removeEventListener('blur', onBlur);
227
+ };
228
+ }, [onHoverElement]);
229
+
230
+ // Press bookkeeping: where the pointer went down and whether it became a
231
+ // pan. Pointer capture keeps the pan alive past the host's edge.
232
+ const pressRef = useRef<{
233
+ id: number;
234
+ x: number;
235
+ y: number;
236
+ lastX: number;
237
+ lastY: number;
238
+ threshold: number;
239
+ panning: boolean;
240
+ } | null>(null);
241
+ const [panning, setPanning] = useState(false);
242
+
243
+ const onPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
244
+ if (event.button !== 0) return;
245
+ // Pressing a control is not the start of a pan either.
246
+ if (isDiagramControlEvent(event)) {
247
+ pressRef.current = null;
248
+ return;
249
+ }
250
+ pressRef.current = {
251
+ id: event.pointerId,
252
+ x: event.clientX,
253
+ y: event.clientY,
254
+ lastX: event.clientX,
255
+ lastY: event.clientY,
256
+ // A fingertip wobbles more than a mouse: a tap must still be a tap.
257
+ threshold: event.pointerType === 'touch' ? TOUCH_DRAG_THRESHOLD_PX : DRAG_THRESHOLD_PX,
258
+ panning: false,
259
+ };
260
+ }, []);
261
+
262
+ const onPointerMove = useCallback(
263
+ (event: ReactPointerEvent<HTMLDivElement>) => {
264
+ const press = pressRef.current;
265
+ if (press === null || press.id !== event.pointerId) {
266
+ // Nothing highlights on a plain mouse-over; the ring under the
267
+ // pointer exists only under the platform modifier.
268
+ onHoverElement(isModKeyHeld(event) ? targetUnder(event) : null);
269
+ return;
270
+ }
271
+ if (!press.panning) {
272
+ const travelled = Math.hypot(event.clientX - press.x, event.clientY - press.y);
273
+ if (travelled < press.threshold) return;
274
+ press.panning = true;
275
+ setPanning(true);
276
+ onHoverElement(null);
277
+ if (typeof event.currentTarget.setPointerCapture === 'function') {
278
+ event.currentTarget.setPointerCapture(event.pointerId);
279
+ }
280
+ }
281
+ panBy(event.clientX - press.lastX, event.clientY - press.lastY);
282
+ press.lastX = event.clientX;
283
+ press.lastY = event.clientY;
284
+ },
285
+ [onHoverElement, panBy, targetUnder],
286
+ );
287
+
288
+ const endPress = useCallback(
289
+ (event: ReactPointerEvent<HTMLDivElement>, click: boolean) => {
290
+ const press = pressRef.current;
291
+ if (press === null || press.id !== event.pointerId) return;
292
+ pressRef.current = null;
293
+ // Released over a control (the press began on the canvas): not a click
294
+ // on the diagram, and `targetUnder` answering null would otherwise
295
+ // read as "comment on the whole diagram".
296
+ if (isDiagramControlEvent(event)) {
297
+ if (press.panning) setPanning(false);
298
+ return;
299
+ }
300
+ if (press.panning) {
301
+ setPanning(false);
302
+ const target = event.currentTarget;
303
+ if (typeof target.hasPointerCapture === 'function' && target.hasPointerCapture(event.pointerId)) {
304
+ target.releasePointerCapture(event.pointerId);
305
+ }
306
+ return;
307
+ }
308
+ if (click) onClickElement(targetUnder(event), event.shiftKey);
309
+ },
310
+ [onClickElement, targetUnder],
311
+ );
312
+
313
+ const onKeyDown = useCallback(
314
+ (event: ReactKeyboardEvent<HTMLDivElement>) => {
315
+ // Keys act on the canvas itself, never on the composer's textarea.
316
+ if (event.target !== event.currentTarget) return;
317
+ // Mod+0, Mod+-, Alt+Arrow belong to the browser (page zoom, history).
318
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
319
+ switch (event.key) {
320
+ case '+':
321
+ case '=':
322
+ zoomBy(ZOOM_STEP);
323
+ break;
324
+ case '-':
325
+ case '_':
326
+ zoomBy(1 / ZOOM_STEP);
327
+ break;
328
+ case '0':
329
+ fit();
330
+ break;
331
+ case 'ArrowLeft':
332
+ panBy(event.shiftKey ? KEY_PAN_PX * 5 : KEY_PAN_PX, 0);
333
+ break;
334
+ case 'ArrowRight':
335
+ panBy(event.shiftKey ? -KEY_PAN_PX * 5 : -KEY_PAN_PX, 0);
336
+ break;
337
+ case 'ArrowUp':
338
+ panBy(0, event.shiftKey ? KEY_PAN_PX * 5 : KEY_PAN_PX);
339
+ break;
340
+ case 'ArrowDown':
341
+ panBy(0, event.shiftKey ? -KEY_PAN_PX * 5 : -KEY_PAN_PX);
342
+ break;
343
+ case 'Escape': {
344
+ if (onEscape() !== 'consumed') return;
345
+ event.stopPropagation();
346
+ break;
347
+ }
348
+ default:
349
+ return;
350
+ }
351
+ event.preventDefault();
352
+ },
353
+ [fit, onEscape, panBy, zoomBy],
354
+ );
355
+
356
+ const handle = useMemo<DiagramCanvasHandle>(() => ({ viewport, hostRef, panIntoView }), [panIntoView, viewport]);
357
+
358
+ return (
359
+ <div
360
+ ref={hostRef}
361
+ data-diagram-canvas=""
362
+ tabIndex={0}
363
+ aria-label="Diagram canvas. Drag or arrow keys to pan, wheel or plus and minus to zoom, 0 to fit, click a part to comment."
364
+ className={cn(
365
+ 'relative h-full w-full touch-pan-y select-none overflow-hidden outline-none',
366
+ panning ? 'cursor-grabbing' : 'cursor-grab',
367
+ className,
368
+ )}
369
+ onPointerDown={onPointerDown}
370
+ onPointerMove={onPointerMove}
371
+ onPointerUp={(event) => endPress(event, true)}
372
+ onPointerCancel={(event) => endPress(event, false)}
373
+ onPointerLeave={() => onHoverElement(null)}
374
+ onKeyDown={onKeyDown}
375
+ >
376
+ <div
377
+ ref={wrapperRef}
378
+ data-diagram-svg=""
379
+ className={cn('absolute left-0 top-0 origin-top-left transition-opacity duration-150', dimmed && 'opacity-40')}
380
+ style={{
381
+ width: content?.width,
382
+ height: content?.height,
383
+ transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
384
+ }}
385
+ />
386
+ {overlay(handle)}
387
+ {children}
388
+ <div
389
+ // Screen chrome: never printed (rings and badges are, they are the
390
+ // comments). On a narrow screen the strip takes the left edge so it
391
+ // never stacks under a host's own bottom-right controls.
392
+ data-print-hide=""
393
+ data-diagram-control=""
394
+ data-diagram-zoom-strip=""
395
+ className="absolute bottom-3 right-3 z-10 flex items-center gap-0.5 rounded-md border border-border bg-card/85 p-0.5 backdrop-blur max-md:bottom-4 max-md:left-4 max-md:right-auto"
396
+ role="group"
397
+ aria-label="Zoom"
398
+ >
399
+ <Button
400
+ type="button"
401
+ variant="ghost"
402
+ size="icon"
403
+ className="size-7"
404
+ aria-label="Zoom out"
405
+ title="Zoom out (-)"
406
+ onClick={() => zoomBy(1 / ZOOM_STEP)}
407
+ >
408
+ <Minus aria-hidden="true" />
409
+ </Button>
410
+ <Button type="button" variant="ghost" size="icon" className="size-7" aria-label="Fit diagram" title="Fit (0)" onClick={fit}>
411
+ <Maximize2 aria-hidden="true" />
412
+ </Button>
413
+ <Button
414
+ type="button"
415
+ variant="ghost"
416
+ size="icon"
417
+ className="size-7"
418
+ aria-label="Zoom in"
419
+ title="Zoom in (+)"
420
+ onClick={() => zoomBy(ZOOM_STEP)}
421
+ >
422
+ <Plus aria-hidden="true" />
423
+ </Button>
424
+ </div>
425
+ </div>
426
+ );
427
+ }
@@ -0,0 +1,135 @@
1
+ import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
2
+ import { diagramTargetName, diagramTargetText } from '@plannotator/core/diagram-anchor';
3
+ import { cn } from '../../lib/utils';
4
+ import type { ScreenRect } from '../../utils/diagram-projection';
5
+ import { Button } from '../ui/button';
6
+ import type { DiagramComposerDraft } from './useDiagramComments';
7
+
8
+ /**
9
+ * The inline composer beside the ring: the part's label as the context
10
+ * line, a textarea, Cancel and Comment. Enter saves, Shift+Enter breaks a
11
+ * line and Esc discards, and none of the three is spelled on screen (owner
12
+ * ruling; the markdown composer shows no hint either). It lives here
13
+ * because the markdown composer is bound to web-highlighter's selection and
14
+ * the html composer to the sandbox bridge; neither reaches an app-owned svg.
15
+ */
16
+
17
+ /** The composer's width; it sits to the right of the ring and flips left
18
+ * when the host is too narrow there. */
19
+ const COMPOSER_WIDTH_PX = 288;
20
+
21
+ export function DiagramComposer({
22
+ draft,
23
+ anchorRect,
24
+ hostWidth,
25
+ sourceDirty,
26
+ submitting,
27
+ error,
28
+ disabledReason,
29
+ onSubmit,
30
+ onCancel,
31
+ }: {
32
+ draft: DiagramComposerDraft;
33
+ anchorRect: ScreenRect;
34
+ hostWidth: number;
35
+ sourceDirty: boolean;
36
+ submitting: boolean;
37
+ /** The last submit's failure, shown under the textarea. */
38
+ error: string | null;
39
+ /** When set, commenting is off for this viewer and the composer says
40
+ * why instead of offering a textarea. */
41
+ disabledReason?: string;
42
+ onSubmit: (body: string) => void;
43
+ onCancel: () => void;
44
+ }) {
45
+ const [text, setText] = useState('');
46
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null);
47
+ useEffect(() => {
48
+ textareaRef.current?.focus();
49
+ }, []);
50
+
51
+ const target = draft.primary.target;
52
+ const unsavedPart = draft.sourceLine === null && sourceDirty;
53
+ const canWrite = disabledReason === undefined && !unsavedPart;
54
+ const rightFits = anchorRect.left + anchorRect.width + 12 + COMPOSER_WIDTH_PX <= hostWidth;
55
+ const left = rightFits ? anchorRect.left + anchorRect.width + 12 : Math.max(0, anchorRect.left - COMPOSER_WIDTH_PX - 12);
56
+
57
+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
58
+ if (event.key === 'Escape') {
59
+ // Ours: the canvas and whatever holds it (a popout) must not also
60
+ // act on this Escape.
61
+ event.preventDefault();
62
+ event.stopPropagation();
63
+ onCancel();
64
+ return;
65
+ }
66
+ if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
67
+ event.preventDefault();
68
+ if (canWrite && text.trim() !== '') onSubmit(text);
69
+ }
70
+ };
71
+
72
+ return (
73
+ <div
74
+ data-diagram-composer=""
75
+ role="dialog"
76
+ aria-label={`Comment on ${diagramTargetText(target)}`}
77
+ className="pointer-events-auto absolute z-10 flex w-72 flex-col gap-1.5 rounded-md border border-border bg-card p-2 shadow-lg"
78
+ style={{ left, top: Math.max(0, anchorRect.top) }}
79
+ onPointerDown={(event) => event.stopPropagation()}
80
+ onPointerUp={(event) => event.stopPropagation()}
81
+ onKeyDown={(event) => {
82
+ // Keys inside the composer never reach the canvas's zoom keys.
83
+ event.stopPropagation();
84
+ }}
85
+ >
86
+ <div className="text-[10px] text-muted-foreground">
87
+ On <span className="font-medium text-foreground">{diagramTargetText(target)}</span> ({diagramTargetName(target)})
88
+ {draft.sourceLine !== null && ` · line ${draft.sourceLine[0]}`}
89
+ {draft.additional.length > 0 && ` · +${draft.additional.length} more`}
90
+ </div>
91
+ {disabledReason === undefined ? (
92
+ <>
93
+ {unsavedPart && (
94
+ <p className="text-[10px] text-warning">This part is only in your unsaved draft. Save the diagram to comment on it.</p>
95
+ )}
96
+ <textarea
97
+ ref={textareaRef}
98
+ value={text}
99
+ onChange={(event) => setText(event.target.value)}
100
+ onKeyDown={onKeyDown}
101
+ placeholder="Add a comment..."
102
+ rows={3}
103
+ disabled={submitting}
104
+ className={cn(
105
+ 'w-full resize-none rounded-md border border-input bg-background px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground',
106
+ 'outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
107
+ )}
108
+ />
109
+ {error !== null && (
110
+ <p role="alert" className="text-[10px] text-destructive">
111
+ {error}
112
+ </p>
113
+ )}
114
+ <div className="flex items-center justify-end gap-1">
115
+ <Button type="button" variant="ghost" size="xs" onClick={onCancel}>
116
+ Cancel
117
+ </Button>
118
+ <Button type="button" size="xs" disabled={!canWrite || submitting || text.trim() === ''} onClick={() => onSubmit(text)}>
119
+ Comment
120
+ </Button>
121
+ </div>
122
+ </>
123
+ ) : (
124
+ <>
125
+ <p className="text-xs text-muted-foreground">{disabledReason}</p>
126
+ <div className="flex justify-end">
127
+ <Button type="button" variant="ghost" size="xs" onClick={onCancel}>
128
+ Close
129
+ </Button>
130
+ </div>
131
+ </>
132
+ )}
133
+ </div>
134
+ );
135
+ }