pmx-canvas 0.1.23 → 0.1.25
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/.github/extensions/pmx-canvas/extension.mjs +591 -0
- package/CHANGELOG.md +123 -0
- package/Readme.md +36 -5
- package/dist/canvas/global.css +36 -3
- package/dist/canvas/index.js +54 -54
- package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +1 -0
- package/dist/types/client/nodes/iframe-document-url.d.ts +8 -0
- package/dist/types/client/state/intent-bridge.d.ts +4 -0
- package/dist/types/client/types.d.ts +1 -0
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/mcp/canvas-access.d.ts +9 -0
- package/dist/types/server/ax-context.d.ts +3 -0
- package/dist/types/server/ax-state.d.ts +43 -0
- package/dist/types/server/canvas-db.d.ts +5 -0
- package/dist/types/server/canvas-operations.d.ts +4 -0
- package/dist/types/server/canvas-state.d.ts +20 -3
- package/dist/types/server/index.d.ts +6 -0
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/docs/cli.md +13 -0
- package/docs/http-api.md +24 -0
- package/docs/mcp.md +20 -2
- package/docs/plans/plan-004-pmx-ax-primitives.md +463 -0
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +5 -0
- package/package.json +2 -1
- package/skills/pmx-canvas/SKILL.md +14 -0
- package/skills/pmx-canvas/references/codex-app-adapter.md +110 -0
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +125 -0
- package/src/cli/agent.ts +34 -0
- package/src/cli/index.ts +2 -1
- package/src/client/App.tsx +2 -0
- package/src/client/canvas/CanvasNode.tsx +7 -0
- package/src/client/canvas/CommandPalette.tsx +2 -1
- package/src/client/canvas/use-node-drag.ts +29 -7
- package/src/client/canvas/use-node-resize.ts +27 -7
- package/src/client/nodes/ExtAppFrame.tsx +51 -10
- package/src/client/nodes/HtmlNode.tsx +5 -2
- package/src/client/nodes/McpAppNode.tsx +13 -1
- package/src/client/nodes/iframe-document-url.ts +58 -0
- package/src/client/state/intent-bridge.ts +8 -0
- package/src/client/state/sse-bridge.ts +3 -3
- package/src/client/theme/global.css +36 -3
- package/src/client/types.ts +1 -0
- package/src/mcp/canvas-access.ts +38 -0
- package/src/mcp/server.ts +113 -4
- package/src/server/ax-context.ts +38 -0
- package/src/server/ax-state.ts +130 -0
- package/src/server/canvas-db.ts +36 -1
- package/src/server/canvas-operations.ts +96 -4
- package/src/server/canvas-state.ts +123 -4
- package/src/server/index.ts +29 -2
- package/src/server/mutation-history.ts +12 -0
- package/src/server/server.ts +312 -14
|
@@ -103,7 +103,8 @@ interface CanvasAddNodeInput {
|
|
|
103
103
|
strictSize?: boolean;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
export const MARKDOWN_NODE_DEFAULT_SIZE = { width:
|
|
106
|
+
export const MARKDOWN_NODE_DEFAULT_SIZE = { width: 640, height: 420 };
|
|
107
|
+
export const MCP_APP_NODE_DEFAULT_SIZE = { width: 960, height: 600 };
|
|
107
108
|
|
|
108
109
|
interface CanvasCreateGroupInput {
|
|
109
110
|
title?: string;
|
|
@@ -1099,8 +1100,84 @@ function collectGridArrangeExcludedNodeIds(nodes: CanvasNodeState[]): Set<string
|
|
|
1099
1100
|
return excluded;
|
|
1100
1101
|
}
|
|
1101
1102
|
|
|
1103
|
+
interface ArrangeObstacleRect {
|
|
1104
|
+
id: string;
|
|
1105
|
+
position: { x: number; y: number };
|
|
1106
|
+
size: { width: number; height: number };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const GRID_OBSTACLE_GAP_Y = 72;
|
|
1110
|
+
|
|
1111
|
+
function rectsOverlap(a: ArrangeObstacleRect, b: ArrangeObstacleRect): boolean {
|
|
1112
|
+
return (
|
|
1113
|
+
a.position.x < b.position.x + b.size.width &&
|
|
1114
|
+
a.position.x + a.size.width > b.position.x &&
|
|
1115
|
+
a.position.y < b.position.y + b.size.height &&
|
|
1116
|
+
a.position.y + a.size.height > b.position.y
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function collectGridArrangeObstacles(nodes: CanvasNodeState[], excludedIds: Set<string>): ArrangeObstacleRect[] {
|
|
1121
|
+
return nodes
|
|
1122
|
+
.filter((node) => excludedIds.has(node.id) && node.dockPosition === null)
|
|
1123
|
+
.map((node) => ({
|
|
1124
|
+
id: node.id,
|
|
1125
|
+
position: { ...node.position },
|
|
1126
|
+
size: { ...node.size },
|
|
1127
|
+
}));
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function buildUpdatedArrangeRect(
|
|
1131
|
+
update: CanvasNodeUpdate,
|
|
1132
|
+
nodesById: Map<string, CanvasNodeState>,
|
|
1133
|
+
): ArrangeObstacleRect | null {
|
|
1134
|
+
const node = nodesById.get(update.id);
|
|
1135
|
+
if (!node) return null;
|
|
1136
|
+
return {
|
|
1137
|
+
id: update.id,
|
|
1138
|
+
position: update.position ? { ...update.position } : { ...node.position },
|
|
1139
|
+
size: update.size ? { ...update.size } : { ...node.size },
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function shiftGridUpdatesBelowObstacles(
|
|
1144
|
+
updates: CanvasNodeUpdate[],
|
|
1145
|
+
nodes: CanvasNodeState[],
|
|
1146
|
+
obstacles: ArrangeObstacleRect[],
|
|
1147
|
+
): CanvasNodeUpdate[] {
|
|
1148
|
+
if (updates.length === 0 || obstacles.length === 0) return updates;
|
|
1149
|
+
|
|
1150
|
+
// Grid arrange only sees movable nodes, so preserved locked/docked-group frames
|
|
1151
|
+
// need a separate obstacle pass before applying the planned positions.
|
|
1152
|
+
const nodesById = new Map(nodes.map((node) => [node.id, node]));
|
|
1153
|
+
let shifted = updates;
|
|
1154
|
+
|
|
1155
|
+
for (let attempt = 0; attempt <= obstacles.length; attempt++) {
|
|
1156
|
+
const plannedRects = shifted
|
|
1157
|
+
.map((update) => buildUpdatedArrangeRect(update, nodesById))
|
|
1158
|
+
.filter((rect): rect is ArrangeObstacleRect => rect !== null);
|
|
1159
|
+
if (plannedRects.length === 0) return shifted;
|
|
1160
|
+
|
|
1161
|
+
const blockers = obstacles.filter((obstacle) =>
|
|
1162
|
+
plannedRects.some((rect) => rect.id !== obstacle.id && rectsOverlap(rect, obstacle)),
|
|
1163
|
+
);
|
|
1164
|
+
if (blockers.length === 0) return shifted;
|
|
1165
|
+
|
|
1166
|
+
const minPlannedY = Math.min(...plannedRects.map((rect) => rect.position.y));
|
|
1167
|
+
const blockerBottom = Math.max(...blockers.map((rect) => rect.position.y + rect.size.height));
|
|
1168
|
+
const deltaY = blockerBottom + GRID_OBSTACLE_GAP_Y - minPlannedY;
|
|
1169
|
+
if (deltaY <= 0) return shifted;
|
|
1170
|
+
|
|
1171
|
+
shifted = shifted.map((update) => update.position
|
|
1172
|
+
? { ...update, position: { x: update.position.x, y: update.position.y + deltaY } }
|
|
1173
|
+
: update);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
return shifted;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1102
1179
|
export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: number; layout: CanvasArrangeMode } {
|
|
1103
|
-
const nodes = canvasState.
|
|
1180
|
+
const nodes = canvasState.getLayoutForPersistence().nodes;
|
|
1104
1181
|
const excludedIds = layout === 'grid'
|
|
1105
1182
|
? collectGridArrangeExcludedNodeIds(nodes)
|
|
1106
1183
|
: collectArrangeExcludedNodeIds(nodes);
|
|
@@ -1135,6 +1212,9 @@ export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: numbe
|
|
|
1135
1212
|
size: { width: bounds.width, height: bounds.height },
|
|
1136
1213
|
});
|
|
1137
1214
|
}
|
|
1215
|
+
const obstacles = collectGridArrangeObstacles(nodes, excludedIds);
|
|
1216
|
+
const shiftedUpdates = shiftGridUpdatesBelowObstacles(updates, nodes, obstacles);
|
|
1217
|
+
updates.splice(0, updates.length, ...shiftedUpdates);
|
|
1138
1218
|
}
|
|
1139
1219
|
|
|
1140
1220
|
canvasState.withSuppressedRecording(() => {
|
|
@@ -1578,8 +1658,20 @@ export async function executeCanvasBatch(
|
|
|
1578
1658
|
...(typeof args.width === 'number' ? { width: args.width } : {}),
|
|
1579
1659
|
...(typeof args.height === 'number' ? { height: args.height } : {}),
|
|
1580
1660
|
...(args.strictSize === true ? { strictSize: true } : {}),
|
|
1581
|
-
defaultWidth: type === 'html'
|
|
1582
|
-
|
|
1661
|
+
defaultWidth: type === 'html'
|
|
1662
|
+
? 720
|
|
1663
|
+
: type === 'markdown'
|
|
1664
|
+
? MARKDOWN_NODE_DEFAULT_SIZE.width
|
|
1665
|
+
: type === 'mcp-app'
|
|
1666
|
+
? MCP_APP_NODE_DEFAULT_SIZE.width
|
|
1667
|
+
: 360,
|
|
1668
|
+
defaultHeight: type === 'html'
|
|
1669
|
+
? 640
|
|
1670
|
+
: type === 'markdown'
|
|
1671
|
+
? MARKDOWN_NODE_DEFAULT_SIZE.height
|
|
1672
|
+
: type === 'mcp-app'
|
|
1673
|
+
? MCP_APP_NODE_DEFAULT_SIZE.height
|
|
1674
|
+
: 200,
|
|
1583
1675
|
fileMode: 'auto',
|
|
1584
1676
|
});
|
|
1585
1677
|
result = { ok: true, ...serializeCreatedNode(created.node) };
|
|
@@ -30,7 +30,9 @@ import {
|
|
|
30
30
|
checkpointCanvasDb,
|
|
31
31
|
finalizeCanvasDbForClose,
|
|
32
32
|
type PersistedCanvasState,
|
|
33
|
+
type CanvasTheme,
|
|
33
34
|
} from './canvas-db.js';
|
|
35
|
+
import { normalizeCanvasTheme } from './canvas-db.js';
|
|
34
36
|
import {
|
|
35
37
|
type CanvasPlacementRect,
|
|
36
38
|
computeGroupBounds,
|
|
@@ -39,6 +41,13 @@ import {
|
|
|
39
41
|
GROUP_TITLEBAR_HEIGHT,
|
|
40
42
|
resolveGroupCollision,
|
|
41
43
|
} from './placement.js';
|
|
44
|
+
import {
|
|
45
|
+
createEmptyAxState,
|
|
46
|
+
normalizeAxState,
|
|
47
|
+
type PmxAxFocusState,
|
|
48
|
+
type PmxAxSource,
|
|
49
|
+
type PmxAxState,
|
|
50
|
+
} from './ax-state.js';
|
|
42
51
|
|
|
43
52
|
function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
|
|
44
53
|
console.warn(`[canvas-state] ${action}`, { error, ...(details ?? {}) });
|
|
@@ -186,6 +195,7 @@ export interface CanvasAnnotation {
|
|
|
186
195
|
|
|
187
196
|
export interface CanvasLayout {
|
|
188
197
|
viewport: ViewportState;
|
|
198
|
+
theme: CanvasTheme;
|
|
189
199
|
nodes: CanvasNodeState[];
|
|
190
200
|
edges: CanvasEdge[];
|
|
191
201
|
annotations: CanvasAnnotation[];
|
|
@@ -199,10 +209,10 @@ export interface CanvasNodeUpdate {
|
|
|
199
209
|
dockPosition?: 'left' | 'right' | null;
|
|
200
210
|
}
|
|
201
211
|
|
|
202
|
-
export type CanvasChangeType = 'pins' | 'nodes';
|
|
212
|
+
export type CanvasChangeType = 'pins' | 'nodes' | 'ax';
|
|
203
213
|
|
|
204
214
|
export interface MutationRecordInfo {
|
|
205
|
-
operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
|
|
215
|
+
operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'setAxFocus' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
|
|
206
216
|
description: string;
|
|
207
217
|
forward: () => void;
|
|
208
218
|
inverse: () => void;
|
|
@@ -260,7 +270,9 @@ class CanvasStateManager {
|
|
|
260
270
|
private edges = new Map<string, CanvasEdge>();
|
|
261
271
|
private annotations = new Map<string, CanvasAnnotation>();
|
|
262
272
|
private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
|
|
273
|
+
private _theme: CanvasTheme = 'dark';
|
|
263
274
|
private _contextPinnedNodeIds = new Set<string>();
|
|
275
|
+
private _axState: PmxAxState = createEmptyAxState();
|
|
264
276
|
private _workspaceRoot = process.cwd();
|
|
265
277
|
|
|
266
278
|
// ── Change listeners (for MCP resource notifications) ──────
|
|
@@ -310,6 +322,18 @@ class CanvasStateManager {
|
|
|
310
322
|
}
|
|
311
323
|
}
|
|
312
324
|
|
|
325
|
+
private currentNodeIdSet(): Set<string> {
|
|
326
|
+
return new Set(this.nodes.keys());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private normalizeAxForCurrentNodes(state: unknown): PmxAxState {
|
|
330
|
+
return normalizeAxState(state, this.currentNodeIdSet());
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private applyAxState(state: PmxAxState): void {
|
|
334
|
+
this._axState = this.normalizeAxForCurrentNodes(state);
|
|
335
|
+
}
|
|
336
|
+
|
|
313
337
|
private applyResolvedGroupBounds(
|
|
314
338
|
group: CanvasNodeState,
|
|
315
339
|
groupId: string,
|
|
@@ -376,6 +400,14 @@ class CanvasStateManager {
|
|
|
376
400
|
return normalized;
|
|
377
401
|
}
|
|
378
402
|
|
|
403
|
+
private nodeForRead(node: CanvasNodeState): CanvasNodeState {
|
|
404
|
+
const resolved = this.resolveNodeDataBlobs(node);
|
|
405
|
+
return {
|
|
406
|
+
...resolved,
|
|
407
|
+
pinned: resolved.pinned || this._contextPinnedNodeIds.has(resolved.id),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
379
411
|
private reflowAllGroups(): void {
|
|
380
412
|
const groups = Array.from(this.nodes.values())
|
|
381
413
|
.filter((node): node is CanvasNodeState => node.type === 'group')
|
|
@@ -788,11 +820,13 @@ class CanvasStateManager {
|
|
|
788
820
|
private emptyPersistedState(): PersistedCanvasState {
|
|
789
821
|
return {
|
|
790
822
|
version: 1,
|
|
823
|
+
theme: this._theme,
|
|
791
824
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
792
825
|
nodes: [],
|
|
793
826
|
edges: [],
|
|
794
827
|
annotations: [],
|
|
795
828
|
contextPins: [],
|
|
829
|
+
ax: createEmptyAxState(),
|
|
796
830
|
};
|
|
797
831
|
}
|
|
798
832
|
|
|
@@ -863,11 +897,13 @@ class CanvasStateManager {
|
|
|
863
897
|
try {
|
|
864
898
|
const payload = this.externalizePersistedStateBlobs({
|
|
865
899
|
version: 1,
|
|
900
|
+
theme: this._theme,
|
|
866
901
|
viewport: this._viewport,
|
|
867
902
|
nodes: Array.from(this.nodes.values()),
|
|
868
903
|
edges: Array.from(this.edges.values()),
|
|
869
904
|
annotations: Array.from(this.annotations.values()),
|
|
870
905
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
906
|
+
ax: this.getAxState(),
|
|
871
907
|
});
|
|
872
908
|
saveStateToDB(this._db, payload);
|
|
873
909
|
} catch (error) {
|
|
@@ -909,12 +945,14 @@ class CanvasStateManager {
|
|
|
909
945
|
this.edges.clear();
|
|
910
946
|
this.annotations.clear();
|
|
911
947
|
this._contextPinnedNodeIds.clear();
|
|
948
|
+
this._axState = createEmptyAxState();
|
|
912
949
|
|
|
913
950
|
this._viewport = {
|
|
914
951
|
x: state.viewport?.x ?? 0,
|
|
915
952
|
y: state.viewport?.y ?? 0,
|
|
916
953
|
scale: state.viewport?.scale ?? 1,
|
|
917
954
|
};
|
|
955
|
+
this._theme = normalizeCanvasTheme(state.theme, this._theme);
|
|
918
956
|
|
|
919
957
|
if (Array.isArray(state.nodes)) {
|
|
920
958
|
for (const node of state.nodes) {
|
|
@@ -938,6 +976,7 @@ class CanvasStateManager {
|
|
|
938
976
|
if (this.nodes.has(pinId)) this._contextPinnedNodeIds.add(pinId);
|
|
939
977
|
}
|
|
940
978
|
}
|
|
979
|
+
this._axState = this.normalizeAxForCurrentNodes(state.ax);
|
|
941
980
|
}
|
|
942
981
|
|
|
943
982
|
private readResolvedSnapshot(idOrName: string): {
|
|
@@ -1028,11 +1067,13 @@ class CanvasStateManager {
|
|
|
1028
1067
|
try {
|
|
1029
1068
|
const payload = this.externalizePersistedStateBlobs({
|
|
1030
1069
|
version: 1,
|
|
1070
|
+
theme: this._theme,
|
|
1031
1071
|
viewport: this._viewport,
|
|
1032
1072
|
nodes: Array.from(this.nodes.values()),
|
|
1033
1073
|
edges: Array.from(this.edges.values()),
|
|
1034
1074
|
annotations: Array.from(this.annotations.values()),
|
|
1035
1075
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1076
|
+
ax: this.getAxState(),
|
|
1036
1077
|
});
|
|
1037
1078
|
saveSnapshotToDB(this._db, snapshot, payload);
|
|
1038
1079
|
snapshot.nodeCount = payload.nodes.length;
|
|
@@ -1117,19 +1158,23 @@ class CanvasStateManager {
|
|
|
1117
1158
|
|
|
1118
1159
|
const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
|
|
1119
1160
|
version: 1,
|
|
1161
|
+
theme: this._theme,
|
|
1120
1162
|
viewport: structuredClone(this._viewport),
|
|
1121
1163
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
|
|
1122
1164
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
1123
1165
|
annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
|
|
1124
1166
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1167
|
+
ax: this.getAxState(),
|
|
1125
1168
|
});
|
|
1126
1169
|
const nextState: PersistedCanvasState = {
|
|
1127
1170
|
version: 1,
|
|
1171
|
+
theme: normalizeCanvasTheme(resolved.state.theme, this._theme),
|
|
1128
1172
|
viewport: structuredClone(resolved.state.viewport),
|
|
1129
1173
|
nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
|
|
1130
1174
|
edges: Array.isArray(resolved.state.edges) ? resolved.state.edges.map((edge) => structuredClone(edge)) : [],
|
|
1131
1175
|
annotations: Array.isArray(resolved.state.annotations) ? resolved.state.annotations.map((annotation) => structuredClone(annotation)) : [],
|
|
1132
1176
|
contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
|
|
1177
|
+
ax: resolved.state.ax ? structuredClone(resolved.state.ax) : createEmptyAxState(),
|
|
1133
1178
|
};
|
|
1134
1179
|
|
|
1135
1180
|
try {
|
|
@@ -1137,6 +1182,7 @@ class CanvasStateManager {
|
|
|
1137
1182
|
this.scheduleSave();
|
|
1138
1183
|
this.notifyChange('nodes');
|
|
1139
1184
|
this.notifyChange('pins');
|
|
1185
|
+
this.notifyChange('ax');
|
|
1140
1186
|
this.recordMutation({
|
|
1141
1187
|
operationType: 'restoreSnapshot',
|
|
1142
1188
|
description: `Restored snapshot "${resolved.snapshot.name}"`,
|
|
@@ -1145,12 +1191,14 @@ class CanvasStateManager {
|
|
|
1145
1191
|
this.scheduleSave();
|
|
1146
1192
|
this.notifyChange('nodes');
|
|
1147
1193
|
this.notifyChange('pins');
|
|
1194
|
+
this.notifyChange('ax');
|
|
1148
1195
|
}),
|
|
1149
1196
|
inverse: this.suppressed(() => {
|
|
1150
1197
|
this.applyPersistedState(previousState);
|
|
1151
1198
|
this.scheduleSave();
|
|
1152
1199
|
this.notifyChange('nodes');
|
|
1153
1200
|
this.notifyChange('pins');
|
|
1201
|
+
this.notifyChange('ax');
|
|
1154
1202
|
}),
|
|
1155
1203
|
});
|
|
1156
1204
|
return true;
|
|
@@ -1287,6 +1335,7 @@ class CanvasStateManager {
|
|
|
1287
1335
|
const existing = this.nodes.get(id);
|
|
1288
1336
|
const connectedEdges = existing ? this.getEdgesForNode(id).map((e) => structuredClone(e)) : [];
|
|
1289
1337
|
const cloned = existing ? structuredClone(existing) : null;
|
|
1338
|
+
const oldAxState = this.getAxState();
|
|
1290
1339
|
|
|
1291
1340
|
// Prune from parent group's children list
|
|
1292
1341
|
if (existing) {
|
|
@@ -1315,9 +1364,11 @@ class CanvasStateManager {
|
|
|
1315
1364
|
this.nodes.delete(id);
|
|
1316
1365
|
this.removeEdgesForNode(id);
|
|
1317
1366
|
this._contextPinnedNodeIds.delete(id);
|
|
1367
|
+
this.applyAxState(this._axState);
|
|
1318
1368
|
this.scheduleSave();
|
|
1319
1369
|
this.notifyChange('nodes');
|
|
1320
1370
|
this.notifyChange('pins');
|
|
1371
|
+
this.notifyChange('ax');
|
|
1321
1372
|
if (cloned) {
|
|
1322
1373
|
this.recordMutation({
|
|
1323
1374
|
operationType: 'removeNode',
|
|
@@ -1326,6 +1377,9 @@ class CanvasStateManager {
|
|
|
1326
1377
|
inverse: this.suppressed(() => {
|
|
1327
1378
|
this.addNode(structuredClone(cloned));
|
|
1328
1379
|
for (const edge of connectedEdges) this.addEdge(structuredClone(edge));
|
|
1380
|
+
this.applyAxState(oldAxState);
|
|
1381
|
+
this.scheduleSave();
|
|
1382
|
+
this.notifyChange('ax');
|
|
1329
1383
|
}),
|
|
1330
1384
|
});
|
|
1331
1385
|
}
|
|
@@ -1333,7 +1387,7 @@ class CanvasStateManager {
|
|
|
1333
1387
|
|
|
1334
1388
|
getNode(id: string): CanvasNodeState | undefined {
|
|
1335
1389
|
const node = this.nodes.get(id);
|
|
1336
|
-
return node ? structuredClone(this.
|
|
1390
|
+
return node ? structuredClone(this.nodeForRead(node)) : undefined;
|
|
1337
1391
|
}
|
|
1338
1392
|
|
|
1339
1393
|
getNodeForPersistence(id: string): CanvasNodeState | undefined {
|
|
@@ -1435,7 +1489,8 @@ class CanvasStateManager {
|
|
|
1435
1489
|
getLayout(): CanvasLayout {
|
|
1436
1490
|
return {
|
|
1437
1491
|
viewport: structuredClone(this._viewport),
|
|
1438
|
-
|
|
1492
|
+
theme: this._theme,
|
|
1493
|
+
nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.nodeForRead(node))),
|
|
1439
1494
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
1440
1495
|
annotations: this.getAnnotations(),
|
|
1441
1496
|
};
|
|
@@ -1444,6 +1499,7 @@ class CanvasStateManager {
|
|
|
1444
1499
|
getLayoutForPersistence(): CanvasLayout {
|
|
1445
1500
|
return {
|
|
1446
1501
|
viewport: structuredClone(this._viewport),
|
|
1502
|
+
theme: this._theme,
|
|
1447
1503
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
|
|
1448
1504
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
1449
1505
|
annotations: this.getAnnotations(),
|
|
@@ -1563,12 +1619,70 @@ class CanvasStateManager {
|
|
|
1563
1619
|
});
|
|
1564
1620
|
}
|
|
1565
1621
|
|
|
1622
|
+
get theme(): CanvasTheme {
|
|
1623
|
+
return this._theme;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
setTheme(theme: CanvasTheme): CanvasTheme {
|
|
1627
|
+
const next = normalizeCanvasTheme(theme, this._theme);
|
|
1628
|
+
if (next === this._theme) return this._theme;
|
|
1629
|
+
this._theme = next;
|
|
1630
|
+
this.scheduleSave();
|
|
1631
|
+
this.notifyChange('nodes');
|
|
1632
|
+
return this._theme;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1566
1635
|
// ── Context pins ─────────────────────────────────────────────
|
|
1567
1636
|
|
|
1568
1637
|
get contextPinnedNodeIds(): Set<string> {
|
|
1569
1638
|
return new Set(this._contextPinnedNodeIds);
|
|
1570
1639
|
}
|
|
1571
1640
|
|
|
1641
|
+
getAxState(): PmxAxState {
|
|
1642
|
+
return structuredClone(this.normalizeAxForCurrentNodes(this._axState));
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
getAxFocus(): PmxAxFocusState {
|
|
1646
|
+
return this.getAxState().focus;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
setAxFocus(nodeIds: string[], options: { source?: PmxAxSource; recordHistory?: boolean } = {}): PmxAxFocusState {
|
|
1650
|
+
const oldAxState = this.getAxState();
|
|
1651
|
+
const nextAxState: PmxAxState = {
|
|
1652
|
+
...oldAxState,
|
|
1653
|
+
focus: {
|
|
1654
|
+
nodeIds,
|
|
1655
|
+
primaryNodeId: nodeIds[0] ?? null,
|
|
1656
|
+
updatedAt: new Date().toISOString(),
|
|
1657
|
+
source: options.source ?? 'api',
|
|
1658
|
+
},
|
|
1659
|
+
};
|
|
1660
|
+
this.applyAxState(nextAxState);
|
|
1661
|
+
const appliedAxState = this.getAxState();
|
|
1662
|
+
this.scheduleSave();
|
|
1663
|
+
this.notifyChange('ax');
|
|
1664
|
+
if (options.recordHistory === false) return appliedAxState.focus;
|
|
1665
|
+
this.recordMutation({
|
|
1666
|
+
operationType: 'setAxFocus',
|
|
1667
|
+
description: `Set AX focus (${appliedAxState.focus.nodeIds.length} nodes)`,
|
|
1668
|
+
forward: this.suppressed(() => {
|
|
1669
|
+
this.applyAxState(appliedAxState);
|
|
1670
|
+
this.scheduleSave();
|
|
1671
|
+
this.notifyChange('ax');
|
|
1672
|
+
}),
|
|
1673
|
+
inverse: this.suppressed(() => {
|
|
1674
|
+
this.applyAxState(oldAxState);
|
|
1675
|
+
this.scheduleSave();
|
|
1676
|
+
this.notifyChange('ax');
|
|
1677
|
+
}),
|
|
1678
|
+
});
|
|
1679
|
+
return appliedAxState.focus;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
clearAxFocus(): PmxAxFocusState {
|
|
1683
|
+
return this.setAxFocus([], { source: 'system' });
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1572
1686
|
setContextPins(nodeIds: string[]): void {
|
|
1573
1687
|
const oldPins = Array.from(this._contextPinnedNodeIds);
|
|
1574
1688
|
this._contextPinnedNodeIds.clear();
|
|
@@ -1694,15 +1808,18 @@ class CanvasStateManager {
|
|
|
1694
1808
|
const oldEdges = Array.from(this.edges.values()).map((e) => structuredClone(e));
|
|
1695
1809
|
const oldAnnotations = Array.from(this.annotations.values()).map((annotation) => structuredClone(annotation));
|
|
1696
1810
|
const oldPins = Array.from(this._contextPinnedNodeIds);
|
|
1811
|
+
const oldAxState = this.getAxState();
|
|
1697
1812
|
const oldViewport = { ...this._viewport };
|
|
1698
1813
|
this.nodes.clear();
|
|
1699
1814
|
this.edges.clear();
|
|
1700
1815
|
this.annotations.clear();
|
|
1701
1816
|
this._contextPinnedNodeIds.clear();
|
|
1817
|
+
this._axState = createEmptyAxState();
|
|
1702
1818
|
this._viewport = { x: 0, y: 0, scale: 1 };
|
|
1703
1819
|
this.scheduleSave();
|
|
1704
1820
|
this.notifyChange('nodes');
|
|
1705
1821
|
this.notifyChange('pins');
|
|
1822
|
+
this.notifyChange('ax');
|
|
1706
1823
|
this.recordMutation({
|
|
1707
1824
|
operationType: 'clear',
|
|
1708
1825
|
description: `Cleared canvas (was ${oldNodes.length} nodes, ${oldEdges.length} edges)`,
|
|
@@ -1712,7 +1829,9 @@ class CanvasStateManager {
|
|
|
1712
1829
|
for (const e of oldEdges) this.addEdge(structuredClone(e));
|
|
1713
1830
|
for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
|
|
1714
1831
|
this.setContextPins(oldPins);
|
|
1832
|
+
this.applyAxState(oldAxState);
|
|
1715
1833
|
this.setViewport(oldViewport);
|
|
1834
|
+
this.notifyChange('ax');
|
|
1716
1835
|
}),
|
|
1717
1836
|
});
|
|
1718
1837
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import { canvasState, IMAGE_MIME_MAP } from './canvas-state.js';
|
|
3
3
|
import type { CanvasAnnotation, CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from './canvas-state.js';
|
|
4
|
+
import { buildCanvasAxContext } from './ax-context.js';
|
|
5
|
+
import type { PmxAxContext, PmxAxFocusState, PmxAxSource, PmxAxState } from './ax-state.js';
|
|
4
6
|
import { findCanvasExtAppNodeId } from './ext-app-lookup.js';
|
|
5
7
|
import { onFileNodeChanged } from './file-watcher.js';
|
|
6
8
|
import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
addCanvasNode,
|
|
12
14
|
addCanvasEdge,
|
|
13
15
|
MARKDOWN_NODE_DEFAULT_SIZE,
|
|
16
|
+
MCP_APP_NODE_DEFAULT_SIZE,
|
|
14
17
|
applyCanvasNodeUpdates,
|
|
15
18
|
arrangeCanvasNodes,
|
|
16
19
|
clearCanvas,
|
|
@@ -181,8 +184,16 @@ export class PmxCanvas extends EventEmitter {
|
|
|
181
184
|
}
|
|
182
185
|
const { id, needsCodeGraphRecompute } = addCanvasNode({
|
|
183
186
|
...input,
|
|
184
|
-
defaultWidth: input.type === 'markdown'
|
|
185
|
-
|
|
187
|
+
defaultWidth: input.type === 'markdown'
|
|
188
|
+
? MARKDOWN_NODE_DEFAULT_SIZE.width
|
|
189
|
+
: input.type === 'mcp-app'
|
|
190
|
+
? MCP_APP_NODE_DEFAULT_SIZE.width
|
|
191
|
+
: 360,
|
|
192
|
+
defaultHeight: input.type === 'markdown'
|
|
193
|
+
? MARKDOWN_NODE_DEFAULT_SIZE.height
|
|
194
|
+
: input.type === 'mcp-app'
|
|
195
|
+
? MCP_APP_NODE_DEFAULT_SIZE.height
|
|
196
|
+
: 200,
|
|
186
197
|
fileMode: 'path',
|
|
187
198
|
...(input.strictSize ? { strictSize: true } : {}),
|
|
188
199
|
});
|
|
@@ -402,7 +413,9 @@ export class PmxCanvas extends EventEmitter {
|
|
|
402
413
|
y: node.position.y - 100,
|
|
403
414
|
});
|
|
404
415
|
}
|
|
416
|
+
const focus = canvasState.setAxFocus([id], { source: 'sdk', recordHistory: false });
|
|
405
417
|
emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId: id, noPan });
|
|
418
|
+
emitPrimaryWorkbenchEvent('ax-state-changed', { focus });
|
|
406
419
|
if (!noPan) {
|
|
407
420
|
emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
408
421
|
}
|
|
@@ -410,6 +423,20 @@ export class PmxCanvas extends EventEmitter {
|
|
|
410
423
|
return { focused: id, panned: !noPan };
|
|
411
424
|
}
|
|
412
425
|
|
|
426
|
+
getAxState(): PmxAxState {
|
|
427
|
+
return canvasState.getAxState();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
getAxContext(): PmxAxContext {
|
|
431
|
+
return buildCanvasAxContext();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): PmxAxFocusState {
|
|
435
|
+
const focus = canvasState.setAxFocus(nodeIds, { source: options?.source ?? 'sdk' });
|
|
436
|
+
emitPrimaryWorkbenchEvent('ax-state-changed', { focus });
|
|
437
|
+
return focus;
|
|
438
|
+
}
|
|
439
|
+
|
|
413
440
|
fitView(options?: {
|
|
414
441
|
width?: number;
|
|
415
442
|
height?: number;
|
|
@@ -28,6 +28,7 @@ export type MutationOp =
|
|
|
28
28
|
| 'arrange'
|
|
29
29
|
| 'restoreSnapshot'
|
|
30
30
|
| 'setPins'
|
|
31
|
+
| 'setAxFocus'
|
|
31
32
|
| 'batch'
|
|
32
33
|
| 'viewport'
|
|
33
34
|
| 'groupNodes'
|
|
@@ -65,6 +66,13 @@ export interface SnapshotDiffResult {
|
|
|
65
66
|
removedEdges: { id: string; from: string; to: string; type: string }[];
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function comparableNodeData(data: Record<string, unknown>): Record<string, unknown> {
|
|
70
|
+
const comparable = { ...data };
|
|
71
|
+
delete comparable.title;
|
|
72
|
+
delete comparable.content;
|
|
73
|
+
return comparable;
|
|
74
|
+
}
|
|
75
|
+
|
|
68
76
|
// ── Ring Buffer ──────────────────────────────────────────────────────
|
|
69
77
|
|
|
70
78
|
const MAX_ENTRIES = 200;
|
|
@@ -257,6 +265,10 @@ export function diffLayouts(
|
|
|
257
265
|
changes.push(`content changed (${lenDiff >= 0 ? '+' : ''}${lenDiff} chars)`);
|
|
258
266
|
}
|
|
259
267
|
|
|
268
|
+
if (JSON.stringify(comparableNodeData(snapNode.data)) !== JSON.stringify(comparableNodeData(curNode.data))) {
|
|
269
|
+
changes.push('data changed');
|
|
270
|
+
}
|
|
271
|
+
|
|
260
272
|
if (changes.length > 0) {
|
|
261
273
|
modifiedNodes.push({
|
|
262
274
|
id,
|