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
@@ -0,0 +1,4 @@
1
+ import type { CanvasAnnotation } from '../types';
2
+ export declare function AnnotationLayer({ annotations }: {
3
+ annotations: CanvasAnnotation[];
4
+ }): import("preact/src").JSX.Element | null;
@@ -1,8 +1,11 @@
1
1
  import type { CanvasNodeState } from '../types';
2
+ type AnnotationTool = 'pen' | 'eraser' | null;
2
3
  interface CanvasViewportProps {
3
4
  onNodeContextMenu?: (e: MouseEvent, nodeId: string) => void;
4
5
  onCanvasContextMenu?: (e: MouseEvent, canvasX: number, canvasY: number) => void;
6
+ annotationMode?: boolean;
7
+ annotationTool?: AnnotationTool;
5
8
  }
6
9
  export declare function getRenderableWorldNodes(allNodes: Iterable<CanvasNodeState>, focusedNodeId: string | null): CanvasNodeState[];
7
- export declare function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu }: CanvasViewportProps): import("preact/src").JSX.Element;
10
+ export declare function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotationMode, annotationTool }: CanvasViewportProps): import("preact/src").JSX.Element;
8
11
  export {};
@@ -4,6 +4,7 @@ interface PanZoomOptions {
4
4
  viewport: Signal<ViewportState>;
5
5
  onViewportChange: (v: ViewportState) => void;
6
6
  onViewportCommit: (v: ViewportState) => void;
7
+ disabled?: boolean;
7
8
  }
8
9
  /**
9
10
  * Hook that wires up pan/zoom interactions on a container element.
@@ -12,5 +13,5 @@ interface PanZoomOptions {
12
13
  * - Pointer drag on background: pan
13
14
  * - Pinch (touch): zoom
14
15
  */
15
- export declare function usePanZoom({ viewport, onViewportChange, onViewportCommit }: PanZoomOptions): import("preact/src").RefObject<HTMLDivElement>;
16
+ export declare function usePanZoom({ viewport, onViewportChange, onViewportCommit, disabled }: PanZoomOptions): import("preact/src").RefObject<HTMLDivElement>;
16
17
  export {};
@@ -19,6 +19,10 @@ export declare function IconMinimap(p: IconProps): JSX.Element;
19
19
  export declare function IconSun(p: IconProps): JSX.Element;
20
20
  /** Crescent moon */
21
21
  export declare function IconMoon(p: IconProps): JSX.Element;
22
+ /** Pen stroke — canvas annotation mode */
23
+ export declare function IconPen(p: IconProps): JSX.Element;
24
+ /** Eraser — remove canvas annotations */
25
+ export declare function IconEraser(p: IconProps): JSX.Element;
22
26
  /** Camera — snapshots */
23
27
  export declare function IconSnapshot(p: IconProps): JSX.Element;
24
28
  /** Bullseye — trace toggle */
