@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.
Files changed (52) hide show
  1. package/HANDOFF.md +140 -10
  2. package/README.md +9 -5
  3. package/components/AnnotationPanel.tsx +244 -13
  4. package/components/CommentPopover.tsx +91 -2
  5. package/components/DiagramBlock.tsx +376 -0
  6. package/components/GraphvizBlock.tsx +22 -597
  7. package/components/HtmlSurfaceControls.tsx +188 -23
  8. package/components/ListMarker.tsx +10 -1
  9. package/components/MermaidBlock.tsx +17 -630
  10. package/components/TableOfContents.tsx +5 -1
  11. package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
  12. package/components/Viewer.tsx +67 -3
  13. package/components/blocks/AlertBlock.tsx +7 -2
  14. package/components/diagram/DiagramCanvas.tsx +431 -0
  15. package/components/diagram/DiagramComposer.tsx +135 -0
  16. package/components/diagram/DiagramOverlay.tsx +215 -0
  17. package/components/diagram/DiagramPopout.tsx +70 -0
  18. package/components/diagram/DiagramSourcePane.tsx +244 -0
  19. package/components/diagram/DiagramViewer.tsx +276 -0
  20. package/components/diagram/anchorClaims.ts +71 -0
  21. package/components/diagram/index.ts +36 -0
  22. package/components/diagram/useDiagramComments.ts +341 -0
  23. package/components/diagram/useDiagramRender.ts +91 -0
  24. package/components/diagram/useDiagramSourceDraft.ts +143 -0
  25. package/components/diagram/useDiagramViewport.ts +156 -0
  26. package/components/html-viewer/HtmlViewer.tsx +32 -0
  27. package/components/html-viewer/bridge-script.asset.js +121 -9
  28. package/components/html-viewer/bridge-script.lite.ts +1 -1
  29. package/components/html-viewer/bridge-script.ts +133 -9
  30. package/components/html-viewer/useHtmlAnnotation.ts +44 -151
  31. package/hooks/useAnnotationHighlighter.ts +469 -14
  32. package/hooks/useLinkedDoc.ts +100 -9
  33. package/package.json +6 -4
  34. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
  35. package/styles.css +1 -1
  36. package/theme.css +62 -0
  37. package/types.ts +5 -0
  38. package/utils/annotationScope.ts +159 -0
  39. package/utils/cssColor.ts +463 -0
  40. package/utils/diagram-anchor-graphviz.ts +143 -0
  41. package/utils/diagram-anchor.ts +401 -0
  42. package/utils/diagram-projection.ts +66 -0
  43. package/utils/diagram-render.ts +668 -0
  44. package/utils/graphviz.ts +93 -0
  45. package/utils/htmlChrome.ts +70 -5
  46. package/utils/htmlLinkNavigation.ts +196 -0
  47. package/utils/mermaid-eager.ts +13 -11
  48. package/utils/mermaid.ts +19 -10
  49. package/utils/mermaidTheme.ts +732 -0
  50. package/utils/parser.ts +36 -7
  51. package/utils/terminalToolsAnnouncement.ts +76 -0
  52. package/components/mermaidSvg.ts +0 -33
