pmx-canvas 0.1.16 → 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 +65 -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/state/canvas-store.d.ts +16 -1
- 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 +2 -1
- package/src/client/canvas/use-pan-zoom.ts +10 -5
- package/src/client/icons.tsx +22 -0
- package/src/client/state/canvas-store.ts +52 -2
- package/src/client/state/sse-bridge.ts +35 -1
- 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 +77 -2
- package/src/server/spatial-analysis.ts +46 -1
- package/src/shared/semantic-attention.ts +4 -2
package/src/mcp/canvas-access.ts
CHANGED
|
@@ -106,6 +106,7 @@ export interface CanvasAccess {
|
|
|
106
106
|
buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult>;
|
|
107
107
|
updateNode(id: string, patch: UpdateNodePatch): Promise<void>;
|
|
108
108
|
removeNode(id: string): Promise<void>;
|
|
109
|
+
removeAnnotation(id: string): Promise<boolean>;
|
|
109
110
|
addEdge(input: AddEdgeInput): Promise<string>;
|
|
110
111
|
removeEdge(id: string): Promise<void>;
|
|
111
112
|
createGroup(input: CreateGroupInput): Promise<string>;
|
|
@@ -203,6 +204,10 @@ class LocalCanvasAccess implements CanvasAccess {
|
|
|
203
204
|
this.canvas.removeNode(id);
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
async removeAnnotation(id: string): Promise<boolean> {
|
|
208
|
+
return this.canvas.removeAnnotation(id);
|
|
209
|
+
}
|
|
210
|
+
|
|
206
211
|
async addEdge(input: AddEdgeInput): Promise<string> {
|
|
207
212
|
return this.canvas.addEdge(input);
|
|
208
213
|
}
|
|
@@ -455,6 +460,11 @@ class RemoteCanvasAccess implements CanvasAccess {
|
|
|
455
460
|
await this.requestJson<unknown>('DELETE', `/api/canvas/node/${encodeURIComponent(id)}`);
|
|
456
461
|
}
|
|
457
462
|
|
|
463
|
+
async removeAnnotation(id: string): Promise<boolean> {
|
|
464
|
+
const response = await this.requestJson<{ ok?: boolean }>('DELETE', `/api/canvas/annotation/${encodeURIComponent(id)}`);
|
|
465
|
+
return response.ok === true;
|
|
466
|
+
}
|
|
467
|
+
|
|
458
468
|
async addEdge(input: AddEdgeInput): Promise<string> {
|
|
459
469
|
const response = await this.requestJson<{ id?: string }>('POST', '/api/canvas/edge', input);
|
|
460
470
|
if (!response.id) throw new Error('Canvas edge response did not include an edge id.');
|
package/src/mcp/server.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { createCanvasAccess, refreshCanvasAccess, type CanvasAccess } from './ca
|
|
|
29
29
|
import { serializeNodeForAgentContext } from '../server/agent-context.js';
|
|
30
30
|
import { wrapCanvasAutomationScript } from '../server/server.js';
|
|
31
31
|
import { buildSpatialContext, findNeighborhoods } from '../server/spatial-analysis.js';
|
|
32
|
-
import { getCanvasNodeTitle, serializeCanvasLayout, serializeCanvasNode } from '../server/canvas-serialization.js';
|
|
32
|
+
import { getCanvasNodeTitle, serializeCanvasLayout, serializeCanvasNode, summarizeCanvasAnnotationForContext } from '../server/canvas-serialization.js';
|
|
33
33
|
import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js';
|
|
34
34
|
|
|
35
35
|
let canvas: CanvasAccess | null = null;
|
|
@@ -191,6 +191,7 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
|
|
|
191
191
|
return {
|
|
192
192
|
summary: buildSummaryFromLayout(layout, pinnedIds),
|
|
193
193
|
viewport: layout.viewport,
|
|
194
|
+
annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
194
195
|
nodes: layout.nodes.map((node) => compactNodePayload(node)).filter((node): node is Record<string, unknown> => node !== null),
|
|
195
196
|
edges: layout.edges.map((edge) => ({
|
|
196
197
|
id: edge.id,
|
|
@@ -204,6 +205,13 @@ function compactLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout
|
|
|
204
205
|
};
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
function agentSafeFullLayoutPayload(layout: Awaited<ReturnType<CanvasAccess['getLayout']>>): Record<string, unknown> {
|
|
209
|
+
return {
|
|
210
|
+
...serializeCanvasLayout(layout),
|
|
211
|
+
annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
207
215
|
function compactBatchValue(value: unknown): unknown {
|
|
208
216
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
209
217
|
const record = value as Record<string, unknown>;
|
|
@@ -248,6 +256,8 @@ function buildSummaryFromLayout(layout: Awaited<ReturnType<CanvasAccess['getLayo
|
|
|
248
256
|
return {
|
|
249
257
|
totalNodes: layout.nodes.length,
|
|
250
258
|
totalEdges: layout.edges.length,
|
|
259
|
+
totalAnnotations: (layout.annotations ?? []).length,
|
|
260
|
+
annotations: (layout.annotations ?? []).map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
251
261
|
nodesByType,
|
|
252
262
|
pinnedCount: pinned.size,
|
|
253
263
|
pinnedTitles,
|
|
@@ -263,6 +273,7 @@ function buildSnapshotRestoreSummary(layout: Awaited<ReturnType<CanvasAccess['ge
|
|
|
263
273
|
return {
|
|
264
274
|
nodeCount: layout.nodes.length,
|
|
265
275
|
edgeCount: layout.edges.length,
|
|
276
|
+
annotationCount: (layout.annotations ?? []).length,
|
|
266
277
|
nodesByType,
|
|
267
278
|
viewport: layout.viewport,
|
|
268
279
|
};
|
|
@@ -287,7 +298,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
287
298
|
const c = await ensureCanvas();
|
|
288
299
|
const layout = await c.getLayout();
|
|
289
300
|
const payload = wantsFullPayload(input)
|
|
290
|
-
?
|
|
301
|
+
? agentSafeFullLayoutPayload(layout)
|
|
291
302
|
: compactLayoutPayload(layout, await c.getPinnedNodeIds());
|
|
292
303
|
return {
|
|
293
304
|
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
@@ -902,6 +913,26 @@ export async function startMcpServer(): Promise<void> {
|
|
|
902
913
|
},
|
|
903
914
|
);
|
|
904
915
|
|
|
916
|
+
// ── canvas_remove_annotation ─────────────────────────────────────
|
|
917
|
+
server.tool(
|
|
918
|
+
'canvas_remove_annotation',
|
|
919
|
+
'Remove a human-drawn canvas annotation by ID.',
|
|
920
|
+
{ id: z.string().describe('Annotation ID to remove') },
|
|
921
|
+
async ({ id }) => {
|
|
922
|
+
const c = await ensureCanvas();
|
|
923
|
+
const removed = await c.removeAnnotation(id);
|
|
924
|
+
if (!removed) {
|
|
925
|
+
return {
|
|
926
|
+
content: [{ type: 'text', text: `Annotation "${id}" not found.` }],
|
|
927
|
+
isError: true,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
return {
|
|
931
|
+
content: [{ type: 'text', text: JSON.stringify({ ok: true, removed: id }) }],
|
|
932
|
+
};
|
|
933
|
+
},
|
|
934
|
+
);
|
|
935
|
+
|
|
905
936
|
// ── canvas_add_edge ────────────────────────────────────────────
|
|
906
937
|
server.tool(
|
|
907
938
|
'canvas_add_edge',
|
|
@@ -1401,7 +1432,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1401
1432
|
},
|
|
1402
1433
|
async () => {
|
|
1403
1434
|
const c = await ensureCanvas();
|
|
1404
|
-
const layout =
|
|
1435
|
+
const layout = agentSafeFullLayoutPayload(await c.getLayout());
|
|
1405
1436
|
return {
|
|
1406
1437
|
contents: [
|
|
1407
1438
|
{
|
|
@@ -1452,7 +1483,7 @@ export async function startMcpServer(): Promise<void> {
|
|
|
1452
1483
|
async () => {
|
|
1453
1484
|
const c = await ensureCanvas();
|
|
1454
1485
|
const layout = await c.getLayout();
|
|
1455
|
-
const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()));
|
|
1486
|
+
const spatial = buildSpatialContext(layout.nodes, layout.edges, new Set(await c.getPinnedNodeIds()), layout.annotations ?? []);
|
|
1456
1487
|
return {
|
|
1457
1488
|
contents: [
|
|
1458
1489
|
{
|
|
@@ -223,6 +223,31 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
223
223
|
'Webpage nodes persist `data.provenance` with the source URL and refresh strategy so reopened snapshots can be re-fetched.',
|
|
224
224
|
],
|
|
225
225
|
},
|
|
226
|
+
{
|
|
227
|
+
type: 'html',
|
|
228
|
+
kind: 'node',
|
|
229
|
+
description: 'Sandboxed iframe node rendered from inline HTML.',
|
|
230
|
+
endpoint: '/api/canvas/node',
|
|
231
|
+
mcpTool: 'canvas_add_html_node',
|
|
232
|
+
fields: [
|
|
233
|
+
{ name: 'html', type: 'string', required: false, description: 'HTML document or fragment rendered in the sandboxed iframe.', aliases: ['content', 'stdin'] },
|
|
234
|
+
{ name: 'title', type: 'string', required: false, description: 'Optional node title.' },
|
|
235
|
+
{ name: 'x', type: 'number', required: false, description: 'Optional X position.' },
|
|
236
|
+
{ name: 'y', type: 'number', required: false, description: 'Optional Y position.' },
|
|
237
|
+
{ name: 'width', type: 'number', required: false, description: 'Optional node width.' },
|
|
238
|
+
{ name: 'height', type: 'number', required: false, description: 'Optional node height.' },
|
|
239
|
+
{ name: 'strictSize', type: 'boolean', required: false, description: 'Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting.', aliases: ['strict-size', 'scroll-overflow'] },
|
|
240
|
+
],
|
|
241
|
+
example: {
|
|
242
|
+
type: 'html',
|
|
243
|
+
title: 'HTML Widget',
|
|
244
|
+
html: '<main><h1>Hello from PMX Canvas</h1></main>',
|
|
245
|
+
},
|
|
246
|
+
notes: [
|
|
247
|
+
'The CLI accepts --content as an alias and stores it as data.html so the renderer can load it.',
|
|
248
|
+
'HTML runs in a sandboxed iframe without same-origin access to the canvas host.',
|
|
249
|
+
],
|
|
250
|
+
},
|
|
226
251
|
{
|
|
227
252
|
type: 'mcp-app',
|
|
228
253
|
kind: 'node',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { canvasState } from './canvas-state.js';
|
|
2
|
-
import type { CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
|
|
2
|
+
import type { CanvasAnnotation, CanvasLayout, CanvasNodeState, ViewportState } from './canvas-state.js';
|
|
3
3
|
import {
|
|
4
4
|
normalizeCanvasNodeData,
|
|
5
5
|
type CanvasNodeProvenance,
|
|
@@ -19,6 +19,26 @@ export interface SerializedCanvasLayout extends Omit<CanvasLayout, 'nodes'> {
|
|
|
19
19
|
nodes: SerializedCanvasNode[];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export interface CanvasAnnotationSummary {
|
|
23
|
+
id: string;
|
|
24
|
+
type: CanvasAnnotation['type'];
|
|
25
|
+
bounds: CanvasAnnotation['bounds'];
|
|
26
|
+
color: string;
|
|
27
|
+
width: number;
|
|
28
|
+
pointCount: number;
|
|
29
|
+
label: string | null;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CanvasAnnotationContextSummary {
|
|
34
|
+
id: string;
|
|
35
|
+
label: string | null;
|
|
36
|
+
bounds: CanvasAnnotation['bounds'];
|
|
37
|
+
targetNodeIds: string[];
|
|
38
|
+
targetNodeTitles: string[];
|
|
39
|
+
target: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
interface BlobSummary {
|
|
23
43
|
stored: 'sidecar';
|
|
24
44
|
path: string;
|
|
@@ -104,9 +124,55 @@ export function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): Se
|
|
|
104
124
|
};
|
|
105
125
|
}
|
|
106
126
|
|
|
127
|
+
export function summarizeCanvasAnnotation(annotation: CanvasAnnotation): CanvasAnnotationSummary {
|
|
128
|
+
return {
|
|
129
|
+
id: annotation.id,
|
|
130
|
+
type: annotation.type,
|
|
131
|
+
bounds: annotation.bounds,
|
|
132
|
+
color: annotation.color,
|
|
133
|
+
width: annotation.width,
|
|
134
|
+
pointCount: annotation.points.length,
|
|
135
|
+
label: annotation.label ?? null,
|
|
136
|
+
createdAt: annotation.createdAt,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function rectsOverlap(
|
|
141
|
+
a: { x: number; y: number; width: number; height: number },
|
|
142
|
+
b: { x: number; y: number; width: number; height: number },
|
|
143
|
+
): boolean {
|
|
144
|
+
return a.x <= b.x + b.width &&
|
|
145
|
+
a.x + a.width >= b.x &&
|
|
146
|
+
a.y <= b.y + b.height &&
|
|
147
|
+
a.y + a.height >= b.y;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function summarizeCanvasAnnotationForContext(
|
|
151
|
+
annotation: CanvasAnnotation,
|
|
152
|
+
nodes: CanvasNodeState[],
|
|
153
|
+
): CanvasAnnotationContextSummary {
|
|
154
|
+
const targetNodes = nodes.filter((node) => rectsOverlap(annotation.bounds, {
|
|
155
|
+
x: node.position.x,
|
|
156
|
+
y: node.position.y,
|
|
157
|
+
width: node.size.width,
|
|
158
|
+
height: node.size.height,
|
|
159
|
+
}));
|
|
160
|
+
const targetNodeTitles = targetNodes.map((node) => getCanvasNodeTitle(node) ?? node.id);
|
|
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
|
+
|
|
107
171
|
export interface CanvasSummary {
|
|
108
172
|
totalNodes: number;
|
|
109
173
|
totalEdges: number;
|
|
174
|
+
totalAnnotations: number;
|
|
175
|
+
annotations: CanvasAnnotationContextSummary[];
|
|
110
176
|
nodesByType: Record<string, number>;
|
|
111
177
|
pinnedCount: number;
|
|
112
178
|
pinnedTitles: string[];
|
|
@@ -130,6 +196,8 @@ export function buildCanvasSummary(): CanvasSummary {
|
|
|
130
196
|
return {
|
|
131
197
|
totalNodes: layout.nodes.length,
|
|
132
198
|
totalEdges: layout.edges.length,
|
|
199
|
+
totalAnnotations: layout.annotations.length,
|
|
200
|
+
annotations: layout.annotations.map((annotation) => summarizeCanvasAnnotationForContext(annotation, layout.nodes)),
|
|
133
201
|
nodesByType: typeCounts,
|
|
134
202
|
pinnedCount: pinnedIds.size,
|
|
135
203
|
pinnedTitles,
|
|
@@ -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';
|