@@ -1,7 +1,8 @@
1
- import { type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
1
+ import { type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
2
2
  export declare const viewport: import("@preact/signals-core").Signal<ViewportState>;
3
3
  export declare const nodes: import("@preact/signals-core").Signal<Map<string, CanvasNodeState>>;
4
4
  export declare const edges: import("@preact/signals-core").Signal<Map<string, CanvasEdge>>;
5
+ export declare const annotations: import("@preact/signals-core").Signal<Map<string, CanvasAnnotation>>;
5
6
  export declare const activeNodeId: import("@preact/signals-core").Signal<string | null>;
6
7
  export declare const connectionStatus: import("@preact/signals-core").Signal<ConnectionStatus>;
7
8
  export declare const sessionId: import("@preact/signals-core").Signal<string>;
@@ -41,6 +42,19 @@ export declare function removeNode(id: string): void;
41
42
  export declare function addEdge(edge: CanvasEdge): void;
42
43
  export declare function removeEdge(id: string): void;
43
44
  export declare function removeEdgesForNode(nodeId: string): void;
45
+ export declare function addAnnotation(annotation: CanvasAnnotation): void;
46
+ export declare function removeAnnotation(id: string): void;
47
+ export declare function createAnnotationFromClient(input: {
48
+ points: CanvasAnnotation['points'];
49
+ color: string;
50
+ width: number;
51
+ label?: string;
52
+ }): Promise<{
53
+ ok: boolean;
54
+ }>;
55
+ export declare function removeAnnotationFromClient(id: string): Promise<{
56
+ ok: boolean;
57
+ }>;
44
58
  export declare function resizeNode(id: string, size: {
45
59
  width: number;
46
60
  height: number;
@@ -56,6 +70,7 @@ export declare function replaceViewport(next: ViewportState): void;
56
70
  export declare function commitViewport(next: ViewportState): void;
57
71
  export declare function applyServerCanvasLayout(layout: Pick<CanvasLayout, 'nodes' | 'edges'> & {
58
72
  viewport?: ViewportState;
73
+ annotations?: CanvasAnnotation[];
59
74
  }, options?: {
60
75
  applyViewport?: boolean;
61
76
  }): void;
@@ -29,6 +29,25 @@ export interface CanvasEdge {
29
29
  style?: 'solid' | 'dashed' | 'dotted';
30
30
  animated?: boolean;
31
31
  }
32
+ export interface CanvasAnnotationPoint {
33
+ x: number;
34
+ y: number;
35
+ }
36
+ export interface CanvasAnnotation {
37
+ id: string;
38
+ type: 'freehand';
39
+ points: CanvasAnnotationPoint[];
40
+ bounds: {
41
+ x: number;
42
+ y: number;
43
+ width: number;
44
+ height: number;
45
+ };
46
+ color: string;
47
+ width: number;
48
+ label?: string;
49
+ createdAt: string;
50
+ }
32
51
  export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
33
52
  export declare const TYPE_LABELS: Record<CanvasNodeState['type'], string>;
34
53
  /** Node types that support the full-viewport expand/focus overlay. */
@@ -40,4 +59,5 @@ export interface CanvasLayout {
40
59
  viewport: ViewportState;
41
60
  nodes: CanvasNodeState[];
42
61
  edges: CanvasEdge[];
62
+ annotations?: CanvasAnnotation[];
43
63
  }
@@ -54,6 +54,7 @@ export interface CanvasAccess {
54
54
  buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult>;
55
55
  updateNode(id: string, patch: UpdateNodePatch): Promise<void>;
56
56
  removeNode(id: string): Promise<void>;
57
+ removeAnnotation(id: string): Promise<boolean>;
57
58
  addEdge(input: AddEdgeInput): Promise<string>;
58
59
  removeEdge(id: string): Promise<void>;
59
60
  createGroup(input: CreateGroupInput): Promise<string>;
@@ -1,4 +1,4 @@
1
- import type { CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
1
+ import type { CanvasAnnotation, CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
2
2
  import { type CanvasNodeProvenance } from './canvas-provenance.js';
3
3
  export interface SerializedCanvasNode extends CanvasNodeState {
4
4
  kind: string;
@@ -11,6 +11,24 @@ export interface SerializedCanvasNode extends CanvasNodeState {
11
11
  export interface SerializedCanvasLayout extends Omit<CanvasLayout, 'nodes'> {
12
12
  nodes: SerializedCanvasNode[];
13
13
  }
14
+ export interface CanvasAnnotationSummary {
15
+ id: string;
16
+ type: CanvasAnnotation['type'];
17
+ bounds: CanvasAnnotation['bounds'];
18
+ color: string;
19
+ width: number;
20
+ pointCount: number;
21
+ label: string | null;
22
+ createdAt: string;
23
+ }
24
+ export interface CanvasAnnotationContextSummary {
25
+ id: string;
26
+ label: string | null;
27
+ bounds: CanvasAnnotation['bounds'];
28
+ targetNodeIds: string[];
29
+ targetNodeTitles: string[];
30
+ target: string;
31
+ }
14
32
  export declare function getCanvasNodeKind(node: CanvasNodeState, data: Record<string, unknown>): string;
15
33
  export declare function getCanvasNodeTitle(node: CanvasNodeState): string | null;
16
34
  export declare function getCanvasNodeContent(node: CanvasNodeState): string | null;
@@ -18,9 +36,13 @@ export declare function serializeCanvasNode(node: CanvasNodeState): SerializedCa
18
36
  export declare function serializeCanvasNodeWithBlobSummaries(node: CanvasNodeState): SerializedCanvasNode;
19
37
  export declare function serializeCanvasLayout(layout: CanvasLayout): SerializedCanvasLayout;
20
38
  export declare function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): SerializedCanvasLayout;
39
+ export declare function summarizeCanvasAnnotation(annotation: CanvasAnnotation): CanvasAnnotationSummary;
40
+ export declare function summarizeCanvasAnnotationForContext(annotation: CanvasAnnotation, nodes: CanvasNodeState[]): CanvasAnnotationContextSummary;
21
41
  export interface CanvasSummary {
22
42
  totalNodes: number;
23
43
  totalEdges: number;
44
+ totalAnnotations: number;
45
+ annotations: CanvasAnnotationContextSummary[];
24
46
  nodesByType: Record<string, number>;
25
47
  pinnedCount: number;
26
48
  pinnedTitles: string[];
@@ -22,6 +22,7 @@ interface PersistedCanvasState {
22
22
  viewport: ViewportState;
23
23
  nodes: CanvasNodeState[];
24
24
  edges: CanvasEdge[];
25
+ annotations?: CanvasAnnotation[];
25
26
  contextPins: string[];
26
27
  }
27
28
  interface LoadFromDiskOptions {
@@ -81,10 +82,30 @@ export interface CanvasEdge {
81
82
  style?: 'solid' | 'dashed' | 'dotted';
82
83
  animated?: boolean;
83
84
  }
85
+ export interface CanvasAnnotationPoint {
86
+ x: number;
87
+ y: number;
88
+ }
89
+ export interface CanvasAnnotation {
90
+ id: string;
91
+ type: 'freehand';
92
+ points: CanvasAnnotationPoint[];
93
+ bounds: {
94
+ x: number;
95
+ y: number;
96
+ width: number;
97
+ height: number;
98
+ };
99
+ color: string;
100
+ width: number;
101
+ label?: string;
102
+ createdAt: string;
103
+ }
84
104
  export interface CanvasLayout {
85
105
  viewport: ViewportState;
86
106
  nodes: CanvasNodeState[];
87
107
  edges: CanvasEdge[];
108
+ annotations: CanvasAnnotation[];
88
109
  }
89
110
  export interface CanvasNodeUpdate {
90
111
  id: string;
@@ -101,7 +122,7 @@ export interface CanvasNodeUpdate {
101
122
  }
102
123
  export type CanvasChangeType = 'pins' | 'nodes';
103
124
  export interface MutationRecordInfo {
104
- operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
125
+ operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
105
126
  description: string;
106
127
  forward: () => void;
107
128
  inverse: () => void;
@@ -114,6 +135,7 @@ interface GroupNodesOptions {
114
135
  declare class CanvasStateManager {
115
136
  private nodes;
116
137
  private edges;
138
+ private annotations;
117
139
  private _viewport;
118
140
  private _contextPinnedNodeIds;
119
141
  private _workspaceRoot;
@@ -185,6 +207,7 @@ declare class CanvasStateManager {
185
207
  name: string;
186
208
  nodes: CanvasNodeState[];
187
209
  edges: CanvasEdge[];
210
+ annotations: CanvasAnnotation[];
188
211
  } | null;
189
212
  /** Delete a snapshot. */
190
213
  deleteSnapshot(id: string): boolean;
@@ -200,6 +223,9 @@ declare class CanvasStateManager {
200
223
  removeEdge(id: string): boolean;
201
224
  getEdges(): CanvasEdge[];
202
225
  getEdgesForNode(nodeId: string): CanvasEdge[];
226
+ addAnnotation(annotation: CanvasAnnotation): void;
227
+ removeAnnotation(id: string): boolean;
228
+ getAnnotations(): CanvasAnnotation[];
203
229
  private removeEdgesForNode;
204
230
  getLayout(): CanvasLayout;
205
231
  getLayoutForPersistence(): CanvasLayout;
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import type { CanvasNodeState, CanvasEdge, CanvasLayout } from './canvas-state.js';
2
+ import type { CanvasAnnotation, CanvasNodeState, CanvasEdge, CanvasLayout } from './canvas-state.js';
3
3
  import { searchNodes } from './spatial-analysis.js';
4
4
  import { diffLayouts } from './mutation-history.js';
5
5
  import { fitCanvasView, gcCanvasSnapshots, listCanvasSnapshots } from './canvas-operations.js';
@@ -69,6 +69,11 @@ export declare class PmxCanvas extends EventEmitter {
69
69
  style?: CanvasEdge['style'];
70
70
  animated?: boolean;
71
71
  }): string;
72
+ addAnnotation(input: Omit<CanvasAnnotation, 'id' | 'createdAt'> & {
73
+ id?: string;
74
+ createdAt?: string;
75
+ }): string;
76
+ removeAnnotation(id: string): boolean;
72
77
  removeEdge(id: string): void;
73
78
  /**
74
79
  * Create a group node and optionally add child nodes to it.
@@ -263,7 +268,7 @@ export type { CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from '.
263
268
  export type { CanvasAutomationWebViewOptions, CanvasAutomationWebViewStatus, PrimaryWorkbenchCanvasPromptRequest, PrimaryWorkbenchIntent, } from './server.js';
264
269
  export { emitPrimaryWorkbenchEvent, consumePrimaryWorkbenchIntents, setPrimaryWorkbenchAutoOpenEnabled, setPrimaryWorkbenchCanvasPromptHandler, startCanvasServer, stopCanvasServer, getCanvasServerPort, openUrlInExternalBrowser, getCanvasAutomationWebViewStatus, startCanvasAutomationWebView, stopCanvasAutomationWebView, evaluateCanvasAutomationWebView, resizeCanvasAutomationWebView, screenshotCanvasAutomationWebView, } from './server.js';
265
270
  export { canvasState } from './canvas-state.js';
266
- export type { CanvasSnapshot, CanvasSnapshotGcResult, CanvasSnapshotListOptions } from './canvas-state.js';
271
+ export type { CanvasAnnotation, CanvasSnapshot, CanvasSnapshotGcResult, CanvasSnapshotListOptions } from './canvas-state.js';
267
272
  export { findOpenCanvasPosition } from './placement.js';
268
273
  export { searchNodes, buildSpatialContext, detectClusters, findNeighborhoods } from './spatial-analysis.js';
269
274
  export type { SpatialCluster, SpatialContext, SpatialNeighbor, NodeSpatialInfo } from './spatial-analysis.js';
@@ -12,7 +12,7 @@
12
12
  * - _replaying flag prevents undo/redo from recording new entries
13
13
  */
14
14
  import type { CanvasNodeState, CanvasEdge } from './canvas-state.js';
15
- export type MutationOp = 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'clear' | 'arrange' | 'restoreSnapshot' | 'setPins' | 'batch' | 'viewport' | 'groupNodes' | 'ungroupNodes';
15
+ export type MutationOp = 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'arrange' | 'restoreSnapshot' | 'setPins' | 'batch' | 'viewport' | 'groupNodes' | 'ungroupNodes';
16
16
  export interface MutationEntry {
17
17
  id: string;
18
18
  timestamp: string;
@@ -9,7 +9,7 @@
9
9
  * actually real for agents. Instead of raw x/y coordinates, agents get
10
10
  * semantic clusters, ordered context, and implicit human intent.
11
11
  */
12
- import type { CanvasNodeState, CanvasEdge } from './canvas-state.js';
12
+ import type { CanvasAnnotation, CanvasNodeState, CanvasEdge } from './canvas-state.js';
13
13
  export interface SpatialCluster {
14
14
  /** Auto-generated cluster ID */
15
15
  id: string;
@@ -45,6 +45,14 @@ export interface NodeSpatialInfo {
45
45
  /** Reading order index (top-left to bottom-right) */
46
46
  readingOrder: number;
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
+ }
48
56
  export interface SpatialContext {
49
57
  /** Total nodes on canvas */
50
58
  totalNodes: number;
@@ -58,6 +66,7 @@ export interface SpatialContext {
58
66
  pinnedNodeTitle: string | null;
59
67
  neighbors: SpatialNeighbor[];
60
68
  }[];
69
+ annotations: SpatialAnnotationContext[];
61
70
  }
62
71
  /**
63
72
  * Detect proximity clusters using single-linkage clustering.
@@ -84,4 +93,4 @@ export declare function searchNodes(nodes: CanvasNodeState[], query: string): {
84
93
  /**
85
94
  * Build the complete spatial context for the canvas.
86
95
  */
87
- export declare function buildSpatialContext(nodes: CanvasNodeState[], _edges: CanvasEdge[], pinnedIds: Set<string>): SpatialContext;
96
+ export declare function buildSpatialContext(nodes: CanvasNodeState[], _edges: CanvasEdge[], pinnedIds: Set<string>, annotations?: CanvasAnnotation[]): SpatialContext;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -302,6 +302,10 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
302
302
  - `id` (required): node to remove
303
303
  - Clean up nodes that are no longer relevant
304
304
 
305
+ **`canvas_remove_annotation`** — Remove a human-drawn annotation
306
+ - `id` (required): annotation to remove
307
+ - Use when context gives you the annotation ID; use WebView first if you need to identify a mark by shape or location.
308
+
305
309
  **`canvas_get_node`** — Get a single node's full data
306
310
  - `id` (required): node to retrieve
307
311
 
@@ -564,6 +568,11 @@ tools below operate on the live canvas state.
564
568
  Useful workbench selectors:
565
569
  - Nodes: `.canvas-node`, `.canvas-node.active`, `.canvas-node.context-pinned`, `.canvas-node.group-node`
566
570
  - Node internals: `.node-title`, `.node-titlebar`, `.node-body`, `.node-type-badge`, `.node-controls`
571
+ - Annotations: `.annotation-layer path` renders human-drawn freehand ink. Use WebView
572
+ to inspect or screenshot annotation shapes; MCP/context resources only expose compact
573
+ annotation target summaries, not the raw visual shape. Humans can remove marks with
574
+ the eraser toolbar button; agents can remove a known annotation ID with
575
+ `canvas_remove_annotation`.
567
576
  - Canvas chrome: `.hud-layer`, `.canvas-toolbar`, `.connection-dot`, `.canvas-bootstrap-card`
568
577
  - Nodes do not expose stable `data-node-id` attributes. Use `canvas_get_layout`, `canvas_search`, or MCP resource data for exact node IDs.
569
578
 
@@ -610,6 +619,9 @@ canvas_webview_stop();
610
619
  geometric `graph` node would feel too rigid
611
620
  - Prefer labeled shapes (`"label": { "text": "..." }` on rectangle/ellipse/diamond) over
612
621
  separate text elements — fewer tokens and auto-centered
622
+ - Do not use separate `text` elements with `containerId`/`boundElements` to place centered text
623
+ inside shapes. The hosted SVG preview does not auto-position those; PMX normalizes imported
624
+ canonical bound text back into shape labels for hosted app calls.
613
625
  - Prefer the pastel fill palette in the Excalidraw `read_me` (light blue/green/orange/...) for
614
626
  a consistent look across diagrams
615
627
 
@@ -691,6 +703,11 @@ The `canvas://spatial-context` resource reveals how the human has organized info
691
703
  This implies sequence or priority.
692
704
  - **Pinned neighborhoods** — For each pinned node, nearby unpinned nodes are listed. These
693
705
  are the human's implicit context — things they consider related to what they pinned.
706
+ - **Annotations** — Human-drawn markup is summarized by target/bounds only, e.g. an
707
+ annotation over a node or empty canvas region. Use WebView (`canvas_webview_start` +
708
+ `canvas_evaluate`/`canvas_screenshot`) when you need to see whether the mark is an
709
+ arrow, line, circle, or other drawn shape. Remove known annotations with
710
+ `canvas_remove_annotation`; otherwise use WebView to identify the mark first.
694
711
  - **Board density matters** — On a dense board, spatial context can still read like one large
695
712
  gallery unless groups and spacing separate the major regions clearly.
696
713
 
package/src/cli/agent.ts CHANGED
@@ -1027,6 +1027,7 @@ cmd('node add', 'Add a node to the canvas', [
1027
1027
  'pmx-canvas node add --type status --title "Build" --content "passing"',
1028
1028
  'pmx-canvas node add --type file --content "src/index.ts"',
1029
1029
  'pmx-canvas node add --type webpage --url "https://example.com/docs"',
1030
+ 'pmx-canvas node add --type html --title "Widget" --content "<main>Hello</main>"',
1030
1031
  'pmx-canvas node add --type markdown --title "Note" --x 100 --y 200',
1031
1032
  'pmx-canvas node add --type json-render --title "Ops Dashboard" --spec-file ./dashboard.json',
1032
1033
  'pmx-canvas node add --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value',
@@ -1069,6 +1070,9 @@ cmd('node add', 'Add a node to the canvas', [
1069
1070
  body.url = webpageUrl;
1070
1071
  } else if (type === 'image' && imagePath && !flags.content) {
1071
1072
  body.content = imagePath;
1073
+ } else if (type === 'html') {
1074
+ const html = getStringFlag(flags, 'html') ?? getStringFlag(flags, 'content');
1075
+ if (html !== undefined) body.html = html;
1072
1076
  } else if (flags.content) {
1073
1077
  body.content = flags.content;
1074
1078
  }
@@ -1090,6 +1094,8 @@ cmd('node add', 'Add a node to the canvas', [
1090
1094
  if (flags.stdin) {
1091
1095
  if (type === 'webpage') {
1092
1096
  body.url = await readStdin();
1097
+ } else if (type === 'html') {
1098
+ body.html = await readStdin();
1093
1099
  } else {
1094
1100
  body.content = await readStdin();
1095
1101
  }
@@ -41,10 +41,12 @@ import { connectSSE } from './state/sse-bridge';
41
41
  import {
42
42
  IconArrange,
43
43
  IconClearTrace,
44
+ IconEraser,
44
45
  IconFitAll,
45
46
  IconLogo,
46
47
  IconMinimap,
47
48
  IconMoon,
49
+ IconPen,
48
50
  IconResetView,
49
51
  IconSearch,
50
52
  IconShortcuts,
@@ -71,6 +73,8 @@ function sendIntent(type: string, payload: Record<string, unknown> = {}): void {
71
73
  });
72
74
  }
73
75
 
76
+ type AnnotationTool = 'pen' | 'eraser' | null;
77
+
74
78
  function ToolbarHint({
75
79
  label,
76
80
  detail,
@@ -108,6 +112,9 @@ function Toolbar({
108
112
  snapshotBtnRef,
109
113
  onOpenPalette,
110
114
  onOpenShortcuts,
115
+ annotationTool,
116
+ onToggleAnnotationMode,
117
+ onToggleAnnotationEraser,
111
118
  }: {
112
119
  minimapVisible: boolean;
113
120
  onToggleMinimap: () => void;
@@ -116,6 +123,9 @@ function Toolbar({
116
123
  snapshotBtnRef: { current: HTMLButtonElement | null };
117
124
  onOpenPalette: () => void;
118
125
  onOpenShortcuts: () => void;
126
+ annotationTool: AnnotationTool;
127
+ onToggleAnnotationMode: () => void;
128
+ onToggleAnnotationEraser: () => void;
119
129
  }) {
120
130
  const status = connectionStatus.value;
121
131
  const hasSynced = hasInitialServerLayout.value;
@@ -278,6 +288,37 @@ function Toolbar({
278
288
 
279
289
  <div class="separator" />
280
290
 
291
+ <ToolbarHint
292
+ label={annotationTool === 'pen' ? 'Stop annotating' : 'Annotate canvas'}
293
+ detail="Draw directly on the canvas for human-visible markup"
294
+ >
295
+ <button
296
+ type="button"
297
+ onClick={onToggleAnnotationMode}
298
+ aria-label={annotationTool === 'pen' ? 'Stop annotating' : 'Annotate canvas'}
299
+ aria-pressed={annotationTool === 'pen'}
300
+ style={{ color: annotationTool === 'pen' ? 'var(--c-accent)' : undefined }}
301
+ >
302
+ <IconPen />
303
+ </button>
304
+ </ToolbarHint>
305
+ <ToolbarHint
306
+ label={annotationTool === 'eraser' ? 'Stop erasing' : 'Erase annotations'}
307
+ detail="Click a drawn annotation to remove it"
308
+ >
309
+ <button
310
+ type="button"
311
+ onClick={onToggleAnnotationEraser}
312
+ aria-label={annotationTool === 'eraser' ? 'Stop erasing' : 'Erase annotations'}
313
+ aria-pressed={annotationTool === 'eraser'}
314
+ style={{ color: annotationTool === 'eraser' ? 'var(--c-accent)' : undefined }}
315
+ >
316
+ <IconEraser />
317
+ </button>
318
+ </ToolbarHint>
319
+
320
+ <div class="separator" />
321
+
281
322
  <ToolbarHint label="Search nodes and actions" shortcut={`${MOD_KEY}+K`}>
282
323
  <button
283
324
  type="button"
@@ -341,6 +382,7 @@ export function App() {
341
382
  const [snapshotOpen, setSnapshotOpen] = useState(false);
342
383
  const [paletteOpen, setPaletteOpen] = useState(false);
343
384
  const [shortcutsOpen, setShortcutsOpen] = useState(false);
385
+ const [annotationTool, setAnnotationTool] = useState<AnnotationTool>(null);
344
386
  const snapshotBtnRef = useRef<HTMLButtonElement>(null);
345
387
  const { menu, openNodeMenu, openCanvasMenu, closeMenu } = useContextMenu();
346
388
  const hasInitialLayout = hasInitialServerLayout.value;
@@ -348,14 +390,18 @@ export function App() {
348
390
  const handleToggleMinimap = useCallback(() => setMinimapVisible((v) => !v), []);
349
391
  const handleToggleSnapshot = useCallback(() => setSnapshotOpen((v) => !v), []);
350
392
  const handleCloseSnapshot = useCallback(() => setSnapshotOpen(false), []);
393
+ const handleToggleAnnotationMode = useCallback(() => setAnnotationTool((tool) => tool === 'pen' ? null : 'pen'), []);
394
+ const handleToggleAnnotationEraser = useCallback(() => setAnnotationTool((tool) => tool === 'eraser' ? null : 'eraser'), []);
351
395
 
352
396
  const handleMinimapNavigate = useCallback((x: number, y: number) => {
353
397
  animateViewport({ x, y, scale: viewport.value.scale }, 200);
354
398
  }, []);
355
399
 
356
400
  useEffect(() => {
357
- const disconnect = connectSSE();
401
+ return connectSSE();
402
+ }, []);
358
403
 
404
+ useEffect(() => {
359
405
  const handleKeyDown = (e: KeyboardEvent) => {
360
406
  const mod = e.metaKey || e.ctrlKey;
361
407
 
@@ -366,6 +412,13 @@ export function App() {
366
412
  return;
367
413
  }
368
414
 
415
+ // Esc exits annotation tools before handling overlays or selection.
416
+ if (e.key === 'Escape' && annotationTool) {
417
+ e.preventDefault();
418
+ setAnnotationTool(null);
419
+ return;
420
+ }
421
+
369
422
  // Esc always collapses expanded node first (even from inside inputs)
370
423
  if (e.key === 'Escape' && expandedNodeId.value && !pendingExpandedNodeCloseId.value) {
371
424
  e.preventDefault();
@@ -428,10 +481,9 @@ export function App() {
428
481
 
429
482
  document.addEventListener('keydown', handleKeyDown);
430
483
  return () => {
431
- disconnect();
432
484
  document.removeEventListener('keydown', handleKeyDown);
433
485
  };
434
- }, [closeMenu, paletteOpen, shortcutsOpen]);
486
+ }, [annotationTool, closeMenu, paletteOpen, shortcutsOpen]);
435
487
 
436
488
  useEffect(() => {
437
489
  if (!hasInitialLayout) return;
@@ -465,6 +517,9 @@ export function App() {
465
517
  snapshotBtnRef={snapshotBtnRef}
466
518
  onOpenPalette={() => setPaletteOpen(true)}
467
519
  onOpenShortcuts={() => setShortcutsOpen((v) => !v)}
520
+ annotationTool={annotationTool}
521
+ onToggleAnnotationMode={handleToggleAnnotationMode}
522
+ onToggleAnnotationEraser={handleToggleAnnotationEraser}
468
523
  />
469
524
  <div class="hud-right">
470
525
  {dockedRight.map((n) => (
@@ -477,6 +532,8 @@ export function App() {
477
532
  <CanvasViewport
478
533
  onNodeContextMenu={openNodeMenu}
479
534
  onCanvasContextMenu={openCanvasMenu}
535
+ annotationMode={annotationTool !== null}
536
+ annotationTool={annotationTool}
480
537
  />
481
538
  {hasInitialLayout && allNodes.filter((n) => !n.dockPosition).length === 0 && (
482
539
  <WelcomeCard onOpenPalette={() => setPaletteOpen(true)} />
@@ -0,0 +1,28 @@
1
+ import type { CanvasAnnotation, CanvasAnnotationPoint } from '../types';
2
+
3
+ function pointsToPath(points: CanvasAnnotationPoint[]): string {
4
+ const [first, ...rest] = points;
5
+ if (!first) return '';
6
+ return rest.reduce((path, point) => `${path} L ${point.x} ${point.y}`, `M ${first.x} ${first.y}`);
7
+ }
8
+
9
+ export function AnnotationLayer({ annotations }: { annotations: CanvasAnnotation[] }) {
10
+ if (annotations.length === 0) return null;
11
+
12
+ return (
13
+ <svg class="annotation-layer" aria-hidden="true">
14
+ {annotations.map((annotation) => (
15
+ <path
16
+ key={annotation.id}
17
+ d={pointsToPath(annotation.points)}
18
+ fill="none"
19
+ stroke={annotation.color === 'currentColor' ? 'var(--c-annotation)' : annotation.color}
20
+ stroke-width={annotation.width}
21
+ stroke-linecap="round"
22
+ stroke-linejoin="round"
23
+ opacity="0.9"
24
+ />
25
+ ))}
26
+ </svg>
27
+ );
28
+ }