pmx-canvas 0.1.16 → 0.1.17

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 (40) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/Readme.md +2 -2
  3. package/dist/canvas/global.css +25 -0
  4. package/dist/canvas/index.js +72 -72
  5. package/dist/types/client/canvas/AnnotationLayer.d.ts +4 -0
  6. package/dist/types/client/canvas/CanvasViewport.d.ts +4 -1
  7. package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
  8. package/dist/types/client/icons.d.ts +4 -0
  9. package/dist/types/client/state/canvas-store.d.ts +16 -1
  10. package/dist/types/client/types.d.ts +20 -0
  11. package/dist/types/mcp/canvas-access.d.ts +1 -0
  12. package/dist/types/server/canvas-serialization.d.ts +23 -1
  13. package/dist/types/server/canvas-state.d.ts +27 -1
  14. package/dist/types/server/index.d.ts +7 -2
  15. package/dist/types/server/mutation-history.d.ts +1 -1
  16. package/dist/types/server/spatial-analysis.d.ts +11 -2
  17. package/package.json +1 -1
  18. package/skills/pmx-canvas/SKILL.md +17 -0
  19. package/src/cli/agent.ts +6 -0
  20. package/src/client/App.tsx +60 -3
  21. package/src/client/canvas/AnnotationLayer.tsx +28 -0
  22. package/src/client/canvas/CanvasViewport.tsx +169 -10
  23. package/src/client/canvas/ContextPinBar.tsx +2 -1
  24. package/src/client/canvas/use-pan-zoom.ts +10 -5
  25. package/src/client/icons.tsx +22 -0
  26. package/src/client/state/canvas-store.ts +52 -2
  27. package/src/client/state/sse-bridge.ts +35 -1
  28. package/src/client/theme/global.css +25 -0
  29. package/src/client/types.ts +17 -0
  30. package/src/mcp/canvas-access.ts +10 -0
  31. package/src/mcp/server.ts +35 -4
  32. package/src/server/canvas-schema.ts +25 -0
  33. package/src/server/canvas-serialization.ts +69 -1
  34. package/src/server/canvas-state.ts +74 -2
  35. package/src/server/diagram-presets.ts +54 -19
  36. package/src/server/index.ts +20 -3
  37. package/src/server/mutation-history.ts +2 -0
  38. package/src/server/server.ts +77 -2
  39. package/src/server/spatial-analysis.ts +46 -1
  40. package/src/shared/semantic-attention.ts +4 -2
@@ -20,16 +20,20 @@ import {
20
20
  draggingEdge,
21
21
  edges,
22
22
  expandedNodeId,
23
+ annotations,
23
24
  nodes,
24
25
  selectNodes,
25
26
  setViewport,
26
27
  viewport,
28
+ createAnnotationFromClient,
29
+ removeAnnotationFromClient,
27
30
  } from '../state/canvas-store';
28
31
  import { createEdgeFromClient, createNodeFromClient } from '../state/intent-bridge';
29
- import type { CanvasNodeState } from '../types';
32
+ import type { CanvasAnnotation, CanvasNodeState } from '../types';
30
33
  import { FocusFieldLayer } from './FocusFieldLayer';
31
34
  import { CanvasNode } from './CanvasNode';
32
35
  import { EdgeLayer } from './EdgeLayer';
36
+ import { AnnotationLayer } from './AnnotationLayer';
33
37
  import { activeGuides } from './snap-guides';
34
38
  import { usePanZoom } from './use-pan-zoom';
35
39
 
@@ -70,6 +74,45 @@ function renderNodeContent(node: CanvasNodeState) {
70
74
  }
71
75
  }
72
76
 
