pmx-canvas 0.1.15 → 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 (48) hide show
  1. package/CHANGELOG.md +124 -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/nodes/ContextNode.d.ts +11 -2
  10. package/dist/types/client/nodes/StatusNode.d.ts +1 -0
  11. package/dist/types/client/state/canvas-store.d.ts +22 -3
  12. package/dist/types/client/state/intent-bridge.d.ts +2 -0
  13. package/dist/types/client/types.d.ts +20 -0
  14. package/dist/types/mcp/canvas-access.d.ts +1 -0
  15. package/dist/types/server/canvas-serialization.d.ts +23 -1
  16. package/dist/types/server/canvas-state.d.ts +27 -1
  17. package/dist/types/server/index.d.ts +7 -2
  18. package/dist/types/server/mutation-history.d.ts +1 -1
  19. package/dist/types/server/spatial-analysis.d.ts +11 -2
  20. package/package.json +1 -1
  21. package/skills/pmx-canvas/SKILL.md +17 -0
  22. package/src/cli/agent.ts +6 -0
  23. package/src/client/App.tsx +60 -3
  24. package/src/client/canvas/AnnotationLayer.tsx +28 -0
  25. package/src/client/canvas/CanvasViewport.tsx +169 -10
  26. package/src/client/canvas/ContextPinBar.tsx +3 -1
  27. package/src/client/canvas/DockedNode.tsx +4 -3
  28. package/src/client/canvas/use-pan-zoom.ts +10 -5
  29. package/src/client/icons.tsx +22 -0
  30. package/src/client/nodes/ContextNode.tsx +128 -6
  31. package/src/client/nodes/StatusNode.tsx +16 -1
  32. package/src/client/nodes/StatusSummary.tsx +2 -1
  33. package/src/client/state/canvas-store.ts +65 -7
  34. package/src/client/state/intent-bridge.ts +5 -1
  35. package/src/client/state/sse-bridge.ts +36 -2
  36. package/src/client/theme/global.css +25 -0
  37. package/src/client/types.ts +17 -0
  38. package/src/mcp/canvas-access.ts +10 -0
  39. package/src/mcp/server.ts +35 -4
  40. package/src/server/canvas-schema.ts +25 -0
  41. package/src/server/canvas-serialization.ts +69 -1
  42. package/src/server/canvas-state.ts +74 -2
  43. package/src/server/diagram-presets.ts +54 -19
  44. package/src/server/index.ts +20 -3
  45. package/src/server/mutation-history.ts +2 -0
  46. package/src/server/server.ts +101 -3
  47. package/src/server/spatial-analysis.ts +46 -1
  48. package/src/shared/semantic-attention.ts +4 -2
@@ -64,6 +64,7 @@ interface PersistedCanvasState {
64
64
  viewport: ViewportState;
65
65
  nodes: CanvasNodeState[];
66
66
  edges: CanvasEdge[];
67
+ annotations?: CanvasAnnotation[];
67
68
  contextPins: string[];
68
69
  }
69
70
 
@@ -146,10 +147,27 @@ export interface CanvasEdge {
146
147
  animated?: boolean;
147
148
  }
148
149
 
150
+ export interface CanvasAnnotationPoint {
151
+ x: number;
152
+ y: number;
153
+ }
154
+
155
+ export interface CanvasAnnotation {
156
+ id: string;
157
+ type: 'freehand';
158
+ points: CanvasAnnotationPoint[];
159
+ bounds: { x: number; y: number; width: number; height: number };
160
+ color: string;
161
+ width: number;
162
+ label?: string;
163
+ createdAt: string;
164
+ }
165
+
149
166
  export interface CanvasLayout {
150
167
  viewport: ViewportState;
151
168
  nodes: CanvasNodeState[];
152
169
  edges: CanvasEdge[];
170
+ annotations: CanvasAnnotation[];
153
171
  }
154
172
 
