pmx-canvas 0.1.7 → 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 +116 -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 +3 -1
- 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 +17 -3
- package/skills/pmx-canvas-testing/SKILL.md +15 -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 +114 -19
- 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 +58 -3
- package/src/mcp/server.ts +66 -23
- package/src/server/canvas-operations.ts +311 -21
- package/src/server/canvas-schema.ts +5 -3
- package/src/server/index.ts +45 -6
- package/src/server/server.ts +154 -40
|
@@ -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>;
|
|
@@ -4,7 +4,7 @@ export interface JsonRenderSpec {
|
|
|
4
4
|
state?: Record<string, unknown>;
|
|
5
5
|
}
|
|
6
6
|
export interface JsonRenderNodeInput {
|
|
7
|
-
title
|
|
7
|
+
title?: string;
|
|
8
8
|
spec: unknown;
|
|
9
9
|
x?: number;
|
|
10
10
|
y?: number;
|
|
@@ -44,9 +44,11 @@ export declare const GRAPH_NODE_SIZE: {
|
|
|
44
44
|
height: number;
|
|
45
45
|
};
|
|
46
46
|
export type GraphChartType = 'LineChart' | 'BarChart' | 'PieChart' | 'AreaChart' | 'ScatterChart' | 'RadarChart' | 'StackedBarChart' | 'ComposedChart';
|
|
47
|
+
export declare function inferJsonRenderNodeTitle(spec: JsonRenderSpec, fallback?: string): string;
|
|
47
48
|
export declare function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec;
|
|
48
49
|
export declare function normalizeGraphType(value: string): GraphChartType;
|
|
49
50
|
export declare function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec;
|
|
51
|
+
export declare function buildGraphConfig(input: GraphNodeInput): Record<string, unknown>;
|
|
50
52
|
export declare function createJsonRenderNodeData(nodeId: string, title: string, spec: JsonRenderSpec, extra?: Record<string, unknown>): Record<string, unknown>;
|
|
51
53
|
export declare function buildJsonRenderViewerHtml(options: {
|
|
52
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
|
|
@@ -270,6 +273,7 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
|
|
|
270
273
|
- `content`: for most types, this is markdown text. For `file` type, pass the **file path**
|
|
271
274
|
(e.g., `"src/auth/login.ts"`) — the server auto-loads the file content and watches for changes.
|
|
272
275
|
For `image` type, pass a file path, URL, or data URI.
|
|
276
|
+
- `path`: compatibility alias for image paths only; prefer `content` for new image calls
|
|
273
277
|
- `x`, `y`: position (auto-placed if omitted — prefer omitting for auto-layout)
|
|
274
278
|
- `width`, `height`: dimensions (sensible defaults provided)
|
|
275
279
|
- `color`: semantic color
|
|
@@ -278,7 +282,9 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
|
|
|
278
282
|
|
|
279
283
|
**`canvas_update_node`** — Update an existing node
|
|
280
284
|
- `id` (required): node to update
|
|
281
|
-
- 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
|
|
282
288
|
- Use to update status nodes as work progresses
|
|
283
289
|
|
|
284
290
|
**`canvas_remove_node`** — Remove a node and all its connected edges
|
|
@@ -306,8 +312,9 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
|
|
|
306
312
|
```
|
|
307
313
|
|
|
308
314
|
**`canvas_add_json_render_node`** — Add a native json-render node
|
|
309
|
-
- Required: `
|
|
310
|
-
-
|
|
315
|
+
- Required: `spec`; `title` is optional and inferred from the root element when omitted
|
|
316
|
+
- Prefer a complete json-render object with `root`, `elements`, and optional `state`
|
|
317
|
+
- Legacy bare component specs like `{ type: "Badge", props: {...} }` are accepted and wrapped into a one-element document for compatibility
|
|
311
318
|
- Use this when you want a structured UI panel rendered directly inside PMX Canvas
|
|
312
319
|
- For shadcn `Badge`, prefer `props.text` with variants `default`, `secondary`, `destructive`, or
|
|
313
320
|
`outline`. Legacy `props.label` and status variants (`success`, `info`, `warning`, `error`,
|
|
@@ -355,6 +362,10 @@ ID extraction for mixed tool responses:
|
|
|
355
362
|
- Returns the normalized json-render spec the server would accept
|
|
356
363
|
- Use this when you want a dry run before creating a `json-render` or `graph` node
|
|
357
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
|
+
|
|
358
369
|
**Batch graph creation**
|
|
359
370
|
- Use `graph.add` inside `canvas_batch` / `pmx-canvas batch` when you need a graph node as part of
|
|
360
371
|
a larger one-shot build.
|
|
@@ -510,6 +521,8 @@ tools below operate on the live canvas state.
|
|
|
510
521
|
- Required: exactly one of `expression` (single JS expression) or `script` (multi-statement body)
|
|
511
522
|
- `script` is wrapped in an async IIFE, so top-level `await` works inside script bodies
|
|
512
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.
|
|
513
526
|
- Example: read the count of rendered `.canvas-node` elements:
|
|
514
527
|
|
|
515
528
|
```typescript
|
|
@@ -664,6 +677,7 @@ All POST/PATCH endpoints accept `Content-Type: application/json`. Default base U
|
|
|
664
677
|
| POST | `/api/canvas/group/ungroup` | Ungroup |
|
|
665
678
|
| POST | `/api/canvas/arrange` | Auto-arrange |
|
|
666
679
|
| POST | `/api/canvas/focus` | Center viewport on node |
|
|
680
|
+
| POST | `/api/canvas/fit` | Fit viewport to canvas bounds or selected nodes |
|
|
667
681
|
| POST | `/api/canvas/clear` | Clear canvas |
|
|
668
682
|
| POST | `/api/canvas/update` | Batch update positions |
|
|
669
683
|
| GET | `/api/canvas/spatial-context` | Spatial clusters and reading order |
|
|
@@ -40,11 +40,25 @@ bun run test:all # Bun suite + browser smoke
|
|
|
40
40
|
Manual browser validation also requires a fresh client bundle. `bun run test:web-canvas`
|
|
41
41
|
already does this for you.
|
|
42
42
|
|
|
43
|
+
## Coverage Notes
|
|
44
|
+
|
|
45
|
+
- `bun run test:coverage` covers the Bun unit suite under `tests/unit/`
|
|
46
|
+
- Coverage output is written to `coverage/lcov.info` and also printed as a text summary
|
|
47
|
+
- CI currently uses that same unit-test coverage command, then runs browser smoke separately
|
|
48
|
+
- Do not describe `test:coverage` as full-stack coverage; Playwright coverage is not wired in here
|
|
49
|
+
|
|
43
50
|
## Current Project Test Surface
|
|
44
51
|
|
|
45
52
|
- Bun tests live under `tests/unit/`
|
|
46
53
|
- Playwright browser smoke lives under `tests/e2e/`
|
|
47
|
-
- CI runs coverage plus the browser smoke flow
|
|
54
|
+
- CI runs Bun coverage plus the browser smoke flow
|
|
55
|
+
|
|
56
|
+
## WebView Automation Caveat
|
|
57
|
+
|
|
58
|
+
- Some Linux/CI environments expose `Bun.WebView` but still cannot start a usable automation
|
|
59
|
+
session within the timeout window
|
|
60
|
+
- When testing WebView automation, treat a cleanly reported unsupported/timeout runtime boundary
|
|
61
|
+
as distinct from a product regression
|
|
48
62
|
|
|
49
63
|
Prefer extending the existing suites before inventing a one-off script.
|
|
50
64
|
|
|
@@ -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,15 +553,33 @@ 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>> {
|
|
524
580
|
const hint =
|
|
525
|
-
'Use: pmx-canvas node add --type json-render --
|
|
581
|
+
'Use: pmx-canvas node add --type json-render --spec-file ./dashboard.json --title "Ops Dashboard"';
|
|
526
582
|
const title = typeof flags.title === 'string' ? flags.title.trim() : '';
|
|
527
|
-
if (!title) {
|
|
528
|
-
die('json-render nodes require --title.', hint);
|
|
529
|
-
}
|
|
530
583
|
|
|
531
584
|
const rawSpec = await readTextInput(flags, {
|
|
532
585
|
fileFlags: ['spec-file'],
|
|
@@ -538,7 +591,7 @@ async function buildJsonRenderRequestBody(
|
|
|
538
591
|
});
|
|
539
592
|
|
|
540
593
|
const spec = parseJsonValue(rawSpec, 'JSON spec', hint);
|
|
541
|
-
const body: Record<string, unknown> = { title, spec };
|
|
594
|
+
const body: Record<string, unknown> = { ...(title ? { title } : {}), spec };
|
|
542
595
|
applyCommonGeometryFlags(body, flags, {
|
|
543
596
|
x: 'Use a finite number, e.g. --x 500',
|
|
544
597
|
y: 'Use a finite number, e.g. --y 300',
|
|
@@ -550,23 +603,30 @@ async function buildJsonRenderRequestBody(
|
|
|
550
603
|
|
|
551
604
|
async function buildGraphRequestBody(
|
|
552
605
|
flags: Record<string, string | true>,
|
|
606
|
+
options: { requireData?: boolean; allowStdin?: boolean } = {},
|
|
553
607
|
): Promise<Record<string, unknown>> {
|
|
608
|
+
const requireData = options.requireData !== false;
|
|
609
|
+
const allowStdin = options.allowStdin !== false;
|
|
554
610
|
const hint =
|
|
555
611
|
'Use: pmx-canvas node add --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value';
|
|
556
|
-
|
|
612
|
+
|
|
613
|
+
const body: Record<string, unknown> = {
|
|
614
|
+
...(requireData ? { graphType: getStringFlag(flags, 'graph-type', 'graphType') ?? 'line' } : {}),
|
|
615
|
+
};
|
|
616
|
+
const rawData = await readOptionalTextInput(flags, {
|
|
557
617
|
fileFlags: ['data-file'],
|
|
558
618
|
valueFlags: ['data-json', 'data'],
|
|
559
|
-
allowStdin
|
|
619
|
+
allowStdin,
|
|
560
620
|
label: 'graph JSON dataset',
|
|
561
621
|
hint,
|
|
562
|
-
requiredMessage: 'Graph nodes require --data-file, --data-json, --data, or --stdin JSON data.',
|
|
563
622
|
});
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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;
|
|
570
630
|
if (typeof flags.title === 'string') body.title = flags.title;
|
|
571
631
|
const xKey = getStringFlag(flags, 'x-key', 'xKey');
|
|
572
632
|
const yKey = getStringFlag(flags, 'y-key', 'yKey');
|
|
@@ -985,8 +1045,11 @@ cmd('node add', 'Add a node to the canvas', [
|
|
|
985
1045
|
const body: Record<string, unknown> = { type };
|
|
986
1046
|
if (flags.title) body.title = flags.title;
|
|
987
1047
|
const webpageUrl = getStringFlag(flags, 'url');
|
|
1048
|
+
const imagePath = getStringFlag(flags, 'path');
|
|
988
1049
|
if (type === 'webpage' && webpageUrl) {
|
|
989
1050
|
body.url = webpageUrl;
|
|
1051
|
+
} else if (type === 'image' && imagePath && !flags.content) {
|
|
1052
|
+
body.content = imagePath;
|
|
990
1053
|
} else if (flags.content) {
|
|
991
1054
|
body.content = flags.content;
|
|
992
1055
|
}
|
|
@@ -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
|
|