pmx-canvas 0.1.8 → 0.1.9

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.
@@ -0,0 +1,5 @@
1
+ import type { CanvasNodeState } from '../types';
2
+ export declare const AUTO_FIT_MAX_HEIGHT = 600;
3
+ export declare const AUTO_FIT_TITLEBAR_HEIGHT = 37;
4
+ export declare function shouldAutoFitNode(node: CanvasNodeState): boolean;
5
+ export declare function computeAutoFitHeight(node: CanvasNodeState, contentHeight: number): number | null;
@@ -5,6 +5,8 @@ type IframeLoadTarget = Pick<HTMLIFrameElement, 'addEventListener' | 'removeEven
5
5
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
6
6
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
7
7
  interface ExtAppHostDimensionsTarget {
8
+ clientWidth?: number;
9
+ clientHeight?: number;
8
10
  getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
9
11
  }
10
12
  export declare function waitForExtAppFrameLoad(target: IframeLoadTarget): Promise<void>;
@@ -48,6 +48,7 @@ export declare function inferJsonRenderNodeTitle(spec: JsonRenderSpec, fallback?
48
48
  export declare function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec;
49
49
  export declare function normalizeGraphType(value: string): GraphChartType;
50
50
  export declare function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec;
51
+ export declare function buildGraphConfig(input: GraphNodeInput): Record<string, unknown>;
51
52
  export declare function createJsonRenderNodeData(nodeId: string, title: string, spec: JsonRenderSpec, extra?: Record<string, unknown>): Record<string, unknown>;
52
53
  export declare function buildJsonRenderViewerHtml(options: {
53
54
  title: string;
@@ -2,6 +2,38 @@ import { type CanvasEdge, type CanvasNodeState, type CanvasNodeUpdate, type Canv
2
2
  import { type GraphNodeInput, type JsonRenderNodeInput, type JsonRenderSpec } from '../json-render/server.js';
3
3
  export type CanvasArrangeMode = 'grid' | 'column' | 'flow';
4
4
  export type CanvasPinMode = 'set' | 'add' | 'remove';
5
+ export interface CanvasFitViewOptions {
6
+ width?: number;
7
+ height?: number;
8
+ padding?: number;
9
+ maxScale?: number;
10
+ nodeIds?: string[];
11
+ }
12
+ export interface CanvasFitViewResult {
13
+ ok: true;
14
+ viewport: {
15
+ x: number;
16
+ y: number;
17
+ scale: number;
18
+ };
19
+ nodeCount: number;
20
+ bounds: {
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ } | null;
26
+ }
27
+ export interface CanvasGraphNodeUpdateInput extends Partial<GraphNodeInput> {
28
+ spec?: unknown;
29
+ type?: string;
30
+ }
31
+ export interface CanvasStructuredNodeUpdateInput extends Omit<CanvasGraphNodeUpdateInput, 'data'> {
32
+ content?: unknown;
33
+ data?: unknown;
34
+ arrangeLocked?: unknown;
35
+ chartHeight?: unknown;
36
+ }
5
37
  interface CanvasAddNodeInput {
6
38
  type: CanvasNodeState['type'];
7
39
  title?: string;
@@ -34,6 +66,22 @@ interface CanvasNodeLookupInput {
34
66
  id?: string;
35
67
  search?: string;
36
68
  }
69
+ export declare function hasStructuredNodeUpdateFields(input: Record<string, unknown>): boolean;
70
+ export declare function buildStructuredNodeUpdate(node: CanvasNodeState, input: CanvasStructuredNodeUpdateInput): {
71
+ data: Record<string, unknown>;
72
+ };
73
+ export declare function buildJsonRenderNodeUpdate(node: CanvasNodeState, input: {
74
+ title?: string;
75
+ spec: unknown;
76
+ }): {
77
+ data: Record<string, unknown>;
78
+ spec: JsonRenderSpec;
79
+ };
80
+ export declare function buildGraphNodeUpdate(node: CanvasNodeState, input: CanvasGraphNodeUpdateInput): {
81
+ data: Record<string, unknown>;
82
+ spec: JsonRenderSpec;
83
+ graphConfig: Record<string, unknown>;
84
+ };
37
85
  export declare function primeCanvasRuntimeBackends(options?: {
38
86
  forceRehydrateExtApps?: boolean;
39
87
  }): {
@@ -144,6 +192,7 @@ export declare function createCanvasGraphNode(input: GraphNodeInput): {
144
192
  spec: JsonRenderSpec;
145
193
  node: CanvasNodeState;
146
194
  };
195
+ export declare function fitCanvasView(options?: CanvasFitViewOptions): CanvasFitViewResult;
147
196
  export declare function executeCanvasBatch(operations: CanvasBatchOperation[]): Promise<{
148
197
  ok: boolean;
149
198
  results: Array<Record<string, unknown>>;
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events';
2
2
  import type { CanvasNodeState, CanvasEdge, CanvasLayout } from './canvas-state.js';
3
3
  import { searchNodes } from './spatial-analysis.js';
4
4
  import { diffLayouts } from './mutation-history.js';
5
+ import { fitCanvasView } from './canvas-operations.js';
5
6
  import { type WebArtifactBuildInput, type WebArtifactCanvasBuildResult } from './web-artifacts.js';
6
7
  import { type ExternalMcpTransportConfig } from './mcp-app-runtime.js';
7
8
  import { type DiagramPresetOpenInput } from './diagram-presets.js';
@@ -48,7 +49,7 @@ export declare class PmxCanvas extends EventEmitter {
48
49
  id: string;
49
50
  error?: string;
50
51
  }>;
51
- updateNode(id: string, patch: Partial<CanvasNodeState>): void;
52
+ updateNode(id: string, patch: Partial<CanvasNodeState> & Record<string, unknown>): void;
52
53
  removeNode(id: string): void;
53
54
  addEdge(input: {
54
55
  from?: string;
@@ -89,6 +90,13 @@ export declare class PmxCanvas extends EventEmitter {
89
90
  focused: string;
90
91
  panned: boolean;
91
92
  } | null;
93
+ fitView(options?: {
94
+ width?: number;
95
+ height?: number;
96
+ padding?: number;
97
+ maxScale?: number;
98
+ nodeIds?: string[];
99
+ }): ReturnType<typeof fitCanvasView>;
92
100
  getLayout(): CanvasLayout;
93
101
  getNode(id: string): CanvasNodeState | undefined;
94
102
  search(query: string): ReturnType<typeof searchNodes>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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",
@@ -143,6 +143,8 @@ pmx-canvas search "auth"
143
143
  pmx-canvas open
144
144
  pmx-canvas arrange --layout flow
145
145
  pmx-canvas focus <node-id> --no-pan # Select/raise without moving the user's viewport
146
+ pmx-canvas fit --width 1440 --height 900 # Fit the whole board for screenshots/review
147
+ pmx-canvas node update <node-id> --spec-file ./dashboard.json
146
148
  pmx-canvas validate spec --type json-render --spec-file ./dashboard.json --summary
147
149
  pmx-canvas web-artifact build --title "Dashboard" --app-file ./App.tsx --deps recharts --include-logs
148
150
  pmx-canvas node list --type web-artifact --summary
@@ -167,6 +169,7 @@ pmx-canvas spatial
167
169
  `"DVT O3"` can be ambiguous; prefer the full visible title such as `"DVT O3 — GitOps"`.
168
170
  - `search`, `layout`, `status`, `arrange`, `focus` — inspect and navigate the canvas. Prefer
169
171
  `focus --no-pan` when you only need to select/raise a node without hijacking the human's camera.
172
+ - `fit [id ...]` — set the server viewport to fit the whole canvas or selected nodes before screenshots or whole-board review
170
173
  - `open` — open the current workbench in the browser
171
174
  - `pin --list|--clear|<ids...>` — manage context pins
172
175
  - `undo`, `redo`, `history` — time travel
@@ -279,7 +282,9 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
279
282
 
280
283
  **`canvas_update_node`** — Update an existing node
281
284
  - `id` (required): node to update
282
- - Any of: `title`, `content`, `color`, `x`, `y`, `width`, `height`, `collapsed`, `metadata`
285
+ - Any of: `title`, `content`, `x`, `y`, `width`, `height`, `collapsed`, `arrangeLocked`, `data`
286
+ - For `json-render`, pass `spec` to update the rendered spec in place while preserving node ID, edges, pins, and position
287
+ - For `graph`, pass graph fields such as `graphType`, `data`, `xKey`, `yKey`, `color`, and `chartHeight` to rebuild the chart in place; use `height`/`nodeHeight` for frame geometry and `chartHeight` for chart content in CLI flows
283
288
  - Use to update status nodes as work progresses
284
289
 
285
290
  **`canvas_remove_node`** — Remove a node and all its connected edges
@@ -357,6 +362,10 @@ ID extraction for mixed tool responses:
357
362
  - Returns the normalized json-render spec the server would accept
358
363
  - Use this when you want a dry run before creating a `json-render` or `graph` node
359
364
 
365
+ **`canvas_fit_view`** — Fit viewport to all nodes or selected nodes
366
+ - Optional: `width`, `height`, `padding`, `maxScale`, `nodeIds`
367
+ - Use before screenshot workflows or whole-board review so the server viewport matches the intended camera
368
+
360
369
  **Batch graph creation**
361
370
  - Use `graph.add` inside `canvas_batch` / `pmx-canvas batch` when you need a graph node as part of
362
371
  a larger one-shot build.
@@ -512,6 +521,8 @@ tools below operate on the live canvas state.
512
521
  - Required: exactly one of `expression` (single JS expression) or `script` (multi-statement body)
513
522
  - `script` is wrapped in an async IIFE, so top-level `await` works inside script bodies
514
523
  - Useful for asserting DOM state after a sequence of canvas mutations
524
+ - Do not use `fetch()` inside `canvas_evaluate` to call PMX HTTP APIs; WebView security/CORS
525
+ restrictions can block those requests. Use the matching MCP tools instead.
515
526
  - Example: read the count of rendered `.canvas-node` elements:
516
527
 
517
528
  ```typescript
@@ -666,6 +677,7 @@ All POST/PATCH endpoints accept `Content-Type: application/json`. Default base U
666
677
  | POST | `/api/canvas/group/ungroup` | Ungroup |
667
678
  | POST | `/api/canvas/arrange` | Auto-arrange |
668
679
  | POST | `/api/canvas/focus` | Center viewport on node |
680
+ | POST | `/api/canvas/fit` | Fit viewport to canvas bounds or selected nodes |
669
681
  | POST | `/api/canvas/clear` | Clear canvas |
670
682
  | POST | `/api/canvas/update` | Batch update positions |
671
683
  | GET | `/api/canvas/spatial-context` | Spatial clusters and reading order |
@@ -144,7 +144,7 @@ for _ in $(seq 1 120); do
144
144
 
145
145
  NODE_COUNT="$(
146
146
  curl -fsS "http://127.0.0.1:${PORT}/api/canvas/state" \
147
- | "${BUN_BIN}" -e 'let raw="";for await (const chunk of Bun.stdin.stream()){raw+=chunk instanceof Uint8Array ? Buffer.from(chunk).toString() : String(chunk);} const state=JSON.parse(raw); console.log(state.nodes.length);'
147
+ | python3 -c 'import json, sys; print(len(json.load(sys.stdin).get("nodes", [])))'
148
148
  )"
149
149
 
150
150
  if [[ "${NODE_COUNT}" -ge 18 ]]; then
@@ -109,6 +109,17 @@ function ensure_bundle_dependencies() {
109
109
  fi
110
110
  done
111
111
 
112
+ # package.json lists the deps, but node_modules may not be in sync (e.g. fresh
113
+ # CI checkout where node_modules is gitignored). Run a regular install so the
114
+ # local binaries actually exist before we hand off to Parcel. The build
115
+ # allowlist is already persisted in package.json's pnpm.onlyBuiltDependencies
116
+ # by the original `pnpm add` step, so plain `install` is enough here.
117
+ if [ ! -x "./node_modules/.bin/parcel" ]; then
118
+ echo "📦 Syncing bundling dependencies into node_modules..."
119
+ run_pnpm_quiet install
120
+ return 0
121
+ fi
122
+
112
123
  echo "✅ Reusing existing bundling dependencies"
113
124
  }
114
125
 
package/src/cli/agent.ts CHANGED
@@ -503,6 +503,41 @@ async function readTextInput(
503
503
  die(options.requiredMessage, options.hint);
504
504
  }
505
505
 
506
+ async function readOptionalTextInput(
507
+ flags: Record<string, string | true>,
508
+ options: {
509
+ fileFlags?: string[];
510
+ valueFlags?: string[];
511
+ allowStdin?: boolean;
512
+ label: string;
513
+ hint: string;
514
+ },
515
+ ): Promise<string | undefined> {
516
+ for (const name of options.fileFlags ?? []) {
517
+ const path = getStringFlag(flags, name);
518
+ if (!path) continue;
519
+ try {
520
+ return readFileSync(path, 'utf-8');
521
+ } catch (error) {
522
+ die(
523
+ `Unable to read --${name}: ${error instanceof Error ? error.message : String(error)}`,
524
+ options.hint,
525
+ );
526
+ }
527
+ }
528
+
529
+ for (const name of options.valueFlags ?? []) {
530
+ const value = getStringFlag(flags, name);
531
+ if (value !== undefined) return value;
532
+ }
533
+
534
+ if (options.allowStdin && flags.stdin) {
535
+ return await readStdin();
536
+ }
537
+
538
+ return undefined;
539
+ }
540
+
506
541
  function applyCommonGeometryFlags(
507
542
  body: Record<string, unknown>,
508
543
  flags: Record<string, string | true>,
@@ -518,6 +553,27 @@ function applyCommonGeometryFlags(
518
553
  if (height !== undefined) body.height = height;
519
554
  }
520
555
 
556
+ async function applyStructuredNodeUpdateFlags(
557
+ body: Record<string, unknown>,
558
+ flags: Record<string, string | true>,
559
+ ): Promise<void> {
560
+ const specRaw = await readOptionalTextInput(flags, {
561
+ fileFlags: ['spec-file'],
562
+ valueFlags: ['spec-json'],
563
+ allowStdin: false,
564
+ label: 'JSON spec',
565
+ hint: 'Use: pmx-canvas node update <node-id> --spec-file ./new-spec.json',
566
+ });
567
+ if (specRaw !== undefined) {
568
+ body.spec = parseJsonValue(specRaw, 'JSON spec', 'Use: pmx-canvas node update <node-id> --spec-file ./new-spec.json');
569
+ }
570
+
571
+ const graphPatch = await buildGraphRequestBody(flags, { requireData: false, allowStdin: false });
572
+ for (const [key, value] of Object.entries(graphPatch)) {
573
+ body[key === 'height' ? 'chartHeight' : key] = value;
574
+ }
575
+ }
576
+
521
577
  async function buildJsonRenderRequestBody(
522
578
  flags: Record<string, string | true>,
523
579
  ): Promise<Record<string, unknown>> {
@@ -547,23 +603,30 @@ async function buildJsonRenderRequestBody(
547
603
 
548
604
  async function buildGraphRequestBody(
549
605
  flags: Record<string, string | true>,
606
+ options: { requireData?: boolean; allowStdin?: boolean } = {},
550
607
  ): Promise<Record<string, unknown>> {
608
+ const requireData = options.requireData !== false;
609
+ const allowStdin = options.allowStdin !== false;
551
610
  const hint =
552
611
  'Use: pmx-canvas node add --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value';
553
- const rawData = await readTextInput(flags, {
612
+
613
+ const body: Record<string, unknown> = {
614
+ ...(requireData ? { graphType: getStringFlag(flags, 'graph-type', 'graphType') ?? 'line' } : {}),
615
+ };
616
+ const rawData = await readOptionalTextInput(flags, {
554
617
  fileFlags: ['data-file'],
555
618
  valueFlags: ['data-json', 'data'],
556
- allowStdin: true,
619
+ allowStdin,
557
620
  label: 'graph JSON dataset',
558
621
  hint,
559
- requiredMessage: 'Graph nodes require --data-file, --data-json, --data, or --stdin JSON data.',
560
622
  });
561
- const data = parseRecordArrayJson(rawData, hint);
562
-
563
- const body: Record<string, unknown> = {
564
- graphType: getStringFlag(flags, 'graph-type', 'graphType') ?? 'line',
565
- data,
566
- };
623
+ if (rawData !== undefined) {
624
+ body.data = parseRecordArrayJson(rawData, hint);
625
+ } else if (requireData) {
626
+ die('Graph nodes require --data-file, --data-json, --data, or --stdin JSON data.', hint);
627
+ }
628
+ const graphType = getStringFlag(flags, 'graph-type', 'graphType');
629
+ if (graphType) body.graphType = graphType;
567
630
  if (typeof flags.title === 'string') body.title = flags.title;
568
631
  const xKey = getStringFlag(flags, 'x-key', 'xKey');
569
632
  const yKey = getStringFlag(flags, 'y-key', 'yKey');
@@ -1151,6 +1214,8 @@ cmd('node update', 'Update a node by ID', [
1151
1214
  'pmx-canvas node update <node-id> --content "Updated content"',
1152
1215
  'pmx-canvas node update <node-id> --title "Moved" --x 500 --y 300',
1153
1216
  'pmx-canvas node update <node-id> --width 840 --height 620',
1217
+ 'pmx-canvas node update <node-id> --spec-file ./dashboard.json',
1218
+ 'pmx-canvas node update <graph-id> --data-file ./metrics.json --chart-height 420',
1154
1219
  'pmx-canvas node update <node-id> --lock-arrange',
1155
1220
  ], async (args) => {
1156
1221
  const { positional, flags } = parseFlags(args);
@@ -1160,6 +1225,7 @@ cmd('node update', 'Update a node by ID', [
1160
1225
  if (!id) die('Missing node ID', 'pmx-canvas node update <node-id> --title "New Title"');
1161
1226
 
1162
1227
  const body: Record<string, unknown> = {};
1228
+ await applyStructuredNodeUpdateFlags(body, flags);
1163
1229
  if (flags.title && flags.title !== true) body.title = flags.title;
1164
1230
  if (flags.content && flags.content !== true) body.content = flags.content;
1165
1231
  if (flags.stdin) body.content = await readStdin();
@@ -1199,10 +1265,7 @@ cmd('node update', 'Update a node by ID', [
1199
1265
  }
1200
1266
 
1201
1267
  if (arrangeLocked !== undefined) {
1202
- body.data = {
1203
- ...existing.data,
1204
- arrangeLocked,
1205
- };
1268
+ body.arrangeLocked = arrangeLocked;
1206
1269
  }
1207
1270
  }
1208
1271
 
@@ -1396,6 +1459,29 @@ cmd('focus', 'Pan viewport to center on a node', [
1396
1459
  output(result);
1397
1460
  });
1398
1461
 
1462
+ cmd('fit', 'Fit the viewport to all nodes or a selected subset', [
1463
+ 'pmx-canvas fit',
1464
+ 'pmx-canvas fit --width 1440 --height 900 --padding 80',
1465
+ 'pmx-canvas fit node-a node-b',
1466
+ ], async (args) => {
1467
+ const { positional, flags } = parseFlags(args);
1468
+ if (flags.help || flags.h) return showCommandHelp('fit');
1469
+
1470
+ const body: Record<string, unknown> = {};
1471
+ const width = optionalPositiveFiniteFlag(flags, 'width', 'Use a positive number, e.g. --width 1440');
1472
+ const height = optionalPositiveFiniteFlag(flags, 'height', 'Use a positive number, e.g. --height 900');
1473
+ const padding = optionalPositiveFiniteFlag(flags, 'padding', 'Use a positive number, e.g. --padding 80');
1474
+ const maxScale = optionalPositiveFiniteFlag(flags, 'max-scale', 'Use a positive number, e.g. --max-scale 1');
1475
+ if (width !== undefined) body.width = width;
1476
+ if (height !== undefined) body.height = height;
1477
+ if (padding !== undefined) body.padding = padding;
1478
+ if (maxScale !== undefined) body.maxScale = maxScale;
1479
+ if (positional.length > 0) body.nodeIds = positional;
1480
+
1481
+ const result = await api('POST', '/api/canvas/fit', body);
1482
+ output(result);
1483
+ });
1484
+
1399
1485
  // ── external-app add ─────────────────────────────────────────
1400
1486
  cmd('external-app add', 'Create a hosted external app node', [
1401
1487
  'pmx-canvas external-app add --kind excalidraw --title "Diagram"',
@@ -1816,7 +1902,7 @@ cmd('webview start', 'Start or replace the Bun.WebView automation session', [
1816
1902
  if (chromeArgv.length > 0) body.chromeArgv = chromeArgv;
1817
1903
  }
1818
1904
 
1819
- const result = await api('POST', '/api/workbench/webview/start', body);
1905
+ const result = await api('POST', '/api/workbench/webview/start', body, { allowErrorJson: true });
1820
1906
  output(result);
1821
1907
  });
1822
1908
 
@@ -2112,6 +2198,13 @@ function showCommandHelp(name: string): void {
2112
2198
  console.log('\nViewport:');
2113
2199
  console.log(' --no-pan Select/raise the node without moving the viewport');
2114
2200
  }
2201
+ if (name === 'fit') {
2202
+ console.log('\nViewport:');
2203
+ console.log(' --width <px> Viewport width used for fit math (default 1440)');
2204
+ console.log(' --height <px> Viewport height used for fit math (default 900)');
2205
+ console.log(' --padding <px> World-space padding around fitted nodes (default 60)');
2206
+ console.log(' --max-scale <scale> Maximum zoom scale (default 1)');
2207
+ }
2115
2208
  if (name === 'external-app add') {
2116
2209
  console.log('\nOptions:');
2117
2210
  console.log(' --kind excalidraw External app kind to create');
@@ -2161,6 +2254,7 @@ Canvas commands:
2161
2254
  pmx-canvas validate spec Validate json-render/graph payloads without creating nodes
2162
2255
  pmx-canvas watch [options] Watch semantic canvas changes over SSE
2163
2256
  pmx-canvas focus <id> Pan viewport to node
2257
+ pmx-canvas fit [id ...] Fit viewport to canvas or selected nodes
2164
2258
  pmx-canvas external-app add Add hosted external apps like Excalidraw
2165
2259
  pmx-canvas webview status Show WebView automation status
2166
2260
  pmx-canvas webview start [options] Start or replace automation session
@@ -2222,6 +2316,7 @@ Examples:
2222
2316
  pmx-canvas edge add --from-search "DVT O3 — GitOps" --to-search "deep work trend" --type relation
2223
2317
  pmx-canvas search "authentication"
2224
2318
  pmx-canvas open
2319
+ pmx-canvas fit --width 1440 --height 900
2225
2320
  pmx-canvas layout --summary
2226
2321
  pmx-canvas arrange --layout column
2227
2322
  pmx-canvas batch --file ./canvas-ops.json
@@ -28,6 +28,7 @@ import { removeNodeFromClient, updateNodeFromClient } from '../state/intent-brid
28
28
  import { getNodeIcon } from '../icons';
29
29
  import { EXPANDABLE_TYPES, TYPE_LABELS } from '../types';
30
30
  import type { CanvasNodeState } from '../types';
31
+ import { computeAutoFitHeight, shouldAutoFitNode } from './auto-fit';
31
32
  import { activeGuides, buildSnapCache, clearSnapCache, snapToGuides } from './snap-guides';
32
33
  import { useNodeDrag } from './use-node-drag';
33
34
  import { useNodeResize } from './use-node-resize';
@@ -141,12 +142,9 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
141
142
  const bodyRef = useRef<HTMLDivElement>(null);
142
143
  const hasAutoFit = useRef(false);
143
144
  const autoFitPersistTimer = useRef<number | null>(null);
144
- const AUTO_FIT_MAX = 600;
145
- const TITLEBAR_HEIGHT = 37;
146
- const isExtAppNode = node.type === 'mcp-app' && node.data.mode === 'ext-app';
147
145
 
148
146
  useEffect(() => {
149
- if (hasAutoFit.current || node.collapsed || node.dockPosition || node.type === 'group' || isExtAppNode) return;
147
+ if (hasAutoFit.current || !shouldAutoFitNode(node)) return;
150
148
  const body = bodyRef.current;
151
149
  if (!body) return;
152
150
 
@@ -156,9 +154,9 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
156
154
  return;
157
155
  }
158
156
  const contentHeight = body.scrollHeight;
159
- if (contentHeight <= 0) return;
157
+ const fitHeight = computeAutoFitHeight(node, contentHeight);
158
+ if (fitHeight === null) return;
160
159
 
161
- const fitHeight = Math.min(contentHeight + TITLEBAR_HEIGHT, AUTO_FIT_MAX);
162
160
  // Only resize if the fit height differs meaningfully from current
163
161
  if (Math.abs(fitHeight - node.size.height) > 8) {
164
162
  resizeNode(node.id, { width: node.size.width, height: fitHeight });
@@ -181,7 +179,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
181
179
  autoFitPersistTimer.current = null;
182
180
  }
183
181
  };
184
- }, [node.id, node.type, isExtAppNode, node.collapsed, node.dockPosition, node.size.width, node.size.height]);
182
+ }, [node.id, node.type, node.data.mode, node.collapsed, node.dockPosition, node.size.width, node.size.height]);
185
183
 
186
184
  const isPinned = node.pinned;
187
185
  const isTrace = node.type === 'trace';
@@ -0,0 +1,21 @@
1
+ import type { CanvasNodeState } from '../types';
2
+
3
+ export const AUTO_FIT_MAX_HEIGHT = 600;
4
+ export const AUTO_FIT_TITLEBAR_HEIGHT = 37;
5
+
6
+ function isExtAppNode(node: CanvasNodeState): boolean {
7
+ return node.type === 'mcp-app' && node.data.mode === 'ext-app';
8
+ }
9
+
10
+ function hasExplicitStructuredFrame(node: CanvasNodeState): boolean {
11
+ return (node.type === 'graph' || node.type === 'json-render') && node.size.height > AUTO_FIT_MAX_HEIGHT;
12
+ }
13
+
14
+ export function shouldAutoFitNode(node: CanvasNodeState): boolean {
15
+ return !node.collapsed && !node.dockPosition && node.type !== 'group' && !isExtAppNode(node) && !hasExplicitStructuredFrame(node);
16
+ }
17
+
18
+ export function computeAutoFitHeight(node: CanvasNodeState, contentHeight: number): number | null {
19
+ if (!shouldAutoFitNode(node) || contentHeight <= 0) return null;
20
+ return Math.min(contentHeight + AUTO_FIT_TITLEBAR_HEIGHT, AUTO_FIT_MAX_HEIGHT);
21
+ }
@@ -23,6 +23,8 @@ type DisplayMode = 'inline' | 'fullscreen' | 'pip';
23
23
  const DEFAULT_EXT_APP_SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
24
24
 
25
25
  interface ExtAppHostDimensionsTarget {
26
+ clientWidth?: number;
27
+ clientHeight?: number;
26
28
  getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
27
29
  }
28
30
 
@@ -120,8 +122,8 @@ export function resolveExtAppContainerDimensions(
120
122
  ): { width: number; height: number } {
121
123
  const rect = target?.getBoundingClientRect();
122
124
  return {
123
- width: positiveDimension(rect?.width ?? 0, fallback.width),
124
- height: positiveDimension(rect?.height ?? 0, fallback.height),
125
+ width: positiveDimension(target?.clientWidth ?? 0, positiveDimension(rect?.width ?? 0, fallback.width)),
126
+ height: positiveDimension(target?.clientHeight ?? 0, positiveDimension(rect?.height ?? 0, fallback.height)),
125
127
  };
126
128
  }
127
129
 
@@ -430,6 +430,7 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
430
430
  const elementChanged =
431
431
  resolvedType !== element.type ||
432
432
  JSON.stringify(normalizedProps) !== JSON.stringify(rawProps) ||
433
+ !('visible' in element) ||
433
434
  !Array.isArray(element.children) ||
434
435
  normalizedChildren.length !== element.children.length;
435
436
 
@@ -438,6 +439,7 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
438
439
  ...element,
439
440
  type: resolvedType,
440
441
  props: normalizedProps,
442
+ visible: 'visible' in element ? element.visible : true,
441
443
  children: normalizedChildren,
442
444
  }
443
445
  : rawElement;
@@ -460,6 +462,7 @@ function normalizeJsonRenderInput(spec: unknown): unknown {
460
462
  elements: {
461
463
  root: {
462
464
  ...specRecord,
465
+ visible: 'visible' in specRecord ? specRecord.visible : true,
463
466
  children: Array.isArray(specRecord.children)
464
467
  ? specRecord.children.filter((child: unknown) => typeof child === 'string')
465
468
  : [],
@@ -600,6 +603,30 @@ export function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec {
600
603
  });
601
604
  }
602
605
 
606
+ export function buildGraphConfig(input: GraphNodeInput): Record<string, unknown> {
607
+ const title = input.title?.trim() || 'Graph';
608
+ return {
609
+ title,
610
+ graphType: input.graphType,
611
+ data: input.data,
612
+ ...(input.xKey ? { xKey: input.xKey } : {}),
613
+ ...(input.yKey ? { yKey: input.yKey } : {}),
614
+ ...(input.zKey ? { zKey: input.zKey } : {}),
615
+ ...(input.nameKey ? { nameKey: input.nameKey } : {}),
616
+ ...(input.valueKey ? { valueKey: input.valueKey } : {}),
617
+ ...(input.axisKey ? { axisKey: input.axisKey } : {}),
618
+ ...(input.metrics?.length ? { metrics: input.metrics } : {}),
619
+ ...(input.series?.length ? { series: input.series } : {}),
620
+ ...(input.barKey ? { barKey: input.barKey } : {}),
621
+ ...(input.lineKey ? { lineKey: input.lineKey } : {}),
622
+ ...(input.aggregate ? { aggregate: input.aggregate } : {}),
623
+ ...(input.color ? { color: input.color } : {}),
624
+ ...(input.barColor ? { barColor: input.barColor } : {}),
625
+ ...(input.lineColor ? { lineColor: input.lineColor } : {}),
626
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
627
+ };
628
+ }
629
+
603
630
  export function createJsonRenderNodeData(
604
631
  nodeId: string,
605
632
  title: string,
package/src/mcp/server.ts CHANGED
@@ -95,7 +95,9 @@ function encodeBase64(bytes: Uint8Array): string {
95
95
 
96
96
  function createdNodePayload(c: PmxCanvas, id: string): Record<string, unknown> {
97
97
  const node = c.getNode(id);
98
- return node ? { ok: true, ...serializeCanvasNode(node) } : { ok: true, id };
98
+ if (!node) return { ok: true, id };
99
+ const serialized = serializeCanvasNode(node);
100
+ return { ok: true, node: serialized, ...serialized };
99
101
  }
100
102
 
101
103
  export async function startMcpServer(): Promise<void> {
@@ -576,10 +578,16 @@ export async function startMcpServer(): Promise<void> {
576
578
  y: z.number().optional().describe('New Y position'),
577
579
  width: z.number().optional().describe('New width'),
578
580
  height: z.number().optional().describe('New height'),
581
+ spec: z.record(z.string(), z.unknown()).optional().describe('New json-render spec, or a graph payload with graphType/data for graph nodes'),
582
+ graphType: z.string().optional().describe('Graph type when updating a graph node'),
583
+ data: z.array(z.record(z.string(), z.unknown())).optional().describe('Graph dataset when updating a graph node'),
584
+ xKey: z.string().optional().describe('Graph x/category key'),
585
+ yKey: z.string().optional().describe('Graph y/value key'),
586
+ chartHeight: z.number().optional().describe('Graph chart content height, distinct from node height'),
579
587
  collapsed: z.boolean().optional().describe('Collapse or expand the node'),
580
588
  arrangeLocked: z.boolean().optional().describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
581
589
  },
582
- async ({ id, title, content, x, y, width, height, collapsed, arrangeLocked }) => {
590
+ async ({ id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, arrangeLocked }) => {
583
591
  const c = await ensureCanvas();
584
592
  const node = c.getNode(id);
585
593
  if (!node) {
@@ -598,22 +606,21 @@ export async function startMcpServer(): Promise<void> {
598
606
  if (collapsed !== undefined) {
599
607
  patch.collapsed = collapsed;
600
608
  }
601
- if (title !== undefined || content !== undefined) {
602
- patch.data = {
603
- ...node.data,
604
- ...(title !== undefined ? { title } : {}),
605
- ...(content !== undefined ? { content } : {}),
606
- };
607
- }
609
+ if (title !== undefined) patch.title = title;
610
+ if (content !== undefined) patch.content = content;
611
+ if (spec !== undefined) patch.spec = spec;
612
+ if (graphType !== undefined) patch.graphType = graphType;
613
+ if (data !== undefined) patch.data = data;
614
+ if (xKey !== undefined) patch.xKey = xKey;
615
+ if (yKey !== undefined) patch.yKey = yKey;
616
+ if (chartHeight !== undefined) patch.chartHeight = chartHeight;
608
617
  if (arrangeLocked !== undefined) {
609
- patch.data = {
610
- ...(patch.data && typeof patch.data === 'object' ? patch.data as Record<string, unknown> : node.data),
611
- arrangeLocked,
612
- };
618
+ patch.arrangeLocked = arrangeLocked;
613
619
  }
614
620
  c.updateNode(id, patch);
621
+ const updated = c.getNode(id);
615
622
  return {
616
- content: [{ type: 'text', text: JSON.stringify({ ok: true, id }) }],
623
+ content: [{ type: 'text', text: JSON.stringify(updated ? createdNodePayload(c, id) : { ok: true, id }, null, 2) }],
617
624
  };
618
625
  },
619
626
  );
@@ -743,6 +750,31 @@ export async function startMcpServer(): Promise<void> {
743
750
  },
744
751
  );
745
752
 
753
+ server.tool(
754
+ 'canvas_fit_view',
755
+ 'Fit the canvas viewport to all nodes or a selected subset. Useful before screenshots and whole-board review.',
756
+ {
757
+ width: z.number().optional().describe('Viewport width used for fit math (default 1440)'),
758
+ height: z.number().optional().describe('Viewport height used for fit math (default 900)'),
759
+ padding: z.number().optional().describe('World-space padding around fitted nodes (default 60)'),
760
+ maxScale: z.number().optional().describe('Maximum zoom scale (default 1)'),
761
+ nodeIds: z.array(z.string()).optional().describe('Optional node IDs to fit instead of the whole canvas'),
762
+ },
763
+ async (input) => {
764
+ const c = await ensureCanvas();
765
+ const result = c.fitView({
766
+ ...(typeof input.width === 'number' ? { width: input.width } : {}),
767
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
768
+ ...(typeof input.padding === 'number' ? { padding: input.padding } : {}),
769
+ ...(typeof input.maxScale === 'number' ? { maxScale: input.maxScale } : {}),
770
+ ...(Array.isArray(input.nodeIds) ? { nodeIds: input.nodeIds } : {}),
771
+ });
772
+ return {
773
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
774
+ };
775
+ },
776
+ );
777
+
746
778
  // ── canvas_clear ───────────────────────────────────────────────
747
779
  server.tool(
748
780
  'canvas_clear',