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
@@ -106,6 +106,7 @@ export interface CanvasAccess {
106
106
  buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult>;
107
107
  updateNode(id: string, patch: UpdateNodePatch): Promise<void>;
108
108
  removeNode(id: string): Promise<void>;
109
+ removeAnnotation(id: string): Promise<boolean>;
109
110
  addEdge(input: AddEdgeInput): Promise<string>;
110
111
  removeEdge(id: string): Promise<void>;
111
112
  createGroup(input: CreateGroupInput): Promise<string>;
@@ -203,6 +204,10 @@ class LocalCanvasAccess implements CanvasAccess {
203
204
  this.canvas.removeNode(id);
204
205
  }
205
206
 
207
+ async removeAnnotation(id: string): Promise<boolean> {
208
+ return this.canvas.removeAnnotation(id);
209
+ }
210
+
206
211
  async addEdge(input: AddEdgeInput): Promise<string> {
207
212
  return this.canvas.addEdge(input);
208
213
  }
@@ -455,6 +460,11 @@ class RemoteCanvasAccess implements CanvasAccess {
455
460
  await this.requestJson<unknown>('DELETE', `/api/canvas/node/${encodeURIComponent(id)}`);
456
461
  }
457
462
 
463
+ async removeAnnotation(id: string): Promise<boolean> {
464
+ const response = await this.requestJson<{ ok?: boolean }>('DELETE', `/api/canvas/annotation/${encodeURIComponent(id)}`);
465
+ return response.ok === true;
466
+ }
467
+
458
468
  async addEdge(input: AddEdgeInput): Promise<string> {
459
469
  const response = await this.requestJson<{ id?: string }>('POST', '/api/canvas/edge', input);
460
470
  if (!response.id) throw new Error('Canvas edge response did not include an edge id.');
package/src/mcp/server.ts CHANGED
@@ -29,7 +29,7 @@ import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './ca
29
29
  import { serializeNodeForAgentContext } from '../server/agent-context.js';
30
30
  import { wrapCanvasAutomationScript } from '../server/server.js';
31
31
  import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
32
- import { getCanvasNodeTitle, serializeCanvasLayout, serializeCanvasNode } from '../server/canvas-serialization.js';
32
+ import { getCanvasNodeTitle, serializeCanvasLayout, serializeCanvasNode, summarizeCanvasAnnotationForContext } from '../server/canvas-serialization.js';
33
33
  import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
34
34
 
35
35
  let canvas: CanvasAccess | null = null;
@@ -191,6 +191,7 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
191
191
  return {
192
192
  summary: buildSummaryFromLayout(layout, pinnedIds),
193
193
  viewport: layout.viewport,
194
+ annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
194
195
  nodes: layout.nodes.map((node) => compactNodePayload(node)).filter((node): node is Record<string, unknown> => node !== null),
195
196
  edges: layout.edges.map((edge) => ({
196
197
  id: edge.id,
@@ -204,6 +205,13 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
204
205
  };
205
206
  }
206
207
 
208
+ function agentSafeFullLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
209
+ return {
210
+ ...serializeCanvasLayout(layout),
211
+ annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
212
+ };
213
+ }
214
+
207
215
  function compactBatchValue(value: unknown): unknown {
208
216
  if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
209
217
  const record = value as Record<string, unknown>;
@@ -248,6 +256,8 @@ function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayo
248
256
  return {
249
257
  totalNodes: layout.nodes.length,
250
258
  totalEdges: layout.edges.length,
259
+ totalAnnotations: (layout.annotations ?? []).length,
260
+ annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
251
261
  nodesByType,
252
262
  pinnedCount: pinned.size,
253
263
  pinnedTitles,
@@ -263,6 +273,7 @@ function buildSnapshotRestoreSummary(layout: Awaited<ReturnType<CanvasAccess['ge
263
273
  return {
264
274
  nodeCount: layout.nodes.length,
265
275
  edgeCount: layout.edges.length,
276
+ annotationCount: (layout.annotations ?? []).length,
266
277
  nodesByType,
267
278
  viewport: layout.viewport,
268
279
  };
@@ -287,7 +298,7 @@ export async function startMcpServer(): Promise<void> {
287
298
  const c = await ensureCanvas();
288
299
  const layout = await c.getLayout();
289
300
  const payload = wantsFullPayload(input)
290
- ? serializeCanvasLayout(layout)
301
+ ? agentSafeFullLayoutPayload(layout)
291
302
  : compactLayoutPayload(layout, await c.getPinnedNodeIds());
292
303
  return {
293
304
  content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
@@ -902,6 +913,26 @@ export async function startMcpServer(): Promise<void> {
902
913
  },
903
914
  );
904
915
 
916
+ // ── canvas_remove_annotation ─────────────────────────────────────
917
+ server.tool(
918
+ 'canvas_remove_annotation',
919
+ 'Remove a human-drawn canvas annotation by ID.',
920
+ { id: z.string().describe('Annotation ID to remove') },
921
+ async ({ id }) => {
922
+ const c = await ensureCanvas();
923
+ const removed = await c.removeAnnotation(id);
924
+ if (!removed) {
925
+ return {
926
+ content: [{ type: 'text', text: `Annotation "${id}" not found.` }],
927
+ isError: true,
928
+ };
929
+ }
930
+ return {
931
+ content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
932
+ };
933
+ },
934
+ );
935
+
905
936
  // ── canvas_add_edge ────────────────────────────────────────────
906
937
  server.tool(
907
938
  'canvas_add_edge',
@@ -1401,7 +1432,7 @@ export async function startMcpServer(): Promise<void> {
1401
1432
  },
1402
1433
  async () => {
1403
1434
  const c = await ensureCanvas();
1404
- const layout = serializeCanvasLayout(await c.getLayout());
1435
+ const layout = agentSafeFullLayoutPayload(await c.getLayout());
1405
1436
  return {
1406
1437
  contents: [
1407
1438
  {
@@ -1452,7 +1483,7 @@ export async function startMcpServer(): Promise<void> {
1452
1483
  async () => {
1453
1484
  const c = await ensureCanvas();
1454
1485
  const layout = await c.getLayout();
1455
- const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()));
1486
+ const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()), layout.annotations ?? []);
1456
1487
  return {
1457
1488
  contents: [
1458
1489
  {
@@ -223,6 +223,31 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
223
223
  'Webpage nodes persist `data.provenance` with the source URL and refresh strategy so reopened snapshots can be re-fetched.',
224
224
  ],
225
225
  },
226
+ {
227
+ type: 'html',
228
+ kind: 'node',
229
+ description: 'Sandboxed iframe node rendered from inline HTML.',
230
+ endpoint: '/api/canvas/node',
231
+ mcpTool: 'canvas_add_html_node',
232
+ fields: [
233
+ { name: 'html', type: 'string', required: false, description: 'HTML document or fragment rendered in the sandboxed iframe.', aliases: ['content', 'stdin'] },
234
+ { name: 'title', type: 'string', required: false, description: 'Optional node title.' },
235
+ { name: 'x', type: 'number', required: false, description: 'Optional X position.' },
236
+ { name: 'y', type: 'number', required: false, description: 'Optional Y position.' },
237
+ { name: 'width', type: 'number', required: false, description: 'Optional node width.' },
238
+ { name: 'height', type: 'number', required: false, description: 'Optional node height.' },
239
+ { name: 'strictSize', type: 'boolean', required: false, description: 'Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting.', aliases: ['strict-size', 'scroll-overflow'] },
240
+ ],
241
+ example: {
242
+ type: 'html',
243
+ title: 'HTML Widget',
244
+ html: '<main><h1>Hello from PMX Canvas</h1></main>',
245
+ },
246
+ notes: [
247
+ 'The CLI accepts --content as an alias and stores it as data.html so the renderer can load it.',
248
+ 'HTML runs in a sandboxed iframe without same-origin access to the canvas host.',
249
+ ],
250
+ },
226
251
  {
227
252
  type: 'mcp-app',
228
253
  kind: 'node',
@@ -1,5 +1,5 @@
1
1
  import { canvasState } from './canvas-state.js';
2
- import type { CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
2
+ import type { CanvasAnnotation, CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
3
3
  import {
4
4
  normalizeCanvasNodeData,
5
5
  type CanvasNodeProvenance,
@@ -19,6 +19,26 @@ export interface SerializedCanvasLayout extends Omit<CanvasLayout, 'nodes'> {
19
19
  nodes: SerializedCanvasNode[];
20
20
  }
21
21
 
22
+ export interface CanvasAnnotationSummary {
23
+ id: string;
24
+ type: CanvasAnnotation['type'];
25
+ bounds: CanvasAnnotation['bounds'];
26
+ color: string;
27
+ width: number;
28
+ pointCount: number;
29
+ label: string | null;
30
+ createdAt: string;
31
+ }
32
+
33
+ export interface CanvasAnnotationContextSummary {
34
+ id: string;
35
+ label: string | null;
36
+ bounds: CanvasAnnotation['bounds'];
37
+ targetNodeIds: string[];
38
+ targetNodeTitles: string[];
39
+ target: string;
40
+ }
41
+
22
42
  interface BlobSummary {
23
43
  stored: 'sidecar';
24
44
  path: string;
@@ -104,9 +124,55 @@ export function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): Se
104
124
  };
105
125
  }
106
126
 
127
+ export function summarizeCanvasAnnotation(annotation: CanvasAnnotation): CanvasAnnotationSummary {
128
+ return {
129
+ id: annotation.id,
130
+ type: annotation.type,
131
+ bounds: annotation.bounds,
132
+ color: annotation.color,
133
+ width: annotation.width,
134
+ pointCount: annotation.points.length,
135
+ label: annotation.label ?? null,
136
+ createdAt: annotation.createdAt,
137
+ };
138
+ }
139
+
140
+ function rectsOverlap(
141
+ a: { x: number; y: number; width: number; height: number },
142
+ b: { x: number; y: number; width: number; height: number },
143
+ ): boolean {
144
+ return a.x <= b.x + b.width &&
145
+ a.x + a.width >= b.x &&
146
+ a.y <= b.y + b.height &&
147
+ a.y + a.height >= b.y;
148
+ }
149
+
150
+ export function summarizeCanvasAnnotationForContext(
151
+ annotation: CanvasAnnotation,
152
+ nodes: CanvasNodeState[],
153
+ ): CanvasAnnotationContextSummary {
154
+ const targetNodes = nodes.filter((node) => rectsOverlap(annotation.bounds, {
155
+ x: node.position.x,
156
+ y: node.position.y,
157
+ width: node.size.width,
158
+ height: node.size.height,
159
+ }));
160
+ const targetNodeTitles = targetNodes.map((node) => getCanvasNodeTitle(node) ?? node.id);
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
+
107
171
  export interface CanvasSummary {
108
172
  totalNodes: number;
109
173
  totalEdges: number;
174
+ totalAnnotations: number;
175
+ annotations: CanvasAnnotationContextSummary[];
110
176
  nodesByType: Record<string, number>;
111
177
  pinnedCount: number;
112
178
  pinnedTitles: string[];
@@ -130,6 +196,8 @@ export function buildCanvasSummary(): CanvasSummary {
130
196
  return {
131
197
  totalNodes: layout.nodes.length,
132
198
  totalEdges: layout.edges.length,
199
+ totalAnnotations: layout.annotations.length,
200
+ annotations: layout.annotations.map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
133
201
  nodesByType: typeCounts,
134
202
  pinnedCount: pinnedIds.size,
135
203
  pinnedTitles,
@@ -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'