155
173
  export interface CanvasNodeUpdate {
@@ -163,7 +181,7 @@ export interface CanvasNodeUpdate {
163
181
  export type CanvasChangeType = 'pins' | 'nodes';
164
182
 
165
183
  export interface MutationRecordInfo {
166
- operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
184
+ operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
167
185
  description: string;
168
186
  forward: () => void;
169
187
  inverse: () => void;
@@ -215,6 +233,7 @@ function isPersistedBlobRef(value: unknown): value is PersistedBlobRef {
215
233
  class CanvasStateManager {
216
234
  private nodes = new Map<string, CanvasNodeState>();
217
235
  private edges = new Map<string, CanvasEdge>();
236
+ private annotations = new Map<string, CanvasAnnotation>();
218
237
  private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
219
238
  private _contextPinnedNodeIds = new Set<string>();
220
239
  private _workspaceRoot = process.cwd();
@@ -607,6 +626,7 @@ class CanvasStateManager {
607
626
  viewport: { x: 0, y: 0, scale: 1 },
608
627
  nodes: [],
609
628
  edges: [],
629
+ annotations: [],
610
630
  contextPins: [],
611
631
  };
612
632
  }
@@ -663,6 +683,7 @@ class CanvasStateManager {
663
683
  viewport: this._viewport,
664
684
  nodes: Array.from(this.nodes.values()),
665
685
  edges: Array.from(this.edges.values()),
686
+ annotations: Array.from(this.annotations.values()),
666
687
  contextPins: Array.from(this._contextPinnedNodeIds),
667
688
  });
668
689
  writeFileSync(this._stateFilePath, JSON.stringify(payload, null, 2), 'utf-8');
@@ -683,6 +704,7 @@ class CanvasStateManager {
683
704
  private applyPersistedState(state: PersistedCanvasState): void {
684
705
  this.nodes.clear();
685
706
  this.edges.clear();
707
+ this.annotations.clear();
686
708
  this._contextPinnedNodeIds.clear();
687
709
 
688
710
  this._viewport = {
@@ -703,6 +725,11 @@ class CanvasStateManager {
703
725
  if (edge?.id) this.edges.set(edge.id, structuredClone(edge));
704
726
  }
705
727
  }
728
+ if (Array.isArray(state.annotations)) {
729
+ for (const annotation of state.annotations) {
730
+ if (annotation?.id) this.annotations.set(annotation.id, structuredClone(annotation));
731
+ }
732
+ }
706
733
  if (Array.isArray(state.contextPins)) {
707
734
  for (const pinId of state.contextPins) {
708
735
  if (this.nodes.has(pinId)) this._contextPinnedNodeIds.add(pinId);
@@ -798,9 +825,12 @@ class CanvasStateManager {
798
825
  viewport: this._viewport,
799
826
  nodes: Array.from(this.nodes.values()),
800
827
  edges: Array.from(this.edges.values()),
828
+ annotations: Array.from(this.annotations.values()),
801
829
  contextPins: Array.from(this._contextPinnedNodeIds),
802
830
  });
803
831
  writeFileSync(join(dir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
832
+ snapshot.nodeCount = payload.nodes.length;
833
+ snapshot.edgeCount = payload.edges.length;
804
834
  return snapshot;
805
835
  } catch (error) {
806
836
  logCanvasStateWarning('save snapshot failed', error, { id, name });
@@ -870,6 +900,7 @@ class CanvasStateManager {
870
900
  viewport: structuredClone(this._viewport),
871
901
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
872
902
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
903
+ annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
873
904
  contextPins: Array.from(this._contextPinnedNodeIds),
874
905
  });
875
906
  const nextState: PersistedCanvasState = {
@@ -877,6 +908,7 @@ class CanvasStateManager {
877
908
  viewport: structuredClone(resolved.state.viewport),
878
909
  nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
879
910
  edges: Array.isArray(resolved.state.edges) ? resolved.state.edges.map((edge) => structuredClone(edge)) : [],
911
+ annotations: Array.isArray(resolved.state.annotations) ? resolved.state.annotations.map((annotation) => structuredClone(annotation)) : [],
880
912
  contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
881
913
  };
882
914
 
@@ -913,7 +945,7 @@ class CanvasStateManager {
913
945
  }
914
946
 
915
947
  /** Read a snapshot's data without restoring it (for diff). Resolves by ID or name. */
916
- getSnapshotData(idOrName: string): { name: string; nodes: CanvasNodeState[]; edges: CanvasEdge[] } | null {
948
+ getSnapshotData(idOrName: string): { name: string; nodes: CanvasNodeState[]; edges: CanvasEdge[]; annotations: CanvasAnnotation[] } | null {
917
949
  const resolved = this.readResolvedSnapshot(idOrName);
918
950
  if (!resolved) return null;
919
951
  const state = {
@@ -926,6 +958,7 @@ class CanvasStateManager {
926
958
  name: resolved.snapshot.name,
927
959
  nodes: Array.isArray(state.nodes) ? state.nodes.map((node) => structuredClone(node)) : [],
928
960
  edges: Array.isArray(state.edges) ? state.edges.map((edge) => structuredClone(edge)) : [],
961
+ annotations: Array.isArray(state.annotations) ? state.annotations.map((annotation) => structuredClone(annotation)) : [],
929
962
  };
930
963
  }
931
964
 
@@ -1110,6 +1143,40 @@ class CanvasStateManager {
1110
1143
  .map((edge) => structuredClone(edge));
1111
1144
  }
1112
1145
 
1146
+ addAnnotation(annotation: CanvasAnnotation): void {
1147
+ const cloned = structuredClone(annotation);
1148
+ this.annotations.set(annotation.id, cloned);
1149
+ this.scheduleSave();
1150
+ this.notifyChange('nodes');
1151
+ this.recordMutation({
1152
+ operationType: 'addAnnotation',
1153
+ description: `Added annotation ${annotation.id}`,
1154
+ forward: this.suppressed(() => this.addAnnotation(structuredClone(cloned))),
1155
+ inverse: this.suppressed(() => this.removeAnnotation(annotation.id)),
1156
+ });
1157
+ }
1158
+
1159
+ removeAnnotation(id: string): boolean {
1160
+ const existing = this.annotations.get(id);
1161
+ const removed = this.annotations.delete(id);
1162
+ if (removed && existing) {
1163
+ const cloned = structuredClone(existing);
1164
+ this.scheduleSave();
1165
+ this.notifyChange('nodes');
1166
+ this.recordMutation({
1167
+ operationType: 'removeAnnotation',
1168
+ description: `Removed annotation ${id}`,
1169
+ forward: this.suppressed(() => this.removeAnnotation(id)),
1170
+ inverse: this.suppressed(() => this.addAnnotation(structuredClone(cloned))),
1171
+ });
1172
+ }
1173
+ return removed;
1174
+ }
1175
+
1176
+ getAnnotations(): CanvasAnnotation[] {
1177
+ return Array.from(this.annotations.values(), (annotation) => structuredClone(annotation));
1178
+ }
1179
+
1113
1180
  private removeEdgesForNode(nodeId: string): void {
1114
1181
  for (const [id, edge] of this.edges) {
1115
1182
  if (edge.from === nodeId || edge.to === nodeId) {
@@ -1123,6 +1190,7 @@ class CanvasStateManager {
1123
1190
  viewport: structuredClone(this._viewport),
1124
1191
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
1125
1192
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1193
+ annotations: this.getAnnotations(),
1126
1194
  };
1127
1195
  }
1128
1196
 
@@ -1131,6 +1199,7 @@ class CanvasStateManager {
1131
1199
  viewport: structuredClone(this._viewport),
1132
1200
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
1133
1201
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1202
+ annotations: this.getAnnotations(),
1134
1203
  };
1135
1204
  }
1136
1205
 
@@ -1376,10 +1445,12 @@ class CanvasStateManager {
1376
1445
  clear(): void {
1377
1446
  const oldNodes = Array.from(this.nodes.values()).map((n) => structuredClone(n));
1378
1447
  const oldEdges = Array.from(this.edges.values()).map((e) => structuredClone(e));
1448
+ const oldAnnotations = Array.from(this.annotations.values()).map((annotation) => structuredClone(annotation));
1379
1449
  const oldPins = Array.from(this._contextPinnedNodeIds);
1380
1450
  const oldViewport = { ...this._viewport };
1381
1451
  this.nodes.clear();
1382
1452
  this.edges.clear();
1453
+ this.annotations.clear();
1383
1454
  this._contextPinnedNodeIds.clear();
1384
1455
  this._viewport = { x: 0, y: 0, scale: 1 };
1385
1456
  this.scheduleSave();
@@ -1392,6 +1463,7 @@ class CanvasStateManager {
1392
1463
  inverse: this.suppressed(() => {
1393
1464
  for (const n of oldNodes) this.addNode(structuredClone(n));
1394
1465
  for (const e of oldEdges) this.addEdge(structuredClone(e));
1466
+ for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
1395
1467
  this.setContextPins(oldPins);
1396
1468
  this.setViewport(oldViewport);
1397
1469
  }),
@@ -120,18 +120,33 @@ function positiveFiniteNumber(value: unknown): number | null {
120
120
  return num !== null && num > 0 ? num : null;
121
121
  }
122
122
 
123
+ function elementHasCameraUpdate(elements: Array<Record<string, unknown>>): boolean {
124
+ return elements.some((element) => element.type === 'cameraUpdate');
125
+ }
126
+
127
+ function isTextBindableContainer(element: Record<string, unknown>): boolean {
128
+ return element.type === 'rectangle' || element.type === 'ellipse' || element.type === 'diamond';
129
+ }
130
+
123
131
  function labelFromBoundText(element: Record<string, unknown>): Record<string, unknown> | null {
124
132
  const text = typeof element.text === 'string' ? element.text : '';
125
133
  if (text.trim().length === 0) return null;
126
134
  const fontSize = positiveFiniteNumber(element.fontSize);
135
+ const textAlign = typeof element.textAlign === 'string' ? element.textAlign : null;
136
+ const verticalAlign = typeof element.verticalAlign === 'string' ? element.verticalAlign : null;
127
137
  return {
128
138
  text,
129
139
  ...(fontSize ? { fontSize } : {}),
140
+ ...(textAlign ? { textAlign } : {}),
141
+ ...(verticalAlign ? { verticalAlign } : {}),
130
142
  };
131
143
  }
132
144
 
133
- function elementHasCameraUpdate(elements: Array<Record<string, unknown>>): boolean {
134
- return elements.some((element) => element.type === 'cameraUpdate');
145
+ function boundTextRefId(value: unknown): string | null {
146
+ if (!isRecord(value) || value.type !== 'text' || typeof value.id !== 'string' || value.id.length === 0) {
147
+ return null;
148
+ }
149
+ return value.id;
135
150
  }
136
151
 
137
152
  function hasRenderableExcalidrawElement(elements: Array<Record<string, unknown>>): boolean {
@@ -153,37 +168,57 @@ function normalizeExcalidrawBoundText(elements: Array<Record<string, unknown>>):
153
168
  }
154
169
 
155
170
  let changed = false;
171
+ const containerIdByTextId = new Map<string, string>();
156
172
  const labelsByContainer = new Map<string, Record<string, unknown>>();
173
+ const collapsedTextIds = new Set<string>();
174
+
175
+ for (const container of elements) {
176
+ if (!isTextBindableContainer(container) || typeof container.id !== 'string' || !Array.isArray(container.boundElements)) continue;
177
+ for (const rawBoundElement of container.boundElements) {
178
+ const textId = boundTextRefId(rawBoundElement);
179
+ if (!textId) continue;
180
+ const textElement = elementsById.get(textId);
181
+ if (textElement?.type !== 'text') continue;
182
+ if (typeof textElement.containerId !== 'string') {
183
+ containerIdByTextId.set(textId, container.id);
184
+ }
185
+ }
186
+ }
157
187
 
158
188
  for (const element of elements) {
159
- if (element.type !== 'text' || typeof element.id !== 'string' || typeof element.containerId !== 'string') continue;
160
- const container = elementsById.get(element.containerId);
161
- if (!container || (container.type !== 'rectangle' && container.type !== 'ellipse' && container.type !== 'diamond')) continue;
189
+ if (element.type !== 'text' || typeof element.id !== 'string') continue;
190
+ const containerId = typeof element.containerId === 'string'
191
+ ? element.containerId
192
+ : containerIdByTextId.get(element.id);
193
+ if (!containerId) continue;
194
+ const container = elementsById.get(containerId);
195
+ if (!container || !isTextBindableContainer(container)) continue;
162
196
  const label = labelFromBoundText(element);
163
197
  if (!label) continue;
164
- labelsByContainer.set(element.containerId, label);
198
+ labelsByContainer.set(containerId, label);
199
+ collapsedTextIds.add(element.id);
165
200
  }
166
201
 
202
+ if (labelsByContainer.size === 0) return elements;
203
+
167
204
  const normalized: Array<Record<string, unknown>> = [];
168
205
  for (const element of elements) {
169
- if (element.type === 'text' && typeof element.containerId === 'string') {
170
- if (labelsByContainer.has(element.containerId)) {
171
- changed = true;
172
- continue;
173
- }
206
+ if (typeof element.id === 'string' && collapsedTextIds.has(element.id)) {
207
+ changed = true;
208
+ continue;
174
209
  }
175
210
 
176
- if (typeof element.id !== 'string' || !labelsByContainer.has(element.id)) {
177
- normalized.push(element);
211
+ if (typeof element.id === 'string' && labelsByContainer.has(element.id)) {
212
+ changed = true;
213
+ const { boundElements: _boundElements, ...container } = element;
214
+ normalized.push({
215
+ ...container,
216
+ label: labelsByContainer.get(element.id),
217
+ });
178
218
  continue;
179
219
  }
180
220
 
181
- changed = true;
182
- const { boundElements: _boundElements, ...container } = element;
183
- normalized.push({
184
- ...container,
185
- label: labelsByContainer.get(element.id),
186
- });
221
+ normalized.push(element);
187
222
  }
188
223
 
189
224
  return changed ? normalized : elements;
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import { canvasState, IMAGE_MIME_MAP } from './canvas-state.js';
3
- import type { CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from './canvas-state.js';
3
+ import type { CanvasAnnotation, CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from './canvas-state.js';
4
4
  import { findCanvasExtAppNodeId } from './ext-app-lookup.js';
5
5
  import { onFileNodeChanged } from './file-watcher.js';
6
6
  import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
@@ -314,6 +314,23 @@ export class PmxCanvas extends EventEmitter {
314
314
  return id;
315
315
  }
316
316
 
317
+ addAnnotation(input: Omit<CanvasAnnotation, 'id' | 'createdAt'> & { id?: string; createdAt?: string }): string {
318
+ const id = input.id ?? `ann-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
319
+ canvasState.addAnnotation({
320
+ ...input,
321
+ id,
322
+ createdAt: input.createdAt ?? new Date().toISOString(),
323
+ });
324
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
325
+ return id;
326
+ }
327
+
328
+ removeAnnotation(id: string): boolean {
329
+ const removed = canvasState.removeAnnotation(id);
330
+ if (removed) emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
331
+ return removed;
332
+ }
333
+
317
334
  removeEdge(id: string): void {
318
335
  removeCanvasEdge(id);
319
336
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
@@ -416,7 +433,7 @@ export class PmxCanvas extends EventEmitter {
416
433
 
417
434
  getSpatialContext() {
418
435
  const layout = canvasState.getLayout();
419
- return buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds);
436
+ return buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds, layout.annotations);
420
437
  }
421
438
 
422
439
  async undo(): Promise<{ ok: boolean; description?: string }> {
@@ -723,7 +740,7 @@ export {
723
740
  screenshotCanvasAutomationWebView,
724
741
  } from './server.js';
725
742
  export { canvasState } from './canvas-state.js';
726
- export type { CanvasSnapshot, CanvasSnapshotGcResult, CanvasSnapshotListOptions } from './canvas-state.js';
743
+ export type { CanvasAnnotation, CanvasSnapshot, CanvasSnapshotGcResult, CanvasSnapshotListOptions } from './canvas-state.js';
727
744
  export { findOpenCanvasPosition } from './placement.js';
728
745
  export { searchNodes, buildSpatialContext, detectClusters, findNeighborhoods } from './spatial-analysis.js';
729
746
  export type { SpatialCluster, SpatialContext, SpatialNeighbor, NodeSpatialInfo } from './spatial-analysis.js';
@@ -22,6 +22,8 @@ export type MutationOp =
22
22
  | 'removeNode'
23
23
  | 'addEdge'
24
24
  | 'removeEdge'
25
+ | 'addAnnotation'
26
+ | 'removeAnnotation'
25
27
  | 'clear'
26
28
  | 'arrange'
27
29
  | 'restoreSnapshot'
@@ -46,7 +46,7 @@ import type {
46
46
  ListResourceTemplatesResult,
47
47
  ListToolsResult,
48
48
  } from '@modelcontextprotocol/sdk/types.js';
49
- import { type CanvasEdge, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
49
+ import { type CanvasAnnotation, type CanvasEdge, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
50
50
  import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
51
51
  import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
52
52
  import { getMcpAppHostSnapshot } from './mcp-app-host.js';
@@ -71,6 +71,7 @@ import {
71
71
  serializeCanvasLayoutWithBlobSummaries,
72
72
  serializeCanvasNode,
73
73
  serializeCanvasNodeWithBlobSummaries,
74
+ summarizeCanvasAnnotation,
74
75
  } from './canvas-serialization.js';
75
76
  import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
76
77
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
@@ -1216,11 +1217,83 @@ async function handleCanvasViewport(req: Request): Promise<Response> {
1216
1217
  y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
1217
1218
  scale: typeof body.scale === 'number' ? body.scale : canvasState.viewport.scale,
1218
1219
  };
1219
- canvasState.setViewport(next);
1220
+ if (body.recordHistory === false) {
1221
+ canvasState.withSuppressedRecording(() => {
1222
+ canvasState.setViewport(next);
1223
+ });
1224
+ } else {
1225
+ canvasState.setViewport(next);
1226
+ }
1220
1227
  emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
1221
1228
  return responseJson({ ok: true });
1222
1229
  }
1223
1230
 
1231
+ function annotationBounds(points: CanvasAnnotation['points']): CanvasAnnotation['bounds'] {
1232
+ const xs = points.map((point) => point.x);
1233
+ const ys = points.map((point) => point.y);
1234
+ const minX = Math.min(...xs);
1235
+ const minY = Math.min(...ys);
1236
+ const maxX = Math.max(...xs);
1237
+ const maxY = Math.max(...ys);
1238
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
1239
+ }
1240
+
1241
+ function parseAnnotationPoints(value: unknown): CanvasAnnotation['points'] {
1242
+ if (!Array.isArray(value)) return [];
1243
+ return value
1244
+ .map((point) => {
1245
+ if (!point || typeof point !== 'object' || Array.isArray(point)) return null;
1246
+ const record = point as Record<string, unknown>;
1247
+ if (typeof record.x !== 'number' || typeof record.y !== 'number') return null;
1248
+ if (!Number.isFinite(record.x) || !Number.isFinite(record.y)) return null;
1249
+ return { x: record.x, y: record.y };
1250
+ })
1251
+ .filter((point): point is CanvasAnnotation['points'][number] => point !== null);
1252
+ }
1253
+
1254
+ async function handleCanvasAddAnnotation(req: Request): Promise<Response> {
1255
+ const body = await readJson(req);
1256
+ const points = parseAnnotationPoints(body.points);
1257
+ if (points.length < 2) {
1258
+ return responseJson({ ok: false, error: 'Annotation requires at least two valid points.' }, 400);
1259
+ }
1260
+
1261
+ const width = typeof body.width === 'number' && Number.isFinite(body.width)
1262
+ ? Math.min(24, Math.max(1, body.width))
1263
+ : 4;
1264
+ const color = typeof body.color === 'string' && (body.color === 'currentColor' || /^#[0-9a-fA-F]{6}$/.test(body.color))
1265
+ ? body.color
1266
+ : 'currentColor';
1267
+ const label = typeof body.label === 'string' && body.label.trim().length > 0
1268
+ ? body.label.trim().slice(0, 160)
1269
+ : undefined;
1270
+ const id = typeof body.id === 'string' && body.id.trim().length > 0
1271
+ ? body.id.trim().slice(0, 120)
1272
+ : `ann-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1273
+ const annotation: CanvasAnnotation = {
1274
+ id,
1275
+ type: 'freehand',
1276
+ points,
1277
+ bounds: annotationBounds(points),
1278
+ color,
1279
+ width,
1280
+ ...(label ? { label } : {}),
1281
+ createdAt: new Date().toISOString(),
1282
+ };
1283
+
1284
+ canvasState.addAnnotation(annotation);
1285
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1286
+ return responseJson({ ok: true, annotation: summarizeCanvasAnnotation(annotation) });
1287
+ }
1288
+
1289
+ function handleCanvasRemoveAnnotation(id: string): Response {
1290
+ const decodedId = decodeURIComponent(id);
1291
+ const removed = canvasState.removeAnnotation(decodedId);
1292
+ if (!removed) return responseJson({ ok: false, error: `Annotation "${decodedId}" not found.` }, 404);
1293
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1294
+ return responseJson({ ok: true, removed: decodedId });
1295
+ }
1296
+
1224
1297
  // ── Serve image file for image nodes ─────────────────────────
1225
1298
  async function handleCanvasImage(pathname: string): Promise<Response> {
1226
1299
  const nodeId = pathname.replace('/api/canvas/image/', '');
@@ -1338,6 +1411,14 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1338
1411
  const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1339
1412
  ? body.data as Record<string, unknown>
1340
1413
  : undefined;
1414
+ if (type === 'html') {
1415
+ if ('html' in body && typeof body.html !== 'string') {
1416
+ return responseJson({ ok: false, error: 'HTML node field "html" must be a string.' }, 400);
1417
+ }
1418
+ if (extraData && 'html' in extraData && typeof extraData.html !== 'string') {
1419
+ return responseJson({ ok: false, error: 'HTML node field "data.html" must be a string.' }, 400);
1420
+ }
1421
+ }
1341
1422
  const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
1342
1423
  ? body.path
1343
1424
  : body.content;
@@ -1395,6 +1476,15 @@ async function handleCanvasCreateGroup(req: Request): Promise<Response> {
1395
1476
  body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
1396
1477
  ? body.childLayout
1397
1478
  : undefined;
1479
+ if (childIds.length > 0) {
1480
+ const missingChildIds = childIds.filter((id) => !canvasState.getNode(id));
1481
+ if (missingChildIds.length > 0) {
1482
+ return responseJson({
1483
+ ok: false,
1484
+ error: `Cannot create group: missing child node ID${missingChildIds.length === 1 ? '' : 's'}: ${missingChildIds.join(', ')}.`,
1485
+ }, 400);
1486
+ }
1487
+ }
1398
1488
 
1399
1489
  const { node } = createCanvasGroup({ title, childIds, color, x, y, width, height, ...(childLayout ? { childLayout } : {}) });
1400
1490
 
@@ -3979,6 +4069,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3979
4069
  return handleCanvasViewport(req);
3980
4070
  }
3981
4071
 
4072
+ if (url.pathname === '/api/canvas/annotation' && req.method === 'POST') {
4073
+ return handleCanvasAddAnnotation(req);
4074
+ }
4075
+
4076
+ if (url.pathname.startsWith('/api/canvas/annotation/') && req.method === 'DELETE') {
4077
+ return handleCanvasRemoveAnnotation(url.pathname.slice('/api/canvas/annotation/'.length));
4078
+ }
4079
+
3982
4080
  if (url.pathname === '/api/canvas/node' && req.method === 'POST') {
3983
4081
  return handleCanvasAddNode(req);
3984
4082
  }
@@ -4094,7 +4192,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4094
4192
  // Spatial context API
4095
4193
  if (url.pathname === '/api/canvas/spatial-context' && req.method === 'GET') {
4096
4194
  const layout = canvasState.getLayout();
4097
- const spatial = buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds);
4195
+ const spatial = buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds, layout.annotations);
4098
4196
  return responseJson(spatial);
4099
4197
  }
4100
4198
 
@@ -10,7 +10,7 @@
10
10
  * semantic clusters, ordered context, and implicit human intent.
11
11
  */
12
12
 
13
- import type { CanvasNodeState, CanvasEdge } from './canvas-state.js';
13
+ import type { CanvasAnnotation, CanvasNodeState, CanvasEdge } from './canvas-state.js';
14
14
  import { summarizeNodeForAgentContext } from './agent-context.js';
15
15
 
16
16
  // ── Types ────────────────────────────────────────────────────────────
@@ -45,6 +45,15 @@ export interface NodeSpatialInfo {
45
45
  readingOrder: number;
46
46
  }
47
47
 
48
+ export interface SpatialAnnotationContext {
49
+ id: string;
50
+ label: string | null;
51
+ bounds: CanvasAnnotation['bounds'];
52
+ targetNodeIds: string[];
53
+ targetNodeTitles: string[];
54
+ target: string;
55
+ }
56
+
48
57
  export interface SpatialContext {
49
58
  /** Total nodes on canvas */
50
59
  totalNodes: number;
@@ -58,6 +67,7 @@ export interface SpatialContext {
58
67
  pinnedNodeTitle: string | null;
59
68
  neighbors: SpatialNeighbor[];
60
69
  }[];
70
+ annotations: SpatialAnnotationContext[];
61
71
  }
62
72
 
63
73
  // ── Helpers ──────────────────────────────────────────────────────────
@@ -125,6 +135,39 @@ function deriveClusterLabel(nodes: CanvasNodeState[]): string {
125
135
  return parts.join(', ');
126
136
  }
127
137
 
138
+ function rectsOverlap(
139
+ a: { x: number; y: number; width: number; height: number },
140
+ b: { x: number; y: number; width: number; height: number },
141
+ ): boolean {
142
+ return a.x <= b.x + b.width &&
143
+ a.x + a.width >= b.x &&
144
+ a.y <= b.y + b.height &&
145
+ a.y + a.height >= b.y;
146
+ }
147
+
148
+ function summarizeAnnotationForSpatialContext(
149
+ annotation: CanvasAnnotation,
150
+ nodes: CanvasNodeState[],
151
+ ): SpatialAnnotationContext {
152
+ const targetNodes = nodes.filter((node) => rectsOverlap(annotation.bounds, {
153
+ x: node.position.x,
154
+ y: node.position.y,
155
+ width: node.size.width,
156
+ height: node.size.height,
157
+ }));
158
+ const targetNodeTitles = targetNodes.map((node) =>
159
+ typeof node.data.title === 'string' && node.data.title.length > 0 ? node.data.title : node.id,
160
+ );
161
+ return {
162
+ id: annotation.id,
163
+ label: annotation.label ?? null,
164
+ bounds: annotation.bounds,
165
+ targetNodeIds: targetNodes.map((node) => node.id),
166
+ targetNodeTitles,
167
+ target: targetNodeTitles.length > 0 ? targetNodeTitles.join(', ') : 'empty canvas region',
168
+ };
169
+ }
170
+
128
171
  // ── Core Analysis ────────────────────────────────────────────────────
129
172
 
130
173
  /**
@@ -320,6 +363,7 @@ export function buildSpatialContext(
320
363
  nodes: CanvasNodeState[],
321
364
  _edges: CanvasEdge[],
322
365
  pinnedIds: Set<string>,
366
+ annotations: CanvasAnnotation[] = [],
323
367
  ): SpatialContext {
324
368
  const clusters = detectClusters(nodes);
325
369
 
@@ -352,5 +396,6 @@ export function buildSpatialContext(
352
396
  clusters,
353
397
  nodesInReadingOrder,
354
398
  pinnedNeighborhoods,
399
+ annotations: annotations.map((annotation) => summarizeAnnotationForSpatialContext(annotation, nodes)),
355
400
  };
356
401
  }
@@ -371,6 +371,7 @@ export class SemanticWatchReducer {
371
371
  this.currentLayout.nodes,
372
372
  this.currentLayout.edges,
373
373
  this.currentPins,
374
+ this.currentLayout.annotations ?? [],
374
375
  );
375
376
 
376
377
  if (previousEventPins.added.length === 0 && previousEventPins.removed.length === 0) {
@@ -394,7 +395,7 @@ export class SemanticWatchReducer {
394
395
  const meta = normalizeEventMeta(payload);
395
396
  if (!this.currentLayout) {
396
397
  this.currentLayout = layout;
397
- this.previousSpatial = buildSpatialContext(layout.nodes, layout.edges, this.currentPins);
398
+ this.previousSpatial = buildSpatialContext(layout.nodes, layout.edges, this.currentPins, layout.annotations ?? []);
398
399
  return [];
399
400
  }
400
401
 
@@ -403,8 +404,9 @@ export class SemanticWatchReducer {
403
404
  prevLayout.nodes,
404
405
  prevLayout.edges,
405
406
  this.currentPins,
407
+ prevLayout.annotations ?? [],
406
408
  );
407
- const nextSpatial = buildSpatialContext(layout.nodes, layout.edges, this.currentPins);
409
+ const nextSpatial = buildSpatialContext(layout.nodes, layout.edges, this.currentPins, layout.annotations ?? []);
408
410
  const events: SemanticWatchEvent[] = [];
409
411
 
410
412
  const prevNodeMap = toNodeMap(prevLayout.nodes);