77
+ function distanceToSegment(
78
+ point: { x: number; y: number },
79
+ start: { x: number; y: number },
80
+ end: { x: number; y: number },
81
+ ): number {
82
+ const dx = end.x - start.x;
83
+ const dy = end.y - start.y;
84
+ const lengthSquared = dx * dx + dy * dy;
85
+ if (lengthSquared === 0) return Math.hypot(point.x - start.x, point.y - start.y);
86
+ const t = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared));
87
+ return Math.hypot(point.x - (start.x + t * dx), point.y - (start.y + t * dy));
88
+ }
89
+
90
+ function findAnnotationAtPoint(
91
+ annotationList: CanvasAnnotation[],
92
+ point: { x: number; y: number },
93
+ hitRadius: number,
94
+ ): CanvasAnnotation | null {
95
+ for (let i = annotationList.length - 1; i >= 0; i--) {
96
+ const annotation = annotationList[i];
97
+ if (!annotation) continue;
98
+ const pad = hitRadius + annotation.width;
99
+ if (
100
+ point.x < annotation.bounds.x - pad ||
101
+ point.x > annotation.bounds.x + annotation.bounds.width + pad ||
102
+ point.y < annotation.bounds.y - pad ||
103
+ point.y > annotation.bounds.y + annotation.bounds.height + pad
104
+ ) continue;
105
+ for (let index = 1; index < annotation.points.length; index++) {
106
+ const start = annotation.points[index - 1];
107
+ const end = annotation.points[index];
108
+ if (start && end && distanceToSegment(point, start, end) <= hitRadius + annotation.width / 2) {
109
+ return annotation;
110
+ }
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
73
116
  interface LassoRect {
74
117
  startX: number;
75
118
  startY: number;
@@ -77,9 +120,27 @@ interface LassoRect {
77
120
  currentY: number;
78
121
  }
79
122
 
123
+ interface AnnotationDraft {
124
+ id: string;
125
+ type: 'freehand';
126
+ points: Array<{ x: number; y: number }>;
127
+ bounds: { x: number; y: number; width: number; height: number };
128
+ color: string;
129
+ width: number;
130
+ createdAt: string;
131
+ }
132
+
133
+ const ANNOTATION_COLOR = 'currentColor';
134
+ const ANNOTATION_WIDTH = 4;
135
+ const ERASER_HIT_RADIUS = 14;
136
+
137
+ type AnnotationTool = 'pen' | 'eraser' | null;
138
+
80
139
  interface CanvasViewportProps {
81
140
  onNodeContextMenu?: (e: MouseEvent, nodeId: string) => void;
82
141
  onCanvasContextMenu?: (e: MouseEvent, canvasX: number, canvasY: number) => void;
142
+ annotationMode?: boolean;
143
+ annotationTool?: AnnotationTool;
83
144
  }
84
145
 
85
146
  const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico', 'avif']);
@@ -165,10 +226,13 @@ export function getRenderableWorldNodes(
165
226
  return worldNodes;
166
227
  }
167
228
 
168
- export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: CanvasViewportProps) {
229
+ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotationMode = false, annotationTool = null }: CanvasViewportProps) {
169
230
  const v = viewport.value;
170
231
  const isLassoing = useRef(false);
232
+ const isAnnotating = useRef(false);
233
+ const annotationPoints = useRef<Array<{ x: number; y: number }>>([]);
171
234
  const [lasso, setLasso] = useState<LassoRect | null>(null);
235
+ const [draftAnnotation, setDraftAnnotation] = useState<AnnotationDraft | null>(null);
172
236
  const [dropActive, setDropActive] = useState(false);
173
237
  const dropCounter = useRef(0);
174
238
  // Ref mirrors lasso state so pointer handlers always read the latest value
@@ -177,15 +241,16 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
177
241
 
178
242
  const containerRef = usePanZoom({
179
243
  viewport,
244
+ disabled: annotationMode,
180
245
  onViewportChange: (next) => {
181
246
  // Don't pan while lassoing — usePanZoom's pointerdown still fires
182
247
  // (native listener) before our Preact handler can stopPropagation.
183
- if (isLassoing.current) return;
248
+ if (isLassoing.current || annotationMode) return;
184
249
  cancelViewportAnimation();
185
250
  setViewport(next);
186
251
  },
187
252
  onViewportCommit: (next) => {
188
- if (isLassoing.current) return;
253
+ if (isLassoing.current || annotationMode) return;
189
254
  cancelViewportAnimation();
190
255
  commitViewport(next);
191
256
  },
@@ -222,7 +287,53 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
222
287
  const handlePointerDown = useCallback(
223
288
  (e: PointerEvent) => {
224
289
  const container = containerRef.current;
225
- if (!container || e.target !== container) return;
290
+ if (!container) return;
291
+
292
+ if (annotationTool === 'eraser') {
293
+ const target = e.target instanceof Element ? e.target : null;
294
+ if (target?.closest('.hud-layer, .snapshot-panel, .context-menu, .command-palette')) return;
295
+ e.preventDefault();
296
+ e.stopPropagation();
297
+ const rect = container.getBoundingClientRect();
298
+ const vp = viewport.value;
299
+ const point = {
300
+ x: (e.clientX - rect.left - vp.x) / vp.scale,
301
+ y: (e.clientY - rect.top - vp.y) / vp.scale,
302
+ };
303
+ const hit = findAnnotationAtPoint(Array.from(annotations.value.values()), point, ERASER_HIT_RADIUS / vp.scale);
304
+ if (hit) void removeAnnotationFromClient(hit.id);
305
+ return;
306
+ }
307
+
308
+ if (annotationTool === 'pen') {
309
+ const target = e.target instanceof Element ? e.target : null;
310
+ if (target?.closest('.hud-layer, .snapshot-panel, .context-menu, .command-palette')) return;
311
+ e.preventDefault();
312
+ e.stopPropagation();
313
+ activeNodeId.value = null;
314
+ clearSelection();
315
+ isAnnotating.current = true;
316
+ const rect = container.getBoundingClientRect();
317
+ const vp = viewport.value;
318
+ const point = {
319
+ x: (e.clientX - rect.left - vp.x) / vp.scale,
320
+ y: (e.clientY - rect.top - vp.y) / vp.scale,
321
+ };
322
+ annotationPoints.current = [point];
323
+ setDraftAnnotation({
324
+ id: 'draft-annotation',
325
+ type: 'freehand',
326
+ points: [point],
327
+ bounds: { x: 0, y: 0, width: 0, height: 0 },
328
+ color: ANNOTATION_COLOR,
329
+ width: ANNOTATION_WIDTH,
330
+ createdAt: '',
331
+ });
332
+ container.setPointerCapture(e.pointerId);
333
+ return;
334
+ }
335
+
336
+ if (e.target !== container) return;
226
337
 
227
338
  if (!e.shiftKey) {
228
339
  if (!lassoRef.current) {
@@ -243,11 +354,27 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
243
354
  setLasso(initial);
244
355
  container.setPointerCapture(e.pointerId);
245
356
  },
246
- [containerRef],
357
+ [annotationTool, containerRef],
247
358
  );
248
359
 
249
360
  const handlePointerMove = useCallback(
250
361
  (e: PointerEvent) => {
362
+ if (isAnnotating.current) {
363
+ const container = containerRef.current;
364
+ if (!container) return;
365
+ const rect = container.getBoundingClientRect();
366
+ const vp = viewport.value;
367
+ const point = {
368
+ x: (e.clientX - rect.left - vp.x) / vp.scale,
369
+ y: (e.clientY - rect.top - vp.y) / vp.scale,
370
+ };
371
+ const previous = annotationPoints.current.at(-1);
372
+ if (previous && Math.hypot(point.x - previous.x, point.y - previous.y) < 2) return;
373
+ annotationPoints.current = [...annotationPoints.current, point];
374
+ setDraftAnnotation((draft) => draft ? { ...draft, points: annotationPoints.current } : null);
375
+ return;
376
+ }
377
+
251
378
  if (!isLassoing.current || !lassoRef.current) return;
252
379
  const rect = containerRef.current?.getBoundingClientRect();
253
380
  if (!rect) return;
@@ -262,7 +389,22 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
262
389
  [containerRef],
263
390
  );
264
391
 
265
- const handlePointerUp = useCallback(() => {
392
+ const handlePointerUp = useCallback((e: PointerEvent) => {
393
+ if (isAnnotating.current) {
394
+ isAnnotating.current = false;
395
+ const points = annotationPoints.current;
396
+ annotationPoints.current = [];
397
+ setDraftAnnotation(null);
398
+ if (points.length >= 2) {
399
+ void createAnnotationFromClient({ points, color: ANNOTATION_COLOR, width: ANNOTATION_WIDTH });
400
+ }
401
+ const container = containerRef.current;
402
+ if (container?.hasPointerCapture(e.pointerId)) {
403
+ container.releasePointerCapture(e.pointerId);
404
+ }
405
+ return;
406
+ }
407
+
266
408
  const current = lassoRef.current;
267
409
  if (!isLassoing.current || !current) return;
268
410
  isLassoing.current = false;
@@ -306,6 +448,14 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
306
448
  setLasso(null);
307
449
  }, []);
308
450
 
451
+ useEffect(() => {
452
+ if (annotationMode) return;
453
+ if (!isAnnotating.current && !draftAnnotation) return;
454
+ isAnnotating.current = false;
455
+ annotationPoints.current = [];
456
+ setDraftAnnotation(null);
457
+ }, [annotationMode, draftAnnotation]);
458
+
309
459
  // ── Drag-to-connect: track cursor in world space, hit-test on drop ──
310
460
  useEffect(() => {
311
461
  function handleMove(e: PointerEvent) {
@@ -359,6 +509,7 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
359
509
  // ── Double-click on background → create new markdown node ──
360
510
  const handleDblClick = useCallback(
361
511
  (e: MouseEvent) => {
512
+ if (annotationMode) return;
362
513
  const container = containerRef.current;
363
514
  if (!container || e.target !== container) return;
364
515
  const rect = container.getBoundingClientRect();
@@ -377,11 +528,12 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
377
528
  height: nodeH,
378
529
  });
379
530
  },
380
- [containerRef],
531
+ [annotationMode, containerRef],
381
532
  );
382
533
 
383
534
  const handleContextMenu = useCallback(
384
535
  (e: MouseEvent) => {
536
+ if (annotationMode) return;
385
537
  if (!onCanvasContextMenu) return;
386
538
 
387
539
  const container = containerRef.current;
@@ -396,7 +548,7 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
396
548
  const canvasY = (e.clientY - rect.top - v.y) / v.scale;
397
549
  onCanvasContextMenu(e, canvasX, canvasY);
398
550
  },
399
- [containerRef, onCanvasContextMenu],
551
+ [annotationMode, containerRef, onCanvasContextMenu],
400
552
  );
401
553
 
402
554
  // ── Drag-and-drop files from filesystem ──
@@ -543,7 +695,11 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
543
695
  height: '100%',
544
696
  position: 'relative',
545
697
  overflow: 'hidden',
546
- cursor: draggingEdge.value ? 'crosshair' : isLassoing.current ? 'crosshair' : 'grab',
698
+ cursor: annotationTool === 'eraser'
699
+ ? 'cell'
700
+ : annotationMode || draggingEdge.value || isLassoing.current
701
+ ? 'crosshair'
702
+ : 'grab',
547
703
  }}
548
704
  >
549
705
  {/* D4: CSS matrix(a,b,c,d,tx,ty) — scale uniformly (a=d=scale, b=c=0)
@@ -561,6 +717,8 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
561
717
  >
562
718
  <FocusFieldLayer />
563
719
  <EdgeLayer nodes={nodes} edges={edges} />
720
+ <AnnotationLayer annotations={Array.from(annotations.value.values())} />
721
+ {draftAnnotation && draftAnnotation.points.length >= 2 && <AnnotationLayer annotations={[draftAnnotation]} />}
564
722
  {worldNodes.map((node) => (
565
723
  <CanvasNode key={node.id} node={node} onContextMenu={onNodeContextMenu}>
566
724
  {renderNodeContent(node)}
@@ -579,6 +737,7 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: Canva
579
737
  </svg>
580
738
  )}
581
739
  </div>
740
+ {annotationMode && <div class={`annotation-capture-layer${annotationTool === 'eraser' ? ' erasing' : ''}`} aria-hidden="true" />}
582
741
  {lassoStyle && <div class="lasso-rect" style={lassoStyle} />}
583
742
  {dropActive && (
584
743
  <div class="drop-zone-overlay">
@@ -1,12 +1,13 @@
1
1
  import {
2
2
  clearContextPins,
3
3
  contextPinnedNodeIds,
4
+ hasOpenDockedContextPanel,
4
5
  } from '../state/canvas-store';
5
6
  import { attentionHistoryOpen } from '../state/attention-store';
6
7
 
7
8
  export function ContextPinBar() {
8
9
  const count = contextPinnedNodeIds.value.size;
9
- if (count === 0 || attentionHistoryOpen.value) return null;
10
+ if (count === 0 || attentionHistoryOpen.value || hasOpenDockedContextPanel.value) return null;
10
11
 
11
12
  return (
12
13
  <div class="context-pin-bar">
@@ -14,6 +14,7 @@ interface PanZoomOptions {
14
14
  viewport: Signal<ViewportState>;
15
15
  onViewportChange: (v: ViewportState) => void;
16
16
  onViewportCommit: (v: ViewportState) => void;
17
+ disabled?: boolean;
17
18
  }
18
19
 
19
20
  /**
@@ -23,7 +24,7 @@ interface PanZoomOptions {
23
24
  * - Pointer drag on background: pan
24
25
  * - Pinch (touch): zoom
25
26
  */
26
- export function usePanZoom({ viewport, onViewportChange, onViewportCommit }: PanZoomOptions) {
27
+ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disabled = false }: PanZoomOptions) {
27
28
  const containerRef = useRef<HTMLDivElement>(null);
28
29
  const isPanning = useRef(false);
29
30
  const lastPointer = useRef({ x: 0, y: 0 });
@@ -42,6 +43,7 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit }: Pan
42
43
 
43
44
  const handleWheel = useCallback(
44
45
  (e: WheelEvent) => {
46
+ if (disabled) return;
45
47
  e.preventDefault();
46
48
  const v = viewport.value;
47
49
 
@@ -74,21 +76,23 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit }: Pan
74
76
  scheduleViewportCommit(next);
75
77
  }
76
78
  },
77
- [viewport, onViewportChange, scheduleViewportCommit],
79
+ [disabled, viewport, onViewportChange, scheduleViewportCommit],
78
80
  );
79
81
 
80
82
  const handlePointerDown = useCallback((e: PointerEvent) => {
83
+ if (disabled) return;
81
84
  // Only pan when clicking the canvas background (not nodes)
82
85
  const container = containerRef.current;
83
86
  if (!container || e.target !== container) return;
84
87
  isPanning.current = true;
85
88
  lastPointer.current = { x: e.clientX, y: e.clientY };
86
89
  container.setPointerCapture(e.pointerId);
87
- }, []);
90
+ }, [disabled]);
88
91
 
89
92
  const handlePointerMove = useCallback(
90
93
  (e: PointerEvent) => {
91
94
  if (!isPanning.current) return;
95
+ if (disabled) return;
92
96
  const dx = e.clientX - lastPointer.current.x;
93
97
  const dy = e.clientY - lastPointer.current.y;
94
98
  lastPointer.current = { x: e.clientX, y: e.clientY };
@@ -96,7 +100,7 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit }: Pan
96
100
  const v = viewport.value;
97
101
  onViewportChange({ x: v.x + dx, y: v.y + dy, scale: v.scale });
98
102
  },
99
- [viewport, onViewportChange],
103
+ [disabled, viewport, onViewportChange],
100
104
  );
101
105
 
102
106
  const handlePointerUp = useCallback(() => {
@@ -109,6 +113,7 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit }: Pan
109
113
  // Touch pinch
110
114
  const handleTouchMove = useCallback(
111
115
  (e: TouchEvent) => {
116
+ if (disabled) return;
112
117
  if (e.touches.length !== 2) {
113
118
  lastPinchDist.current = 0;
114
119
  return;
@@ -142,7 +147,7 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit }: Pan
142
147
  }
143
148
  lastPinchDist.current = dist;
144
149
  },
145
- [viewport, onViewportChange, scheduleViewportCommit],
150
+ [disabled, viewport, onViewportChange, scheduleViewportCommit],
146
151
  );
147
152
 
148
153
  const handleTouchEnd = useCallback(() => {
@@ -117,6 +117,28 @@ export function IconMoon(p: IconProps): JSX.Element {
117
117
  );
118
118
  }
119
119
 
120
+ /** Pen stroke — canvas annotation mode */
121
+ export function IconPen(p: IconProps): JSX.Element {
122
+ return (
123
+ <Icon {...p}>
124
+ <path d="M3 13l2.8-.7L13 5.1 10.9 3 3.7 10.2 3 13Z" />
125
+ <path d="M9.8 4.1l2.1 2.1" />
126
+ <path d="M2.5 14h11" />
127
+ </Icon>
128
+ );
129
+ }
130
+
131
+ /** Eraser — remove canvas annotations */
132
+ export function IconEraser(p: IconProps): JSX.Element {
133
+ return (
134
+ <Icon {...p}>
135
+ <path d="M3 10.5 8.5 5a2 2 0 0 1 2.8 0l1.7 1.7a2 2 0 0 1 0 2.8L8.5 14H5.8L3 11.2Z" />
136
+ <path d="M7.5 6 12 10.5" />
137
+ <path d="M8.5 14H14" />
138
+ </Icon>
139
+ );
140
+ }
141
+
120
142
  /** Camera — snapshots */
121
143
  export function IconSnapshot(p: IconProps): JSX.Element {
122
144
  return (
@@ -1,5 +1,5 @@
1
1
  import { batch, computed, signal } from '@preact/signals';
2
- import { isExcalidrawNode, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
2
+ import { isExcalidrawNode, type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
3
3
  import { computeAutoArrange } from '../../shared/auto-arrange';
4
4
  import { pushCanvasUpdate, updateViewportFromClient } from './intent-bridge';
5
5
 
@@ -11,6 +11,7 @@ function logCanvasStoreError(action: string, error: unknown): void {
11
11
  export const viewport = signal<ViewportState>({ x: 0, y: 0, scale: 1 });
12
12
  export const nodes = signal<Map<string, CanvasNodeState>>(new Map());
13
13
  export const edges = signal<Map<string, CanvasEdge>>(new Map());
14
+ export const annotations = signal<Map<string, CanvasAnnotation>>(new Map());
14
15
  export const activeNodeId = signal<string | null>(null);
15
16
  export const connectionStatus = signal<ConnectionStatus>('connecting');
16
17
  export const sessionId = signal<string>('');
@@ -258,6 +259,50 @@ export function removeEdgesForNode(nodeId: string): void {
258
259
  if (changed) edges.value = next;
259
260
  }
260
261
 
262
+ export function addAnnotation(annotation: CanvasAnnotation): void {
263
+ const next = new Map(annotations.value);
264
+ next.set(annotation.id, annotation);
265
+ annotations.value = next;
266
+ }
267
+
268
+ export function removeAnnotation(id: string): void {
269
+ const next = new Map(annotations.value);
270
+ if (!next.delete(id)) return;
271
+ annotations.value = next;
272
+ }
273
+
274
+ export async function createAnnotationFromClient(input: {
275
+ points: CanvasAnnotation['points'];
276
+ color: string;
277
+ width: number;
278
+ label?: string;
279
+ }): Promise<{ ok: boolean }> {
280
+ try {
281
+ const res = await fetch('/api/canvas/annotation', {
282
+ method: 'POST',
283
+ headers: { 'Content-Type': 'application/json' },
284
+ body: JSON.stringify(input),
285
+ });
286
+ return { ok: res.ok };
287
+ } catch (error) {
288
+ logCanvasStoreError('createAnnotationFromClient', error);
289
+ return { ok: false };
290
+ }
291
+ }
292
+
293
+ export async function removeAnnotationFromClient(id: string): Promise<{ ok: boolean }> {
294
+ try {
295
+ const res = await fetch(`/api/canvas/annotation/${encodeURIComponent(id)}`, {
296
+ method: 'DELETE',
297
+ });
298
+ if (res.ok) removeAnnotation(id);
299
+ return { ok: res.ok };
300
+ } catch (error) {
301
+ logCanvasStoreError('removeAnnotationFromClient', error);
302
+ return { ok: false };
303
+ }
304
+ }
305
+
261
306
  export function resizeNode(id: string, size: { width: number; height: number }): void {
262
307
  const existing = nodes.value.get(id);
263
308
  if (!existing) return;
@@ -343,7 +388,7 @@ function commitViewportWithOptions(
343
388
  }
344
389
 
345
390
  export function applyServerCanvasLayout(
346
- layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState },
391
+ layout: Pick<CanvasLayout, 'nodes' | 'edges'> & { viewport?: ViewportState; annotations?: CanvasAnnotation[] },
347
392
  options: { applyViewport?: boolean } = {},
348
393
  ): void {
349
394
  const nextNodes = new Map<string, CanvasNodeState>();
@@ -360,6 +405,10 @@ export function applyServerCanvasLayout(
360
405
  for (const edge of edgeSource) {
361
406
  nextEdges.set(edge.id, edge);
362
407
  }
408
+ const nextAnnotations = new Map<string, CanvasAnnotation>();
409
+ for (const annotation of layout.annotations ?? []) {
410
+ nextAnnotations.set(annotation.id, annotation);
411
+ }
363
412
 
364
413
  const nextActiveNodeId =
365
414
  activeNodeId.value !== null && nextNodes.has(activeNodeId.value) ? activeNodeId.value : null;
@@ -375,6 +424,7 @@ export function applyServerCanvasLayout(
375
424
  maxZ = nextMaxZ;
376
425
  nodes.value = nextNodes;
377
426
  edges.value = nextEdges;
427
+ annotations.value = nextAnnotations;
378
428
  activeNodeId.value = nextActiveNodeId;
379
429
  expandedNodeId.value = nextExpandedNodeId;
380
430
  if (!sameSetValues(selectedNodeIds.value, nextSelectedNodeIds)) {
@@ -1,6 +1,6 @@
1
1
  import { findOpenCanvasPosition } from '../utils/placement.js';
2
2
  import { normalizeExtAppToolResult } from '../utils/ext-app-tool-result.js';
3
- import type { CanvasEdge, CanvasNodeState } from '../types';
3
+ import type { CanvasAnnotation, CanvasEdge, CanvasNodeState } from '../types';
4
4
  import {
5
5
  activeNodeId,
6
6
  addEdge,
@@ -310,6 +310,7 @@ function isCanvasNodeType(value: unknown): value is CanvasNodeState['type'] {
310
310
  || value === 'trace'
311
311
  || value === 'file'
312
312
  || value === 'image'
313
+ || value === 'html'
313
314
  || value === 'group';
314
315
  }
315
316
 
@@ -334,6 +335,12 @@ function parseCanvasSize(value: unknown): { width: number; height: number } | nu
334
335
  return { width: size.width, height: size.height };
335
336
  }
336
337
 
338
+ function parseCanvasRect(value: unknown): { x: number; y: number; width: number; height: number } | null {
339
+ const position = parseCanvasPosition(value);
340
+ const size = parseCanvasSize(value);
341
+ return position && size ? { ...position, ...size } : null;
342
+ }
343
+
337
344
  function parseCanvasNode(raw: Record<string, unknown>): CanvasNodeState | null {
338
345
  if (typeof raw.id !== 'string' || !raw.id) return null;
339
346
  if (!isCanvasNodeType(raw.type)) return null;
@@ -379,6 +386,28 @@ function parseCanvasEdge(raw: Record<string, unknown>): CanvasEdge | null {
379
386
  };
380
387
  }
381
388
 
389
+ function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation | null {
390
+ if (typeof raw.id !== 'string' || !raw.id) return null;
391
+ if (raw.type !== 'freehand') return null;
392
+ if (!Array.isArray(raw.points)) return null;
393
+ const points = raw.points
394
+ .map((point) => parseCanvasPosition(point))
395
+ .filter((point): point is { x: number; y: number } => point !== null);
396
+ const bounds = parseCanvasRect(raw.bounds);
397
+ if (points.length < 2 || !bounds) return null;
398
+
399
+ return {
400
+ id: raw.id,
401
+ type: 'freehand',
402
+ points,
403
+ bounds,
404
+ color: typeof raw.color === 'string' ? raw.color : '#f97316',
405
+ width: typeof raw.width === 'number' ? raw.width : 4,
406
+ ...(typeof raw.label === 'string' ? { label: raw.label } : {}),
407
+ createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : '',
408
+ };
409
+ }
410
+
382
411
  // ── SSE event handlers ───────────────────────────────────────
383
412
  function handleConnected(data: Record<string, unknown>): void {
384
413
  sessionId.value = (data.sessionId as string) || '';
@@ -801,6 +830,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
801
830
  | {
802
831
  nodes?: Array<Record<string, unknown>>;
803
832
  edges?: Array<Record<string, unknown>>;
833
+ annotations?: Array<Record<string, unknown>>;
804
834
  viewport?: Record<string, unknown>;
805
835
  }
806
836
  | undefined;
@@ -814,6 +844,9 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
814
844
  const serverEdges = Array.isArray(layout.edges)
815
845
  ? layout.edges.map(parseCanvasEdge).filter((edge): edge is CanvasEdge => edge !== null)
816
846
  : Array.from(edges.value.values());
847
+ const serverAnnotations = Array.isArray(layout.annotations)
848
+ ? layout.annotations.map(parseCanvasAnnotation).filter((annotation): annotation is CanvasAnnotation => annotation !== null)
849
+ : undefined;
817
850
  const nextViewport = layout.viewport
818
851
  ? {
819
852
  x: typeof layout.viewport.x === 'number' ? layout.viewport.x : 0,
@@ -827,6 +860,7 @@ function handleCanvasLayoutUpdate(data: Record<string, unknown>): void {
827
860
  ...(nextViewport ? { viewport: nextViewport } : {}),
828
861
  nodes: serverNodes,
829
862
  edges: serverEdges,
863
+ ...(serverAnnotations ? { annotations: serverAnnotations } : {}),
830
864
  }, { applyViewport: shouldApplyViewport });
831
865
 
832
866
  syncAttentionFromSse({ event: 'canvas-layout-update', data });
@@ -50,6 +50,7 @@
50
50
  --c-accent-hover: #6ECAFF;
51
51
  --c-warn-hover: #f5d06b;
52
52
  --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0));
53
+ --c-annotation: #F4EFE6;
53
54
  /* ── Non-color tokens ────────────────────────────────────── */
54
55
  --font: "IBM Plex Sans", "SF Pro Text", "Avenir Next", system-ui, sans-serif;
55
56
  --mono: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
@@ -109,6 +110,7 @@
109
110
  --c-accent-hover: #1588CE;
110
111
  --c-warn-hover: #dab040;
111
112
  --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(8, 21, 36, 0.02));
113
+ --c-annotation: #081524;
112
114
  }
113
115
 
114
116
  :root[data-theme="high-contrast"] {
@@ -162,6 +164,7 @@
162
164
  --c-accent-hover: #33ffff;
163
165
  --c-warn-hover: #ffff33;
164
166
  --c-canvas-wash: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0));
167
+ --c-annotation: #ffff00;
165
168
  }
166
169
 
167
170
  * {
@@ -1471,6 +1474,28 @@ body,
1471
1474
  z-index: 9998;
1472
1475
  }
1473
1476
 
1477
+ .annotation-layer {
1478
+ position: absolute;
1479
+ inset: 0;
1480
+ width: 1px;
1481
+ height: 1px;
1482
+ overflow: visible;
1483
+ pointer-events: none;
1484
+ z-index: 45;
1485
+ }
1486
+
1487
+ .annotation-capture-layer {
1488
+ position: absolute;
1489
+ inset: 0;
1490
+ z-index: 9996;
1491
+ pointer-events: none;
1492
+ background: color-mix(in srgb, var(--c-accent) 5%, transparent);
1493
+ }
1494
+
1495
+ .annotation-capture-layer.erasing {
1496
+ background: color-mix(in srgb, var(--c-danger) 6%, transparent);
1497
+ }
1498
+
1474
1499
  /* ── Drop Zone (file drag-and-drop) ─────────────────────────── */
1475
1500
  .drop-zone-overlay {
1476
1501
  position: absolute;
@@ -41,6 +41,22 @@ export interface CanvasEdge {
41
41
  animated?: boolean;
42
42
  }
43
43
 
44
+ export interface CanvasAnnotationPoint {
45
+ x: number;
46
+ y: number;
47
+ }
48
+
49
+ export interface CanvasAnnotation {
50
+ id: string;
51
+ type: 'freehand';
52
+ points: CanvasAnnotationPoint[];
53
+ bounds: { x: number; y: number; width: number; height: number };
54
+ color: string;
55
+ width: number;
56
+ label?: string;
57
+ createdAt: string;
58
+ }
59
+
44
60
  export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
45
61
 
46
62
  // ── Shared constants for node type display ──────────────────
@@ -93,4 +109,5 @@ export interface CanvasLayout {
93
109
  viewport: ViewportState;
94
110
  nodes: CanvasNodeState[];
95
111
  edges: CanvasEdge[];
112
+ annotations?: CanvasAnnotation[];
96
113
  }