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
@@ -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';
@@ -1227,6 +1228,72 @@ async function handleCanvasViewport(req: Request): Promise<Response> {
1227
1228
  return responseJson({ ok: true });
1228
1229
  }
1229
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
+
1230
1297
  // ── Serve image file for image nodes ─────────────────────────
1231
1298
  async function handleCanvasImage(pathname: string): Promise<Response> {
1232
1299
  const nodeId = pathname.replace('/api/canvas/image/', '');
@@ -4002,6 +4069,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4002
4069
  return handleCanvasViewport(req);
4003
4070
  }
4004
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
+
4005
4080
  if (url.pathname === '/api/canvas/node' && req.method === 'POST') {
4006
4081
  return handleCanvasAddNode(req);
4007
4082
  }
@@ -4117,7 +4192,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4117
4192
  // Spatial context API
4118
4193
  if (url.pathname === '/api/canvas/spatial-context' && req.method === 'GET') {
4119
4194
  const layout = canvasState.getLayout();
4120
- const spatial = buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds);
4195
+ const spatial = buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds, layout.annotations);
4121
4196
  return responseJson(spatial);
4122
4197
  }
4123
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);