@@ -0,0 +1,431 @@
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 {
18
+ DRAG_THRESHOLD_PX,
19
+ TOUCH_DRAG_THRESHOLD_PX,
20
+ useDiagramViewport,
21
+ WHEEL_ZOOM_SENSITIVITY,
22
+ ZOOM_STEP,
23
+ type ContentSize,
24
+ type Viewport,
25
+ } from './useDiagramViewport';
26
+
27
+ /**
28
+ * The canvas: the rendered svg inside a transformed wrapper, edge to edge in
29
+ * its host. Wheel and pinch zoom about the pointer, drag pans, `+` `-` `0`
30
+ * (fit) and the arrow keys on the keyboard, a small control in the corner.
31
+ * Click-to-select, drag-to-pan: a press that does not travel the drag
32
+ * threshold (4 px for a mouse or pen, 10 px for a finger) is a click and
33
+ * opens the composer; one that travels is a pan and never opens it. Nothing
34
+ * highlights on a plain mouse-over (the owner removed hover targeting: it
35
+ * read as messy and fought the pan hand); the only pre-click affordance is
36
+ * the ring under the pointer while the platform modifier is held, and it
37
+ * disarms on the key's release, on any other key, and on window blur.
38
+ *
39
+ * What a click means is decided over EVERYTHING under the pointer
40
+ * (`elementsFromPoint`), never the topmost element alone: every edge has an
41
+ * invisible 14 px hit path in a layer above the diagram (the render slot's
42
+ * `widenEdgeHitAreas`), so where an edge meets a node both are under the
43
+ * pointer, and the caller's `pickTarget` takes the node first, then an edge,
44
+ * then a cluster.
45
+ *
46
+ * In the document flow the canvas lets a finger scroll the page vertically
47
+ * (`touch-action: pan-y`); a full-size host that owns the screen passes
48
+ * `touch-none` through `className`. Under strict Mermaid disables click
49
+ * callbacks, and a `click A "https://..."` link binding still renders an
50
+ * `<a href>` that the render slot's sanitizer strips, so the canvas owns
51
+ * every click and there is no armed switch.
52
+ *
53
+ * The render slot hands over a sanitized svg NODE, not markup: the wrapper
54
+ * mounts it with `replaceChildren` once per render, so no html string ever
55
+ * crosses into the app DOM here. The overlay (a sibling of the wrapper,
56
+ * unscaled) is the caller's, rendered through `overlay` with the live
57
+ * viewport so rings reproject on every change.
58
+ */
59
+
60
+ /** A numeric svg length: `206`, `206pt`, `206px`. */
61
+ function svgLength(value: string | null): number {
62
+ if (value === null) return Number.NaN;
63
+ return Number.parseFloat(value);
64
+ }
65
+
66
+ /** The svg's intrinsic size, from its viewBox (Mermaid and Graphviz always
67
+ * write one), else its `width` / `height` attributes (a `pt` or `px` suffix
68
+ * is accepted, as Graphviz writes them). */
69
+ export function svgContentSize(svg: SVGSVGElement): ContentSize | null {
70
+ const viewBox = svg.getAttribute('viewBox');
71
+ if (viewBox !== null) {
72
+ const parts = viewBox
73
+ .trim()
74
+ .split(/[\s,]+/u)
75
+ .map(Number);
76
+ const width = parts[2];
77
+ const height = parts[3];
78
+ if (
79
+ parts.length === 4 &&
80
+ width !== undefined &&
81
+ height !== undefined &&
82
+ Number.isFinite(width) &&
83
+ Number.isFinite(height) &&
84
+ width > 0 &&
85
+ height > 0
86
+ ) {
87
+ return { width, height };
88
+ }
89
+ }
90
+ const width = svgLength(svg.getAttribute('width'));
91
+ const height = svgLength(svg.getAttribute('height'));
92
+ return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ? { width, height } : null;
93
+ }
94
+
95
+ /** One arrow-key press pans this far (the diagram moves WITH the arrow, as
96
+ * a scroll would); Shift multiplies it by five. */
97
+ export const KEY_PAN_PX = 40;
98
+
99
+ export interface DiagramCanvasHandle {
100
+ readonly viewport: Viewport;
101
+ readonly hostRef: React.RefObject<HTMLDivElement | null>;
102
+ readonly panIntoView: ReturnType<typeof useDiagramViewport>['panIntoView'];
103
+ }
104
+
105
+ /** What an Escape on the canvas did: `consumed` closed something the viewer
106
+ * owns (a draft, a selection) and the key goes no further; `pass` lets it
107
+ * reach whatever holds the canvas (a popout's close). */
108
+ export type DiagramEscapeOutcome = 'consumed' | 'pass';
109
+
110
+ export function DiagramCanvas({
111
+ svgNode,
112
+ targetSelector,
113
+ pickTarget,
114
+ dimmed,
115
+ onSvgRoot,
116
+ onHoverElement,
117
+ onClickElement,
118
+ onEscape,
119
+ overlay,
120
+ children,
121
+ autoFocus,
122
+ className,
123
+ }: {
124
+ /** The last good render's sanitized svg root, or null before the first. */
125
+ svgNode: SVGSVGElement | null;
126
+ /** The engine's selector of every element the pointer can address
127
+ * (the finder's, through the renderer slot). */
128
+ targetSelector: string;
129
+ /** Choose among the addressable elements under the pointer, topmost
130
+ * first (the viewer's rule: a node, then an edge, then a cluster).
131
+ * Default: the topmost. */
132
+ pickTarget?: (candidates: readonly Element[]) => Element | null;
133
+ /** A parse error keeps the last render under the strip, dimmed. */
134
+ dimmed: boolean;
135
+ /** The mounted svg root after each injection (null on unmount). */
136
+ onSvgRoot: (root: SVGSVGElement | null) => void;
137
+ onHoverElement: (element: Element | null) => void;
138
+ onClickElement: (element: Element | null, shiftKey: boolean) => void;
139
+ onEscape: () => DiagramEscapeOutcome;
140
+ /** The overlay layer, given the live viewport and host. */
141
+ overlay: (handle: DiagramCanvasHandle) => ReactNode;
142
+ children?: ReactNode;
143
+ /** Take the keyboard on mount so `+`, `-`, `0` and Escape work at once
144
+ * (a popout). Off in the document flow, where it would steal the focus
145
+ * from the reader. */
146
+ autoFocus?: boolean;
147
+ /** `touch-none` for a host that owns the whole screen (a popout); the
148
+ * default lets a finger scroll the page past an inline diagram. */
149
+ className?: string;
150
+ }) {
151
+ const hostRef = useRef<HTMLDivElement | null>(null);
152
+ const wrapperRef = useRef<HTMLDivElement | null>(null);
153
+ const [content, setContent] = useState<ContentSize | null>(null);
154
+ const { viewport, fit, zoomBy, panBy, panIntoView } = useDiagramViewport(hostRef, content);
155
+
156
+ // Mount the sanitized node once per render and size the wrapper to the
157
+ // diagram's own box so the transform scales real pixels.
158
+ useLayoutEffect(() => {
159
+ const wrapper = wrapperRef.current;
160
+ if (wrapper === null) return;
161
+ if (svgNode === null) {
162
+ wrapper.replaceChildren();
163
+ setContent(null);
164
+ onSvgRoot(null);
165
+ return;
166
+ }
167
+ wrapper.replaceChildren(svgNode);
168
+ const size = svgContentSize(svgNode);
169
+ if (size !== null) {
170
+ svgNode.style.width = '100%';
171
+ svgNode.style.height = '100%';
172
+ svgNode.style.maxWidth = 'none';
173
+ }
174
+ setContent(size);
175
+ onSvgRoot(svgNode);
176
+ return () => onSvgRoot(null);
177
+ }, [onSvgRoot, svgNode]);
178
+
179
+ useEffect(() => {
180
+ if (autoFocus) hostRef.current?.focus();
181
+ }, [autoFocus]);
182
+
183
+ // Wheel zoom needs a non-passive listener (React's onWheel is passive,
184
+ // so preventDefault there cannot stop the page from scrolling).
185
+ useEffect(() => {
186
+ const host = hostRef.current;
187
+ if (host === null) return;
188
+ const onWheel = (event: WheelEvent) => {
189
+ // A sub-pixel delta (a trackpad coming to rest, a horizontal swipe)
190
+ // is neither a zoom nor a reason to hold the page's scroll.
191
+ if (Math.abs(event.deltaY) < 0.1) return;
192
+ event.preventDefault();
193
+ zoomBy(Math.exp(-event.deltaY * WHEEL_ZOOM_SENSITIVITY), event.clientX, event.clientY);
194
+ };
195
+ host.addEventListener('wheel', onWheel, { passive: false });
196
+ return () => host.removeEventListener('wheel', onWheel);
197
+ }, [zoomBy]);
198
+
199
+ const targetUnder = useCallback(
200
+ (event: ReactPointerEvent): Element | null => {
201
+ const wrapper = wrapperRef.current;
202
+ if (wrapper === null) return null;
203
+ // Everything under the pointer, topmost first. Without a layout
204
+ // engine (happy-dom) the event's own target is all there is.
205
+ const doc = wrapper.ownerDocument;
206
+ const stack =
207
+ typeof doc.elementsFromPoint === 'function' ? doc.elementsFromPoint(event.clientX, event.clientY) : [];
208
+ const under = stack.length > 0 ? stack : event.target instanceof Element ? [event.target] : [];
209
+ const candidates: Element[] = [];
210
+ for (const el of under) {
211
+ if (!wrapper.contains(el)) continue;
212
+ // A hit path stands for the visible edge it was made from.
213
+ const node = diagramHitSource(el) ?? el;
214
+ const target = node.closest(targetSelector);
215
+ if (target !== null && wrapper.contains(target) && !candidates.includes(target)) candidates.push(target);
216
+ }
217
+ if (candidates.length === 0) return null;
218
+ return pickTarget ? pickTarget(candidates) : (candidates[0] ?? null);
219
+ },
220
+ [pickTarget, targetSelector],
221
+ );
222
+
223
+ // The modifier-gated ring disarms the way the token hover cards do: on
224
+ // the modifier's release, on any other key while it is held (Cmd+C is a
225
+ // copy, not a question about the part under the pointer), and on blur.
226
+ useEffect(() => {
227
+ const win = hostRef.current?.ownerDocument.defaultView;
228
+ if (!win) return;
229
+ const modKey = isMac ? 'Meta' : 'Control';
230
+ const onKeyUp = (event: KeyboardEvent) => {
231
+ if (event.key === modKey) onHoverElement(null);
232
+ };
233
+ const onKeyDown = (event: KeyboardEvent) => {
234
+ if (event.key !== modKey) onHoverElement(null);
235
+ };
236
+ const onBlur = () => onHoverElement(null);
237
+ win.addEventListener('keyup', onKeyUp);
238
+ win.addEventListener('keydown', onKeyDown);
239
+ win.addEventListener('blur', onBlur);
240
+ return () => {
241
+ win.removeEventListener('keyup', onKeyUp);
242
+ win.removeEventListener('keydown', onKeyDown);
243
+ win.removeEventListener('blur', onBlur);
244
+ };
245
+ }, [onHoverElement]);
246
+
247
+ // Press bookkeeping: where the pointer went down and whether it became a
248
+ // pan. Pointer capture keeps the pan alive past the host's edge.
249
+ const pressRef = useRef<{
250
+ id: number;
251
+ x: number;
252
+ y: number;
253
+ lastX: number;
254
+ lastY: number;
255
+ threshold: number;
256
+ panning: boolean;
257
+ } | null>(null);
258
+ const [panning, setPanning] = useState(false);
259
+
260
+ const onPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
261
+ if (event.button !== 0) return;
262
+ pressRef.current = {
263
+ id: event.pointerId,
264
+ x: event.clientX,
265
+ y: event.clientY,
266
+ lastX: event.clientX,
267
+ lastY: event.clientY,
268
+ // A fingertip wobbles more than a mouse: a tap must still be a tap.
269
+ threshold: event.pointerType === 'touch' ? TOUCH_DRAG_THRESHOLD_PX : DRAG_THRESHOLD_PX,
270
+ panning: false,
271
+ };
272
+ }, []);
273
+
274
+ const onPointerMove = useCallback(
275
+ (event: ReactPointerEvent<HTMLDivElement>) => {
276
+ const press = pressRef.current;
277
+ if (press === null || press.id !== event.pointerId) {
278
+ // Nothing highlights on a plain mouse-over; the ring under the
279
+ // pointer exists only under the platform modifier.
280
+ onHoverElement(isModKeyHeld(event) ? targetUnder(event) : null);
281
+ return;
282
+ }
283
+ if (!press.panning) {
284
+ const travelled = Math.hypot(event.clientX - press.x, event.clientY - press.y);
285
+ if (travelled < press.threshold) return;
286
+ press.panning = true;
287
+ setPanning(true);
288
+ onHoverElement(null);
289
+ if (typeof event.currentTarget.setPointerCapture === 'function') {
290
+ event.currentTarget.setPointerCapture(event.pointerId);
291
+ }
292
+ }
293
+ panBy(event.clientX - press.lastX, event.clientY - press.lastY);
294
+ press.lastX = event.clientX;
295
+ press.lastY = event.clientY;
296
+ },
297
+ [onHoverElement, panBy, targetUnder],
298
+ );
299
+
300
+ const endPress = useCallback(
301
+ (event: ReactPointerEvent<HTMLDivElement>, click: boolean) => {
302
+ const press = pressRef.current;
303
+ if (press === null || press.id !== event.pointerId) return;
304
+ pressRef.current = null;
305
+ if (press.panning) {
306
+ setPanning(false);
307
+ const target = event.currentTarget;
308
+ if (typeof target.hasPointerCapture === 'function' && target.hasPointerCapture(event.pointerId)) {
309
+ target.releasePointerCapture(event.pointerId);
310
+ }
311
+ return;
312
+ }
313
+ if (click) onClickElement(targetUnder(event), event.shiftKey);
314
+ },
315
+ [onClickElement, targetUnder],
316
+ );
317
+
318
+ const onKeyDown = useCallback(
319
+ (event: ReactKeyboardEvent<HTMLDivElement>) => {
320
+ // Keys act on the canvas itself, never on the composer's textarea.
321
+ if (event.target !== event.currentTarget) return;
322
+ // Mod+0, Mod+-, Alt+Arrow belong to the browser (page zoom, history).
323
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
324
+ switch (event.key) {
325
+ case '+':
326
+ case '=':
327
+ zoomBy(ZOOM_STEP);
328
+ break;
329
+ case '-':
330
+ case '_':
331
+ zoomBy(1 / ZOOM_STEP);
332
+ break;
333
+ case '0':
334
+ fit();
335
+ break;
336
+ case 'ArrowLeft':
337
+ panBy(event.shiftKey ? KEY_PAN_PX * 5 : KEY_PAN_PX, 0);
338
+ break;
339
+ case 'ArrowRight':
340
+ panBy(event.shiftKey ? -KEY_PAN_PX * 5 : -KEY_PAN_PX, 0);
341
+ break;
342
+ case 'ArrowUp':
343
+ panBy(0, event.shiftKey ? KEY_PAN_PX * 5 : KEY_PAN_PX);
344
+ break;
345
+ case 'ArrowDown':
346
+ panBy(0, event.shiftKey ? -KEY_PAN_PX * 5 : -KEY_PAN_PX);
347
+ break;
348
+ case 'Escape': {
349
+ if (onEscape() !== 'consumed') return;
350
+ event.stopPropagation();
351
+ break;
352
+ }
353
+ default:
354
+ return;
355
+ }
356
+ event.preventDefault();
357
+ },
358
+ [fit, onEscape, panBy, zoomBy],
359
+ );
360
+
361
+ const handle = useMemo<DiagramCanvasHandle>(() => ({ viewport, hostRef, panIntoView }), [panIntoView, viewport]);
362
+
363
+ return (
364
+ <div
365
+ ref={hostRef}
366
+ data-diagram-canvas=""
367
+ tabIndex={0}
368
+ 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."
369
+ className={cn(
370
+ 'relative h-full w-full touch-pan-y select-none overflow-hidden outline-none',
371
+ panning ? 'cursor-grabbing' : 'cursor-grab',
372
+ className,
373
+ )}
374
+ onPointerDown={onPointerDown}
375
+ onPointerMove={onPointerMove}
376
+ onPointerUp={(event) => endPress(event, true)}
377
+ onPointerCancel={(event) => endPress(event, false)}
378
+ onPointerLeave={() => onHoverElement(null)}
379
+ onKeyDown={onKeyDown}
380
+ >
381
+ <div
382
+ ref={wrapperRef}
383
+ data-diagram-svg=""
384
+ className={cn('absolute left-0 top-0 origin-top-left transition-opacity duration-150', dimmed && 'opacity-40')}
385
+ style={{
386
+ width: content?.width,
387
+ height: content?.height,
388
+ transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
389
+ }}
390
+ />
391
+ {overlay(handle)}
392
+ {children}
393
+ <div
394
+ // Screen chrome: never printed (rings and badges are, they are the
395
+ // comments). On a narrow screen the strip takes the left edge so it
396
+ // never stacks under a host's own bottom-right controls.
397
+ data-print-hide=""
398
+ data-diagram-zoom-strip=""
399
+ 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"
400
+ role="group"
401
+ aria-label="Zoom"
402
+ >
403
+ <Button
404
+ type="button"
405
+ variant="ghost"
406
+ size="icon"
407
+ className="size-7"
408
+ aria-label="Zoom out"
409
+ title="Zoom out (-)"
410
+ onClick={() => zoomBy(1 / ZOOM_STEP)}
411
+ >
412
+ <Minus aria-hidden="true" />
413
+ </Button>
414
+ <Button type="button" variant="ghost" size="icon" className="size-7" aria-label="Fit diagram" title="Fit (0)" onClick={fit}>
415
+ <Maximize2 aria-hidden="true" />
416
+ </Button>
417
+ <Button
418
+ type="button"
419
+ variant="ghost"
420
+ size="icon"
421
+ className="size-7"
422
+ aria-label="Zoom in"
423
+ title="Zoom in (+)"
424
+ onClick={() => zoomBy(ZOOM_STEP)}
425
+ >
426
+ <Plus aria-hidden="true" />
427
+ </Button>
428
+ </div>
429
+ </div>
430
+ );
431
+ }
@@ -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
+ }