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.
- package/CHANGELOG.md +65 -0
- package/Readme.md +52 -54
- package/dist/canvas/index.js +61 -61
- package/dist/types/client/canvas/auto-fit.d.ts +5 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
- package/dist/types/json-render/server.d.ts +1 -0
- package/dist/types/server/canvas-operations.d.ts +49 -0
- package/dist/types/server/index.d.ts +9 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +13 -1
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +1 -1
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +11 -0
- package/src/cli/agent.ts +109 -14
- package/src/client/canvas/CanvasNode.tsx +5 -7
- package/src/client/canvas/auto-fit.ts +21 -0
- package/src/client/nodes/ExtAppFrame.tsx +4 -2
- package/src/json-render/server.ts +27 -0
- package/src/mcp/server.ts +46 -14
- package/src/server/canvas-operations.ts +309 -20
- package/src/server/canvas-schema.ts +2 -0
- package/src/server/index.ts +45 -6
- package/src/server/server.ts +149 -35
|
@@ -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.
|
|
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`, `
|
|
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
|
-
|
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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.
|
|
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',
|