pmx-canvas 0.1.15 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +124 -0
- package/Readme.md +2 -2
- package/dist/canvas/global.css +25 -0
- package/dist/canvas/index.js +72 -72
- package/dist/types/client/canvas/AnnotationLayer.d.ts +4 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +4 -1
- package/dist/types/client/canvas/use-pan-zoom.d.ts +2 -1
- package/dist/types/client/icons.d.ts +4 -0
- package/dist/types/client/nodes/ContextNode.d.ts +11 -2
- package/dist/types/client/nodes/StatusNode.d.ts +1 -0
- package/dist/types/client/state/canvas-store.d.ts +22 -3
- package/dist/types/client/state/intent-bridge.d.ts +2 -0
- package/dist/types/client/types.d.ts +20 -0
- package/dist/types/mcp/canvas-access.d.ts +1 -0
- package/dist/types/server/canvas-serialization.d.ts +23 -1
- package/dist/types/server/canvas-state.d.ts +27 -1
- package/dist/types/server/index.d.ts +7 -2
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/dist/types/server/spatial-analysis.d.ts +11 -2
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +17 -0
- package/src/cli/agent.ts +6 -0
- package/src/client/App.tsx +60 -3
- package/src/client/canvas/AnnotationLayer.tsx +28 -0
- package/src/client/canvas/CanvasViewport.tsx +169 -10
- package/src/client/canvas/ContextPinBar.tsx +3 -1
- package/src/client/canvas/DockedNode.tsx +4 -3
- package/src/client/canvas/use-pan-zoom.ts +10 -5
- package/src/client/icons.tsx +22 -0
- package/src/client/nodes/ContextNode.tsx +128 -6
- package/src/client/nodes/StatusNode.tsx +16 -1
- package/src/client/nodes/StatusSummary.tsx +2 -1
- package/src/client/state/canvas-store.ts +65 -7
- package/src/client/state/intent-bridge.ts +5 -1
- package/src/client/state/sse-bridge.ts +36 -2
- package/src/client/theme/global.css +25 -0
- package/src/client/types.ts +17 -0
- package/src/mcp/canvas-access.ts +10 -0
- package/src/mcp/server.ts +35 -4
- package/src/server/canvas-schema.ts +25 -0
- package/src/server/canvas-serialization.ts +69 -1
- package/src/server/canvas-state.ts +74 -2
- package/src/server/diagram-presets.ts +54 -19
- package/src/server/index.ts +20 -3
- package/src/server/mutation-history.ts +2 -0
- package/src/server/server.ts +101 -3
- package/src/server/spatial-analysis.ts +46 -1
- package/src/shared/semantic-attention.ts +4 -2
|
@@ -64,6 +64,7 @@ interface PersistedCanvasState {
|
|
|
64
64
|
viewport: ViewportState;
|
|
65
65
|
nodes: CanvasNodeState[];
|
|
66
66
|
edges: CanvasEdge[];
|
|
67
|
+
annotations?: CanvasAnnotation[];
|
|
67
68
|
contextPins: string[];
|
|
68
69
|
}
|
|
69
70
|
|
|
@@ -146,10 +147,27 @@ export interface CanvasEdge {
|
|
|
146
147
|
animated?: boolean;
|
|
147
148
|
}
|
|
148
149
|
|
|
150
|
+
export interface CanvasAnnotationPoint {
|
|
151
|
+
x: number;
|
|
152
|
+
y: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface CanvasAnnotation {
|
|
156
|
+
id: string;
|
|
157
|
+
type: 'freehand';
|
|
158
|
+
points: CanvasAnnotationPoint[];
|
|
159
|
+
bounds: { x: number; y: number; width: number; height: number };
|
|
160
|
+
color: string;
|
|
161
|
+
width: number;
|
|
162
|
+
label?: string;
|
|
163
|
+
createdAt: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
149
166
|
export interface CanvasLayout {
|
|
150
167
|
viewport: ViewportState;
|
|
151
168
|
nodes: CanvasNodeState[];
|
|
152
169
|
edges: CanvasEdge[];
|
|
170
|
+
annotations: CanvasAnnotation[];
|
|
153
171
|
}
|
|
154
172
|
|
|
155
173
|
export interface CanvasNodeUpdate {
|
|
@@ -163,7 +181,7 @@ export interface CanvasNodeUpdate {
|
|
|
163
181
|
export type CanvasChangeType = 'pins' | 'nodes';
|
|
164
182
|
|
|
165
183
|
export interface MutationRecordInfo {
|
|
166
|
-
operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
|
|
184
|
+
operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
|
|
167
185
|
description: string;
|
|
168
186
|
forward: () => void;
|
|
169
187
|
inverse: () => void;
|
|
@@ -215,6 +233,7 @@ function isPersistedBlobRef(value: unknown): value is PersistedBlobRef {
|
|
|
215
233
|
class CanvasStateManager {
|
|
216
234
|
private nodes = new Map<string, CanvasNodeState>();
|
|
217
235
|
private edges = new Map<string, CanvasEdge>();
|
|
236
|
+
private annotations = new Map<string, CanvasAnnotation>();
|
|
218
237
|
private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
|
|
219
238
|
private _contextPinnedNodeIds = new Set<string>();
|
|
220
239
|
private _workspaceRoot = process.cwd();
|
|
@@ -607,6 +626,7 @@ class CanvasStateManager {
|
|
|
607
626
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
608
627
|
nodes: [],
|
|
609
628
|
edges: [],
|
|
629
|
+
annotations: [],
|
|
610
630
|
contextPins: [],
|
|
611
631
|
};
|
|
612
632
|
}
|
|
@@ -663,6 +683,7 @@ class CanvasStateManager {
|
|
|
663
683
|
viewport: this._viewport,
|
|
664
684
|
nodes: Array.from(this.nodes.values()),
|
|
665
685
|
edges: Array.from(this.edges.values()),
|
|
686
|
+
annotations: Array.from(this.annotations.values()),
|
|
666
687
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
667
688
|
});
|
|
668
689
|
writeFileSync(this._stateFilePath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
@@ -683,6 +704,7 @@ class CanvasStateManager {
|
|
|
683
704
|
private applyPersistedState(state: PersistedCanvasState): void {
|
|
684
705
|
this.nodes.clear();
|
|
685
706
|
this.edges.clear();
|
|
707
|
+
this.annotations.clear();
|
|
686
708
|
this._contextPinnedNodeIds.clear();
|
|
687
709
|
|
|
688
710
|
this._viewport = {
|
|
@@ -703,6 +725,11 @@ class CanvasStateManager {
|
|
|
703
725
|
if (edge?.id) this.edges.set(edge.id, structuredClone(edge));
|
|
704
726
|
}
|
|
705
727
|
}
|
|
728
|
+
if (Array.isArray(state.annotations)) {
|
|
729
|
+
for (const annotation of state.annotations) {
|
|
730
|
+
if (annotation?.id) this.annotations.set(annotation.id, structuredClone(annotation));
|
|
731
|
+
}
|
|
732
|
+
}
|
|
706
733
|
if (Array.isArray(state.contextPins)) {
|
|
707
734
|
for (const pinId of state.contextPins) {
|
|
708
735
|
if (this.nodes.has(pinId)) this._contextPinnedNodeIds.add(pinId);
|
|
@@ -798,9 +825,12 @@ class CanvasStateManager {
|
|
|
798
825
|
viewport: this._viewport,
|
|
799
826
|
nodes: Array.from(this.nodes.values()),
|
|
800
827
|
edges: Array.from(this.edges.values()),
|
|
828
|
+
annotations: Array.from(this.annotations.values()),
|
|
801
829
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
802
830
|
});
|
|
803
831
|
writeFileSync(join(dir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
|
|
832
|
+
snapshot.nodeCount = payload.nodes.length;
|
|
833
|
+
snapshot.edgeCount = payload.edges.length;
|
|
804
834
|
return snapshot;
|
|
805
835
|
} catch (error) {
|
|
806
836
|
logCanvasStateWarning('save snapshot failed', error, { id, name });
|
|
@@ -870,6 +900,7 @@ class CanvasStateManager {
|
|
|
870
900
|
viewport: structuredClone(this._viewport),
|
|
871
901
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
|
|
872
902
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
903
|
+
annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
|
|
873
904
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
874
905
|
});
|
|
875
906
|
const nextState: PersistedCanvasState = {
|
|
@@ -877,6 +908,7 @@ class CanvasStateManager {
|
|
|
877
908
|
viewport: structuredClone(resolved.state.viewport),
|
|
878
909
|
nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
|
|
879
910
|
edges: Array.isArray(resolved.state.edges) ? resolved.state.edges.map((edge) => structuredClone(edge)) : [],
|
|
911
|
+
annotations: Array.isArray(resolved.state.annotations) ? resolved.state.annotations.map((annotation) => structuredClone(annotation)) : [],
|
|
880
912
|
contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
|
|
881
913
|
};
|
|
882
914
|
|
|
@@ -913,7 +945,7 @@ class CanvasStateManager {
|
|
|
913
945
|
}
|
|
914
946
|
|
|
915
947
|
/** Read a snapshot's data without restoring it (for diff). Resolves by ID or name. */
|
|
916
|
-
getSnapshotData(idOrName: string): { name: string; nodes: CanvasNodeState[]; edges: CanvasEdge[] } | null {
|
|
948
|
+
getSnapshotData(idOrName: string): { name: string; nodes: CanvasNodeState[]; edges: CanvasEdge[]; annotations: CanvasAnnotation[] } | null {
|
|
917
949
|
const resolved = this.readResolvedSnapshot(idOrName);
|
|
918
950
|
if (!resolved) return null;
|
|
919
951
|
const state = {
|
|
@@ -926,6 +958,7 @@ class CanvasStateManager {
|
|
|
926
958
|
name: resolved.snapshot.name,
|
|
927
959
|
nodes: Array.isArray(state.nodes) ? state.nodes.map((node) => structuredClone(node)) : [],
|
|
928
960
|
edges: Array.isArray(state.edges) ? state.edges.map((edge) => structuredClone(edge)) : [],
|
|
961
|
+
annotations: Array.isArray(state.annotations) ? state.annotations.map((annotation) => structuredClone(annotation)) : [],
|
|
929
962
|
};
|
|
930
963
|
}
|
|
931
964
|
|
|
@@ -1110,6 +1143,40 @@ class CanvasStateManager {
|
|
|
1110
1143
|
.map((edge) => structuredClone(edge));
|
|
1111
1144
|
}
|
|
1112
1145
|
|
|
1146
|
+
addAnnotation(annotation: CanvasAnnotation): void {
|
|
1147
|
+
const cloned = structuredClone(annotation);
|
|
1148
|
+
this.annotations.set(annotation.id, cloned);
|
|
1149
|
+
this.scheduleSave();
|
|
1150
|
+
this.notifyChange('nodes');
|
|
1151
|
+
this.recordMutation({
|
|
1152
|
+
operationType: 'addAnnotation',
|
|
1153
|
+
description: `Added annotation ${annotation.id}`,
|
|
1154
|
+
forward: this.suppressed(() => this.addAnnotation(structuredClone(cloned))),
|
|
1155
|
+
inverse: this.suppressed(() => this.removeAnnotation(annotation.id)),
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
removeAnnotation(id: string): boolean {
|
|
1160
|
+
const existing = this.annotations.get(id);
|
|
1161
|
+
const removed = this.annotations.delete(id);
|
|
1162
|
+
if (removed && existing) {
|
|
1163
|
+
const cloned = structuredClone(existing);
|
|
1164
|
+
this.scheduleSave();
|
|
1165
|
+
this.notifyChange('nodes');
|
|
1166
|
+
this.recordMutation({
|
|
1167
|
+
operationType: 'removeAnnotation',
|
|
1168
|
+
description: `Removed annotation ${id}`,
|
|
1169
|
+
forward: this.suppressed(() => this.removeAnnotation(id)),
|
|
1170
|
+
inverse: this.suppressed(() => this.addAnnotation(structuredClone(cloned))),
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
return removed;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
getAnnotations(): CanvasAnnotation[] {
|
|
1177
|
+
return Array.from(this.annotations.values(), (annotation) => structuredClone(annotation));
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1113
1180
|
private removeEdgesForNode(nodeId: string): void {
|
|
1114
1181
|
for (const [id, edge] of this.edges) {
|
|
1115
1182
|
if (edge.from === nodeId || edge.to === nodeId) {
|
|
@@ -1123,6 +1190,7 @@ class CanvasStateManager {
|
|
|
1123
1190
|
viewport: structuredClone(this._viewport),
|
|
1124
1191
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
|
|
1125
1192
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
1193
|
+
annotations: this.getAnnotations(),
|
|
1126
1194
|
};
|
|
1127
1195
|
}
|
|
1128
1196
|
|
|
@@ -1131,6 +1199,7 @@ class CanvasStateManager {
|
|
|
1131
1199
|
viewport: structuredClone(this._viewport),
|
|
1132
1200
|
nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
|
|
1133
1201
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
1202
|
+
annotations: this.getAnnotations(),
|
|
1134
1203
|
};
|
|
1135
1204
|
}
|
|
1136
1205
|
|
|
@@ -1376,10 +1445,12 @@ class CanvasStateManager {
|
|
|
1376
1445
|
clear(): void {
|
|
1377
1446
|
const oldNodes = Array.from(this.nodes.values()).map((n) => structuredClone(n));
|
|
1378
1447
|
const oldEdges = Array.from(this.edges.values()).map((e) => structuredClone(e));
|
|
1448
|
+
const oldAnnotations = Array.from(this.annotations.values()).map((annotation) => structuredClone(annotation));
|
|
1379
1449
|
const oldPins = Array.from(this._contextPinnedNodeIds);
|
|
1380
1450
|
const oldViewport = { ...this._viewport };
|
|
1381
1451
|
this.nodes.clear();
|
|
1382
1452
|
this.edges.clear();
|
|
1453
|
+
this.annotations.clear();
|
|
1383
1454
|
this._contextPinnedNodeIds.clear();
|
|
1384
1455
|
this._viewport = { x: 0, y: 0, scale: 1 };
|
|
1385
1456
|
this.scheduleSave();
|
|
@@ -1392,6 +1463,7 @@ class CanvasStateManager {
|
|
|
1392
1463
|
inverse: this.suppressed(() => {
|
|
1393
1464
|
for (const n of oldNodes) this.addNode(structuredClone(n));
|
|
1394
1465
|
for (const e of oldEdges) this.addEdge(structuredClone(e));
|
|
1466
|
+
for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
|
|
1395
1467
|
this.setContextPins(oldPins);
|
|
1396
1468
|
this.setViewport(oldViewport);
|
|
1397
1469
|
}),
|
|
@@ -120,18 +120,33 @@ function positiveFiniteNumber(value: unknown): number | null {
|
|
|
120
120
|
return num !== null && num > 0 ? num : null;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
function elementHasCameraUpdate(elements: Array<Record<string, unknown>>): boolean {
|
|
124
|
+
return elements.some((element) => element.type === 'cameraUpdate');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isTextBindableContainer(element: Record<string, unknown>): boolean {
|
|
128
|
+
return element.type === 'rectangle' || element.type === 'ellipse' || element.type === 'diamond';
|
|
129
|
+
}
|
|
130
|
+
|
|
123
131
|
function labelFromBoundText(element: Record<string, unknown>): Record<string, unknown> | null {
|
|
124
132
|
const text = typeof element.text === 'string' ? element.text : '';
|
|
125
133
|
if (text.trim().length === 0) return null;
|
|
126
134
|
const fontSize = positiveFiniteNumber(element.fontSize);
|
|
135
|
+
const textAlign = typeof element.textAlign === 'string' ? element.textAlign : null;
|
|
136
|
+
const verticalAlign = typeof element.verticalAlign === 'string' ? element.verticalAlign : null;
|
|
127
137
|
return {
|
|
128
138
|
text,
|
|
129
139
|
...(fontSize ? { fontSize } : {}),
|
|
140
|
+
...(textAlign ? { textAlign } : {}),
|
|
141
|
+
...(verticalAlign ? { verticalAlign } : {}),
|
|
130
142
|
};
|
|
131
143
|
}
|
|
132
144
|
|
|
133
|
-
function
|
|
134
|
-
|
|
145
|
+
function boundTextRefId(value: unknown): string | null {
|
|
146
|
+
if (!isRecord(value) || value.type !== 'text' || typeof value.id !== 'string' || value.id.length === 0) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return value.id;
|
|
135
150
|
}
|
|
136
151
|
|
|
137
152
|
function hasRenderableExcalidrawElement(elements: Array<Record<string, unknown>>): boolean {
|
|
@@ -153,37 +168,57 @@ function normalizeExcalidrawBoundText(elements: Array<Record<string, unknown>>):
|
|
|
153
168
|
}
|
|
154
169
|
|
|
155
170
|
let changed = false;
|
|
171
|
+
const containerIdByTextId = new Map<string, string>();
|
|
156
172
|
const labelsByContainer = new Map<string, Record<string, unknown>>();
|
|
173
|
+
const collapsedTextIds = new Set<string>();
|
|
174
|
+
|
|
175
|
+
for (const container of elements) {
|
|
176
|
+
if (!isTextBindableContainer(container) || typeof container.id !== 'string' || !Array.isArray(container.boundElements)) continue;
|
|
177
|
+
for (const rawBoundElement of container.boundElements) {
|
|
178
|
+
const textId = boundTextRefId(rawBoundElement);
|
|
179
|
+
if (!textId) continue;
|
|
180
|
+
const textElement = elementsById.get(textId);
|
|
181
|
+
if (textElement?.type !== 'text') continue;
|
|
182
|
+
if (typeof textElement.containerId !== 'string') {
|
|
183
|
+
containerIdByTextId.set(textId, container.id);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
157
187
|
|
|
158
188
|
for (const element of elements) {
|
|
159
|
-
if (element.type !== 'text' || typeof element.id !== 'string'
|
|
160
|
-
const
|
|
161
|
-
|
|
189
|
+
if (element.type !== 'text' || typeof element.id !== 'string') continue;
|
|
190
|
+
const containerId = typeof element.containerId === 'string'
|
|
191
|
+
? element.containerId
|
|
192
|
+
: containerIdByTextId.get(element.id);
|
|
193
|
+
if (!containerId) continue;
|
|
194
|
+
const container = elementsById.get(containerId);
|
|
195
|
+
if (!container || !isTextBindableContainer(container)) continue;
|
|
162
196
|
const label = labelFromBoundText(element);
|
|
163
197
|
if (!label) continue;
|
|
164
|
-
labelsByContainer.set(
|
|
198
|
+
labelsByContainer.set(containerId, label);
|
|
199
|
+
collapsedTextIds.add(element.id);
|
|
165
200
|
}
|
|
166
201
|
|
|
202
|
+
if (labelsByContainer.size === 0) return elements;
|
|
203
|
+
|
|
167
204
|
const normalized: Array<Record<string, unknown>> = [];
|
|
168
205
|
for (const element of elements) {
|
|
169
|
-
if (element.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
206
|
+
if (typeof element.id === 'string' && collapsedTextIds.has(element.id)) {
|
|
207
|
+
changed = true;
|
|
208
|
+
continue;
|
|
174
209
|
}
|
|
175
210
|
|
|
176
|
-
if (typeof element.id
|
|
177
|
-
|
|
211
|
+
if (typeof element.id === 'string' && labelsByContainer.has(element.id)) {
|
|
212
|
+
changed = true;
|
|
213
|
+
const { boundElements: _boundElements, ...container } = element;
|
|
214
|
+
normalized.push({
|
|
215
|
+
...container,
|
|
216
|
+
label: labelsByContainer.get(element.id),
|
|
217
|
+
});
|
|
178
218
|
continue;
|
|
179
219
|
}
|
|
180
220
|
|
|
181
|
-
|
|
182
|
-
const { boundElements: _boundElements, ...container } = element;
|
|
183
|
-
normalized.push({
|
|
184
|
-
...container,
|
|
185
|
-
label: labelsByContainer.get(element.id),
|
|
186
|
-
});
|
|
221
|
+
normalized.push(element);
|
|
187
222
|
}
|
|
188
223
|
|
|
189
224
|
return changed ? normalized : elements;
|
package/src/server/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import { canvasState, IMAGE_MIME_MAP } from './canvas-state.js';
|
|
3
|
-
import type { CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from './canvas-state.js';
|
|
3
|
+
import type { CanvasAnnotation, CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from './canvas-state.js';
|
|
4
4
|
import { findCanvasExtAppNodeId } from './ext-app-lookup.js';
|
|
5
5
|
import { onFileNodeChanged } from './file-watcher.js';
|
|
6
6
|
import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
|
|
@@ -314,6 +314,23 @@ export class PmxCanvas extends EventEmitter {
|
|
|
314
314
|
return id;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
addAnnotation(input: Omit<CanvasAnnotation, 'id' | 'createdAt'> & { id?: string; createdAt?: string }): string {
|
|
318
|
+
const id = input.id ?? `ann-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
319
|
+
canvasState.addAnnotation({
|
|
320
|
+
...input,
|
|
321
|
+
id,
|
|
322
|
+
createdAt: input.createdAt ?? new Date().toISOString(),
|
|
323
|
+
});
|
|
324
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
325
|
+
return id;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
removeAnnotation(id: string): boolean {
|
|
329
|
+
const removed = canvasState.removeAnnotation(id);
|
|
330
|
+
if (removed) emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
331
|
+
return removed;
|
|
332
|
+
}
|
|
333
|
+
|
|
317
334
|
removeEdge(id: string): void {
|
|
318
335
|
removeCanvasEdge(id);
|
|
319
336
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
@@ -416,7 +433,7 @@ export class PmxCanvas extends EventEmitter {
|
|
|
416
433
|
|
|
417
434
|
getSpatialContext() {
|
|
418
435
|
const layout = canvasState.getLayout();
|
|
419
|
-
return buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds);
|
|
436
|
+
return buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds, layout.annotations);
|
|
420
437
|
}
|
|
421
438
|
|
|
422
439
|
async undo(): Promise<{ ok: boolean; description?: string }> {
|
|
@@ -723,7 +740,7 @@ export {
|
|
|
723
740
|
screenshotCanvasAutomationWebView,
|
|
724
741
|
} from './server.js';
|
|
725
742
|
export { canvasState } from './canvas-state.js';
|
|
726
|
-
export type { CanvasSnapshot, CanvasSnapshotGcResult, CanvasSnapshotListOptions } from './canvas-state.js';
|
|
743
|
+
export type { CanvasAnnotation, CanvasSnapshot, CanvasSnapshotGcResult, CanvasSnapshotListOptions } from './canvas-state.js';
|
|
727
744
|
export { findOpenCanvasPosition } from './placement.js';
|
|
728
745
|
export { searchNodes, buildSpatialContext, detectClusters, findNeighborhoods } from './spatial-analysis.js';
|
|
729
746
|
export type { SpatialCluster, SpatialContext, SpatialNeighbor, NodeSpatialInfo } from './spatial-analysis.js';
|
package/src/server/server.ts
CHANGED
|
@@ -46,7 +46,7 @@ import type {
|
|
|
46
46
|
ListResourceTemplatesResult,
|
|
47
47
|
ListToolsResult,
|
|
48
48
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
49
|
-
import { type CanvasEdge, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
|
|
49
|
+
import { type CanvasAnnotation, type CanvasEdge, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
|
|
50
50
|
import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
|
|
51
51
|
import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
|
|
52
52
|
import { getMcpAppHostSnapshot } from './mcp-app-host.js';
|
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
serializeCanvasLayoutWithBlobSummaries,
|
|
72
72
|
serializeCanvasNode,
|
|
73
73
|
serializeCanvasNodeWithBlobSummaries,
|
|
74
|
+
summarizeCanvasAnnotation,
|
|
74
75
|
} from './canvas-serialization.js';
|
|
75
76
|
import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
|
|
76
77
|
import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
|
|
@@ -1216,11 +1217,83 @@ async function handleCanvasViewport(req: Request): Promise<Response> {
|
|
|
1216
1217
|
y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
|
|
1217
1218
|
scale: typeof body.scale === 'number' ? body.scale : canvasState.viewport.scale,
|
|
1218
1219
|
};
|
|
1219
|
-
|
|
1220
|
+
if (body.recordHistory === false) {
|
|
1221
|
+
canvasState.withSuppressedRecording(() => {
|
|
1222
|
+
canvasState.setViewport(next);
|
|
1223
|
+
});
|
|
1224
|
+
} else {
|
|
1225
|
+
canvasState.setViewport(next);
|
|
1226
|
+
}
|
|
1220
1227
|
emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
1221
1228
|
return responseJson({ ok: true });
|
|
1222
1229
|
}
|
|
1223
1230
|
|
|
1231
|
+
function annotationBounds(points: CanvasAnnotation['points']): CanvasAnnotation['bounds'] {
|
|
1232
|
+
const xs = points.map((point) => point.x);
|
|
1233
|
+
const ys = points.map((point) => point.y);
|
|
1234
|
+
const minX = Math.min(...xs);
|
|
1235
|
+
const minY = Math.min(...ys);
|
|
1236
|
+
const maxX = Math.max(...xs);
|
|
1237
|
+
const maxY = Math.max(...ys);
|
|
1238
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function parseAnnotationPoints(value: unknown): CanvasAnnotation['points'] {
|
|
1242
|
+
if (!Array.isArray(value)) return [];
|
|
1243
|
+
return value
|
|
1244
|
+
.map((point) => {
|
|
1245
|
+
if (!point || typeof point !== 'object' || Array.isArray(point)) return null;
|
|
1246
|
+
const record = point as Record<string, unknown>;
|
|
1247
|
+
if (typeof record.x !== 'number' || typeof record.y !== 'number') return null;
|
|
1248
|
+
if (!Number.isFinite(record.x) || !Number.isFinite(record.y)) return null;
|
|
1249
|
+
return { x: record.x, y: record.y };
|
|
1250
|
+
})
|
|
1251
|
+
.filter((point): point is CanvasAnnotation['points'][number] => point !== null);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function handleCanvasAddAnnotation(req: Request): Promise<Response> {
|
|
1255
|
+
const body = await readJson(req);
|
|
1256
|
+
const points = parseAnnotationPoints(body.points);
|
|
1257
|
+
if (points.length < 2) {
|
|
1258
|
+
return responseJson({ ok: false, error: 'Annotation requires at least two valid points.' }, 400);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const width = typeof body.width === 'number' && Number.isFinite(body.width)
|
|
1262
|
+
? Math.min(24, Math.max(1, body.width))
|
|
1263
|
+
: 4;
|
|
1264
|
+
const color = typeof body.color === 'string' && (body.color === 'currentColor' || /^#[0-9a-fA-F]{6}$/.test(body.color))
|
|
1265
|
+
? body.color
|
|
1266
|
+
: 'currentColor';
|
|
1267
|
+
const label = typeof body.label === 'string' && body.label.trim().length > 0
|
|
1268
|
+
? body.label.trim().slice(0, 160)
|
|
1269
|
+
: undefined;
|
|
1270
|
+
const id = typeof body.id === 'string' && body.id.trim().length > 0
|
|
1271
|
+
? body.id.trim().slice(0, 120)
|
|
1272
|
+
: `ann-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1273
|
+
const annotation: CanvasAnnotation = {
|
|
1274
|
+
id,
|
|
1275
|
+
type: 'freehand',
|
|
1276
|
+
points,
|
|
1277
|
+
bounds: annotationBounds(points),
|
|
1278
|
+
color,
|
|
1279
|
+
width,
|
|
1280
|
+
...(label ? { label } : {}),
|
|
1281
|
+
createdAt: new Date().toISOString(),
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
canvasState.addAnnotation(annotation);
|
|
1285
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1286
|
+
return responseJson({ ok: true, annotation: summarizeCanvasAnnotation(annotation) });
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function handleCanvasRemoveAnnotation(id: string): Response {
|
|
1290
|
+
const decodedId = decodeURIComponent(id);
|
|
1291
|
+
const removed = canvasState.removeAnnotation(decodedId);
|
|
1292
|
+
if (!removed) return responseJson({ ok: false, error: `Annotation "${decodedId}" not found.` }, 404);
|
|
1293
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1294
|
+
return responseJson({ ok: true, removed: decodedId });
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1224
1297
|
// ── Serve image file for image nodes ─────────────────────────
|
|
1225
1298
|
async function handleCanvasImage(pathname: string): Promise<Response> {
|
|
1226
1299
|
const nodeId = pathname.replace('/api/canvas/image/', '');
|
|
@@ -1338,6 +1411,14 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
|
|
|
1338
1411
|
const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
|
|
1339
1412
|
? body.data as Record<string, unknown>
|
|
1340
1413
|
: undefined;
|
|
1414
|
+
if (type === 'html') {
|
|
1415
|
+
if ('html' in body && typeof body.html !== 'string') {
|
|
1416
|
+
return responseJson({ ok: false, error: 'HTML node field "html" must be a string.' }, 400);
|
|
1417
|
+
}
|
|
1418
|
+
if (extraData && 'html' in extraData && typeof extraData.html !== 'string') {
|
|
1419
|
+
return responseJson({ ok: false, error: 'HTML node field "data.html" must be a string.' }, 400);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1341
1422
|
const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
|
|
1342
1423
|
? body.path
|
|
1343
1424
|
: body.content;
|
|
@@ -1395,6 +1476,15 @@ async function handleCanvasCreateGroup(req: Request): Promise<Response> {
|
|
|
1395
1476
|
body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
|
|
1396
1477
|
? body.childLayout
|
|
1397
1478
|
: undefined;
|
|
1479
|
+
if (childIds.length > 0) {
|
|
1480
|
+
const missingChildIds = childIds.filter((id) => !canvasState.getNode(id));
|
|
1481
|
+
if (missingChildIds.length > 0) {
|
|
1482
|
+
return responseJson({
|
|
1483
|
+
ok: false,
|
|
1484
|
+
error: `Cannot create group: missing child node ID${missingChildIds.length === 1 ? '' : 's'}: ${missingChildIds.join(', ')}.`,
|
|
1485
|
+
}, 400);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1398
1488
|
|
|
1399
1489
|
const { node } = createCanvasGroup({ title, childIds, color, x, y, width, height, ...(childLayout ? { childLayout } : {}) });
|
|
1400
1490
|
|
|
@@ -3979,6 +4069,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3979
4069
|
return handleCanvasViewport(req);
|
|
3980
4070
|
}
|
|
3981
4071
|
|
|
4072
|
+
if (url.pathname === '/api/canvas/annotation' && req.method === 'POST') {
|
|
4073
|
+
return handleCanvasAddAnnotation(req);
|
|
4074
|
+
}
|
|
4075
|
+
|
|
4076
|
+
if (url.pathname.startsWith('/api/canvas/annotation/') && req.method === 'DELETE') {
|
|
4077
|
+
return handleCanvasRemoveAnnotation(url.pathname.slice('/api/canvas/annotation/'.length));
|
|
4078
|
+
}
|
|
4079
|
+
|
|
3982
4080
|
if (url.pathname === '/api/canvas/node' && req.method === 'POST') {
|
|
3983
4081
|
return handleCanvasAddNode(req);
|
|
3984
4082
|
}
|
|
@@ -4094,7 +4192,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4094
4192
|
// Spatial context API
|
|
4095
4193
|
if (url.pathname === '/api/canvas/spatial-context' && req.method === 'GET') {
|
|
4096
4194
|
const layout = canvasState.getLayout();
|
|
4097
|
-
const spatial = buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds);
|
|
4195
|
+
const spatial = buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds, layout.annotations);
|
|
4098
4196
|
return responseJson(spatial);
|
|
4099
4197
|
}
|
|
4100
4198
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* semantic clusters, ordered context, and implicit human intent.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { CanvasNodeState, CanvasEdge } from './canvas-state.js';
|
|
13
|
+
import type { CanvasAnnotation, CanvasNodeState, CanvasEdge } from './canvas-state.js';
|
|
14
14
|
import { summarizeNodeForAgentContext } from './agent-context.js';
|
|
15
15
|
|
|
16
16
|
// ── Types ────────────────────────────────────────────────────────────
|
|
@@ -45,6 +45,15 @@ export interface NodeSpatialInfo {
|
|
|
45
45
|
readingOrder: number;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export interface SpatialAnnotationContext {
|
|
49
|
+
id: string;
|
|
50
|
+
label: string | null;
|
|
51
|
+
bounds: CanvasAnnotation['bounds'];
|
|
52
|
+
targetNodeIds: string[];
|
|
53
|
+
targetNodeTitles: string[];
|
|
54
|
+
target: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
export interface SpatialContext {
|
|
49
58
|
/** Total nodes on canvas */
|
|
50
59
|
totalNodes: number;
|
|
@@ -58,6 +67,7 @@ export interface SpatialContext {
|
|
|
58
67
|
pinnedNodeTitle: string | null;
|
|
59
68
|
neighbors: SpatialNeighbor[];
|
|
60
69
|
}[];
|
|
70
|
+
annotations: SpatialAnnotationContext[];
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
@@ -125,6 +135,39 @@ function deriveClusterLabel(nodes: CanvasNodeState[]): string {
|
|
|
125
135
|
return parts.join(', ');
|
|
126
136
|
}
|
|
127
137
|
|
|
138
|
+
function rectsOverlap(
|
|
139
|
+
a: { x: number; y: number; width: number; height: number },
|
|
140
|
+
b: { x: number; y: number; width: number; height: number },
|
|
141
|
+
): boolean {
|
|
142
|
+
return a.x <= b.x + b.width &&
|
|
143
|
+
a.x + a.width >= b.x &&
|
|
144
|
+
a.y <= b.y + b.height &&
|
|
145
|
+
a.y + a.height >= b.y;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function summarizeAnnotationForSpatialContext(
|
|
149
|
+
annotation: CanvasAnnotation,
|
|
150
|
+
nodes: CanvasNodeState[],
|
|
151
|
+
): SpatialAnnotationContext {
|
|
152
|
+
const targetNodes = nodes.filter((node) => rectsOverlap(annotation.bounds, {
|
|
153
|
+
x: node.position.x,
|
|
154
|
+
y: node.position.y,
|
|
155
|
+
width: node.size.width,
|
|
156
|
+
height: node.size.height,
|
|
157
|
+
}));
|
|
158
|
+
const targetNodeTitles = targetNodes.map((node) =>
|
|
159
|
+
typeof node.data.title === 'string' && node.data.title.length > 0 ? node.data.title : node.id,
|
|
160
|
+
);
|
|
161
|
+
return {
|
|
162
|
+
id: annotation.id,
|
|
163
|
+
label: annotation.label ?? null,
|
|
164
|
+
bounds: annotation.bounds,
|
|
165
|
+
targetNodeIds: targetNodes.map((node) => node.id),
|
|
166
|
+
targetNodeTitles,
|
|
167
|
+
target: targetNodeTitles.length > 0 ? targetNodeTitles.join(', ') : 'empty canvas region',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
128
171
|
// ── Core Analysis ────────────────────────────────────────────────────
|
|
129
172
|
|
|
130
173
|
/**
|
|
@@ -320,6 +363,7 @@ export function buildSpatialContext(
|
|
|
320
363
|
nodes: CanvasNodeState[],
|
|
321
364
|
_edges: CanvasEdge[],
|
|
322
365
|
pinnedIds: Set<string>,
|
|
366
|
+
annotations: CanvasAnnotation[] = [],
|
|
323
367
|
): SpatialContext {
|
|
324
368
|
const clusters = detectClusters(nodes);
|
|
325
369
|
|
|
@@ -352,5 +396,6 @@ export function buildSpatialContext(
|
|
|
352
396
|
clusters,
|
|
353
397
|
nodesInReadingOrder,
|
|
354
398
|
pinnedNeighborhoods,
|
|
399
|
+
annotations: annotations.map((annotation) => summarizeAnnotationForSpatialContext(annotation, nodes)),
|
|
355
400
|
};
|
|
356
401
|
}
|
|
@@ -371,6 +371,7 @@ export class SemanticWatchReducer {
|
|
|
371
371
|
this.currentLayout.nodes,
|
|
372
372
|
this.currentLayout.edges,
|
|
373
373
|
this.currentPins,
|
|
374
|
+
this.currentLayout.annotations ?? [],
|
|
374
375
|
);
|
|
375
376
|
|
|
376
377
|
if (previousEventPins.added.length === 0 && previousEventPins.removed.length === 0) {
|
|
@@ -394,7 +395,7 @@ export class SemanticWatchReducer {
|
|
|
394
395
|
const meta = normalizeEventMeta(payload);
|
|
395
396
|
if (!this.currentLayout) {
|
|
396
397
|
this.currentLayout = layout;
|
|
397
|
-
this.previousSpatial = buildSpatialContext(layout.nodes, layout.edges, this.currentPins);
|
|
398
|
+
this.previousSpatial = buildSpatialContext(layout.nodes, layout.edges, this.currentPins, layout.annotations ?? []);
|
|
398
399
|
return [];
|
|
399
400
|
}
|
|
400
401
|
|
|
@@ -403,8 +404,9 @@ export class SemanticWatchReducer {
|
|
|
403
404
|
prevLayout.nodes,
|
|
404
405
|
prevLayout.edges,
|
|
405
406
|
this.currentPins,
|
|
407
|
+
prevLayout.annotations ?? [],
|
|
406
408
|
);
|
|
407
|
-
const nextSpatial = buildSpatialContext(layout.nodes, layout.edges, this.currentPins);
|
|
409
|
+
const nextSpatial = buildSpatialContext(layout.nodes, layout.edges, this.currentPins, layout.annotations ?? []);
|
|
408
410
|
const events: SemanticWatchEvent[] = [];
|
|
409
411
|
|
|
410
412
|
const prevNodeMap = toNodeMap(prevLayout.nodes);
|