pmx-canvas 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,8 @@ import {
16
16
  createCanvasGraphNode,
17
17
  createCanvasGroup,
18
18
  createCanvasJsonRenderNode,
19
+ buildStructuredNodeUpdate,
20
+ fitCanvasView,
19
21
  deleteCanvasSnapshot,
20
22
  executeCanvasBatch,
21
23
  groupCanvasNodes,
@@ -30,6 +32,7 @@ import {
30
32
  setCanvasContextPins,
31
33
  ungroupCanvasNodes,
32
34
  validateCanvasNodePatch,
35
+ hasStructuredNodeUpdateFields,
33
36
  } from './canvas-operations.js';
34
37
  import { validateCanvasLayout } from './canvas-validation.js';
35
38
  import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
@@ -51,8 +54,6 @@ import {
51
54
  } from './diagram-presets.js';
52
55
  import {
53
56
  buildGraphSpec,
54
- buildJsonRenderViewerHtml,
55
- createJsonRenderNodeData,
56
57
  GRAPH_NODE_SIZE,
57
58
  JSON_RENDER_NODE_SIZE,
58
59
  normalizeAndValidateJsonRenderSpec,
@@ -221,15 +222,41 @@ export class PmxCanvas extends EventEmitter {
221
222
  return result;
222
223
  }
223
224
 
224
- updateNode(id: string, patch: Partial<CanvasNodeState>): void {
225
+ updateNode(id: string, patch: Partial<CanvasNodeState> & Record<string, unknown>): void {
226
+ const existing = canvasState.getNode(id);
227
+ if (!existing) return;
228
+ const resolvedPatch: Partial<CanvasNodeState> = {};
229
+ if (patch.position) resolvedPatch.position = patch.position;
230
+ if (patch.size) resolvedPatch.size = patch.size;
231
+ if (patch.collapsed !== undefined) resolvedPatch.collapsed = patch.collapsed;
232
+ if (patch.pinned !== undefined) resolvedPatch.pinned = patch.pinned;
233
+ if (patch.dockPosition !== undefined) resolvedPatch.dockPosition = patch.dockPosition;
234
+
235
+ if (hasStructuredNodeUpdateFields(patch)) {
236
+ resolvedPatch.data = buildStructuredNodeUpdate(existing, patch).data;
237
+ } else if (
238
+ patch.data !== undefined ||
239
+ patch.title !== undefined ||
240
+ patch.content !== undefined ||
241
+ typeof patch.arrangeLocked === 'boolean'
242
+ ) {
243
+ resolvedPatch.data = {
244
+ ...existing.data,
245
+ ...(patch.data && typeof patch.data === 'object' && !Array.isArray(patch.data) ? patch.data : {}),
246
+ ...(typeof patch.title === 'string' ? { title: patch.title } : {}),
247
+ ...(typeof patch.content === 'string' ? { content: patch.content } : {}),
248
+ ...(typeof patch.arrangeLocked === 'boolean' ? { arrangeLocked: patch.arrangeLocked } : {}),
249
+ };
250
+ }
251
+
225
252
  const error = validateCanvasNodePatch({
226
- ...(patch.position ? { position: patch.position } : {}),
227
- ...(patch.size ? { size: patch.size } : {}),
253
+ ...(resolvedPatch.position ? { position: resolvedPatch.position } : {}),
254
+ ...(resolvedPatch.size ? { size: resolvedPatch.size } : {}),
228
255
  });
229
256
  if (error) {
230
257
  throw new Error(error);
231
258
  }
232
- canvasState.updateNode(id, patch);
259
+ canvasState.updateNode(id, resolvedPatch);
233
260
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
234
261
  }
235
262
 
@@ -344,6 +371,18 @@ export class PmxCanvas extends EventEmitter {
344
371
  return { focused: id, panned: !noPan };
345
372
  }
346
373
 
374
+ fitView(options?: {
375
+ width?: number;
376
+ height?: number;
377
+ padding?: number;
378
+ maxScale?: number;
379
+ nodeIds?: string[];
380
+ }): ReturnType<typeof fitCanvasView> {
381
+ const result = fitCanvasView(options);
382
+ emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: result.viewport });
383
+ return result;
384
+ }
385
+
347
386
  getLayout(): CanvasLayout {
348
387
  return canvasState.getLayout();
349
388
  }
@@ -73,6 +73,7 @@ import {
73
73
  addCanvasNode,
74
74
  addCanvasEdge,
75
75
  applyCanvasNodeUpdates,
76
+ buildStructuredNodeUpdate,
76
77
  arrangeCanvasNodes,
77
78
  clearCanvas,
78
79
  createCanvasGraphNode,
@@ -80,6 +81,7 @@ import {
80
81
  createCanvasJsonRenderNode,
81
82
  deleteCanvasSnapshot,
82
83
  executeCanvasBatch,
84
+ fitCanvasView,
83
85
  groupCanvasNodes,
84
86
  listCanvasSnapshots,
85
87
  refreshCanvasWebpageNode,
@@ -93,6 +95,7 @@ import {
93
95
  setCanvasContextPins,
94
96
  ungroupCanvasNodes,
95
97
  validateCanvasNodePatch,
98
+ hasStructuredNodeUpdateFields,
96
99
  } from './canvas-operations.js';
97
100
  import { validateCanvasLayout } from './canvas-validation.js';
98
101
  import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
@@ -109,12 +112,7 @@ import {
109
112
  import { traceManager } from './trace-manager.js';
110
113
  import { buildWebArtifactOnCanvas, resolveWorkspacePath } from './web-artifacts.js';
111
114
  import {
112
- buildGraphSpec,
113
115
  buildJsonRenderViewerHtml,
114
- createJsonRenderNodeData,
115
- GRAPH_NODE_SIZE,
116
- JSON_RENDER_NODE_SIZE,
117
- normalizeAndValidateJsonRenderSpec,
118
116
  } from '../json-render/server.js';
119
117
  import {
120
118
  WEBPAGE_NODE_DEFAULT_SIZE,
@@ -651,6 +649,97 @@ function isRecord(value: unknown): value is Record<string, unknown> {
651
649
  return value !== null && typeof value === 'object' && !Array.isArray(value);
652
650
  }
653
651
 
652
+ function pickFiniteNumber(record: Record<string, unknown>, key: string): number | undefined {
653
+ const value = record[key];
654
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
655
+ }
656
+
657
+ function getRecord(value: unknown): Record<string, unknown> | undefined {
658
+ return isRecord(value) ? value : undefined;
659
+ }
660
+
661
+ function pickPositiveNumber(record: Record<string, unknown>, key: string): number | undefined {
662
+ const value = pickFiniteNumber(record, key);
663
+ return value !== undefined && value > 0 ? value : undefined;
664
+ }
665
+
666
+ function normalizeGeometryInput(body: Record<string, unknown>): {
667
+ x?: number;
668
+ y?: number;
669
+ width?: number;
670
+ height?: number;
671
+ position?: { x?: number; y?: number };
672
+ size?: { width?: number; height?: number };
673
+ } {
674
+ const position = getRecord(body.position);
675
+ const size = getRecord(body.size);
676
+ return {
677
+ ...(pickFiniteNumber(body, 'x') !== undefined ? { x: pickFiniteNumber(body, 'x') } : {}),
678
+ ...(pickFiniteNumber(body, 'y') !== undefined ? { y: pickFiniteNumber(body, 'y') } : {}),
679
+ ...(pickFiniteNumber(body, 'width') !== undefined ? { width: pickFiniteNumber(body, 'width') } : {}),
680
+ ...(pickFiniteNumber(body, 'height') !== undefined ? { height: pickFiniteNumber(body, 'height') } : {}),
681
+ ...(position ? {
682
+ position: {
683
+ ...(pickFiniteNumber(position, 'x') !== undefined ? { x: pickFiniteNumber(position, 'x') } : {}),
684
+ ...(pickFiniteNumber(position, 'y') !== undefined ? { y: pickFiniteNumber(position, 'y') } : {}),
685
+ },
686
+ } : {}),
687
+ ...(size ? {
688
+ size: {
689
+ ...(pickFiniteNumber(size, 'width') !== undefined ? { width: pickFiniteNumber(size, 'width') } : {}),
690
+ ...(pickFiniteNumber(size, 'height') !== undefined ? { height: pickFiniteNumber(size, 'height') } : {}),
691
+ },
692
+ } : {}),
693
+ };
694
+ }
695
+
696
+ function resolveCreateGeometry(body: Record<string, unknown>): {
697
+ x?: number;
698
+ y?: number;
699
+ width?: number;
700
+ height?: number;
701
+ } {
702
+ const geometry = normalizeGeometryInput(body);
703
+ const x = geometry.x ?? geometry.position?.x;
704
+ const y = geometry.y ?? geometry.position?.y;
705
+ const width = geometry.width ?? geometry.size?.width;
706
+ const height = geometry.height ?? geometry.size?.height;
707
+ return {
708
+ ...(x !== undefined ? { x } : {}),
709
+ ...(y !== undefined ? { y } : {}),
710
+ ...(width !== undefined ? { width } : {}),
711
+ ...(height !== undefined ? { height } : {}),
712
+ };
713
+ }
714
+
715
+ function resolvePatchGeometry(
716
+ body: Record<string, unknown>,
717
+ existing: CanvasNodeState,
718
+ ): {
719
+ position?: { x: number; y: number };
720
+ size?: { width: number; height: number };
721
+ } {
722
+ const geometry = normalizeGeometryInput(body);
723
+ const x = geometry.x ?? geometry.position?.x;
724
+ const y = geometry.y ?? geometry.position?.y;
725
+ const width = geometry.width ?? geometry.size?.width;
726
+ const height = geometry.height ?? geometry.size?.height;
727
+ return {
728
+ ...(x !== undefined || y !== undefined
729
+ ? { position: { x: x ?? existing.position.x, y: y ?? existing.position.y } }
730
+ : {}),
731
+ ...(width !== undefined || height !== undefined
732
+ ? { size: { width: width ?? existing.size.width, height: height ?? existing.size.height } }
733
+ : {}),
734
+ };
735
+ }
736
+
737
+ function parseGraphPayloadData(value: unknown): Array<Record<string, unknown>> | null {
738
+ if (!Array.isArray(value)) return null;
739
+ if (value.some((item) => !isRecord(item))) return null;
740
+ return value as Array<Record<string, unknown>>;
741
+ }
742
+
654
743
  function getExtAppNodeCheckpointId(node: CanvasNodeState): string {
655
744
  const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
656
745
  const storedCheckpointId = appCheckpoint?.id;
@@ -1130,9 +1219,11 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
1130
1219
  const VALID_NODE_TYPES = new Set(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'mcp-app', 'webpage', 'group']);
1131
1220
 
1132
1221
  function buildNodeResponse(node: CanvasNodeState): Record<string, unknown> {
1222
+ const serialized = serializeCanvasNode(node);
1133
1223
  return {
1134
1224
  ok: true,
1135
- ...serializeCanvasNode(node),
1225
+ node: serialized,
1226
+ ...serialized,
1136
1227
  };
1137
1228
  }
1138
1229
 
@@ -1153,15 +1244,15 @@ async function createCanvasWebpageNode(body: Record<string, unknown>): Promise<R
1153
1244
  const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1154
1245
  ? body.data as Record<string, unknown>
1155
1246
  : undefined;
1247
+ const geometry = resolveCreateGeometry(body);
1156
1248
  const { id, node } = addCanvasNode({
1157
1249
  type: 'webpage',
1158
1250
  ...(typeof body.title === 'string' ? { title: body.title } : {}),
1159
1251
  content: normalizedUrl,
1160
1252
  ...(extraData ? { data: extraData } : {}),
1161
- ...(typeof body.x === 'number' ? { x: body.x } : {}),
1162
- ...(typeof body.y === 'number' ? { y: body.y } : {}),
1163
- ...(typeof body.width === 'number' ? { width: body.width } : { width: WEBPAGE_NODE_DEFAULT_SIZE.width }),
1164
- ...(typeof body.height === 'number' ? { height: body.height } : { height: WEBPAGE_NODE_DEFAULT_SIZE.height }),
1253
+ ...geometry,
1254
+ ...(geometry.width === undefined ? { width: WEBPAGE_NODE_DEFAULT_SIZE.width } : {}),
1255
+ ...(geometry.height === undefined ? { height: WEBPAGE_NODE_DEFAULT_SIZE.height } : {}),
1165
1256
  });
1166
1257
 
1167
1258
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
@@ -1210,17 +1301,18 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1210
1301
  const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1211
1302
  ? body.data as Record<string, unknown>
1212
1303
  : undefined;
1304
+ const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
1305
+ ? body.path
1306
+ : body.content;
1213
1307
  let added: ReturnType<typeof addCanvasNode>;
1308
+ const geometry = resolveCreateGeometry(body);
1214
1309
  try {
1215
1310
  added = addCanvasNode({
1216
1311
  type: type as CanvasNodeState['type'],
1217
1312
  ...(typeof body.title === 'string' ? { title: body.title } : {}),
1218
- ...(typeof body.content === 'string' ? { content: body.content } : {}),
1313
+ ...(typeof content === 'string' ? { content } : {}),
1219
1314
  ...(extraData ? { data: extraData } : {}),
1220
- ...(typeof body.x === 'number' ? { x: body.x } : {}),
1221
- ...(typeof body.y === 'number' ? { y: body.y } : {}),
1222
- ...(typeof body.width === 'number' ? { width: body.width } : {}),
1223
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
1315
+ ...geometry,
1224
1316
  defaultWidth: 360,
1225
1317
  defaultHeight: 200,
1226
1318
  fileMode: 'auto',
@@ -1371,15 +1463,19 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
1371
1463
  if (existing.type === 'webpage' && body.refresh === true) {
1372
1464
  return handleCanvasRefreshWebpageNode(nodeId, req);
1373
1465
  }
1374
- const patch: Record<string, unknown> = {};
1375
- if (body.position) patch.position = body.position;
1376
- if (body.size) patch.size = body.size;
1466
+ const patch: Record<string, unknown> = resolvePatchGeometry(body, existing);
1377
1467
  if (body.collapsed !== undefined) patch.collapsed = body.collapsed;
1378
1468
  if (body.pinned !== undefined) patch.pinned = Boolean(body.pinned);
1379
1469
  if (body.dockPosition === null || body.dockPosition === 'left' || body.dockPosition === 'right') {
1380
1470
  patch.dockPosition = body.dockPosition;
1381
1471
  }
1382
- if (body.title !== undefined || body.content !== undefined || body.data || typeof body.arrangeLocked === 'boolean') {
1472
+ if (hasStructuredNodeUpdateFields(body)) {
1473
+ try {
1474
+ patch.data = buildStructuredNodeUpdate(existing, body).data;
1475
+ } catch (error) {
1476
+ return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
1477
+ }
1478
+ } else if (body.title !== undefined || body.content !== undefined || body.data || typeof body.arrangeLocked === 'boolean') {
1383
1479
  const data = { ...existing.data };
1384
1480
  if (body.title !== undefined) {
1385
1481
  data.title = String(body.title);
@@ -1416,7 +1512,8 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
1416
1512
  if (error) return responseJson({ ok: false, error }, 400);
1417
1513
  canvasState.updateNode(nodeId, patch as Partial<CanvasNodeState>);
1418
1514
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1419
- return responseJson({ ok: true, id: nodeId });
1515
+ const updated = canvasState.getNode(nodeId);
1516
+ return responseJson(updated ? buildNodeResponse(updated) : { ok: true, id: nodeId });
1420
1517
  }
1421
1518
 
1422
1519
  // ── Arrange nodes ───────────────────────────────────────────
@@ -1457,6 +1554,22 @@ async function handleCanvasFocus(req: Request): Promise<Response> {
1457
1554
  return responseJson({ ok: true, focused: nodeId, panned: !noPan });
1458
1555
  }
1459
1556
 
1557
+ async function handleCanvasFit(req: Request): Promise<Response> {
1558
+ const body = await readJson(req);
1559
+ const nodeIds = Array.isArray(body.nodeIds)
1560
+ ? body.nodeIds.filter((id): id is string => typeof id === 'string')
1561
+ : undefined;
1562
+ const result = fitCanvasView({
1563
+ ...(typeof body.width === 'number' ? { width: body.width } : {}),
1564
+ ...(typeof body.height === 'number' ? { height: body.height } : {}),
1565
+ ...(typeof body.padding === 'number' ? { padding: body.padding } : {}),
1566
+ ...(typeof body.maxScale === 'number' ? { maxScale: body.maxScale } : {}),
1567
+ ...(nodeIds ? { nodeIds } : {}),
1568
+ });
1569
+ emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: result.viewport });
1570
+ return responseJson(result);
1571
+ }
1572
+
1460
1573
  async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
1461
1574
  const body = await readJson(req);
1462
1575
  const title = typeof body.title === 'string' ? body.title.trim() : '';
@@ -1549,9 +1662,7 @@ async function handleCanvasValidateSpec(req: Request): Promise<Response> {
1549
1662
  }));
1550
1663
  }
1551
1664
 
1552
- const data = Array.isArray(body.data)
1553
- ? body.data.filter((item: unknown) => item && typeof item === 'object') as Array<Record<string, unknown>>
1554
- : null;
1665
+ const data = parseGraphPayloadData(body.data);
1555
1666
  if (!data) {
1556
1667
  return responseJson({ ok: false, error: 'Graph validation requires a data array.' }, 400);
1557
1668
  }
@@ -1603,21 +1714,16 @@ async function handleCanvasAddJsonRender(req: Request): Promise<Response> {
1603
1714
  const title = typeof body.title === 'string' ? body.title.trim() : '';
1604
1715
  const rawSpec =
1605
1716
  body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec) ? body.spec : body;
1606
- if (!title) {
1607
- return responseJson({ ok: false, error: 'Missing required field: title.' }, 400);
1608
- }
1717
+ const geometry = resolveCreateGeometry(body);
1609
1718
 
1610
1719
  try {
1611
1720
  const result = createCanvasJsonRenderNode({
1612
- title,
1721
+ ...(title ? { title } : {}),
1613
1722
  spec: rawSpec,
1614
- ...(typeof body.x === 'number' ? { x: body.x } : {}),
1615
- ...(typeof body.y === 'number' ? { y: body.y } : {}),
1616
- ...(typeof body.width === 'number' ? { width: body.width } : {}),
1617
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
1723
+ ...geometry,
1618
1724
  });
1619
1725
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1620
- return responseJson({ ok: true, ...result, ...serializeCanvasNode(result.node) });
1726
+ return responseJson({ ...buildNodeResponse(result.node), url: result.url, spec: result.spec });
1621
1727
  } catch (error) {
1622
1728
  const message = error instanceof Error ? error.message : String(error);
1623
1729
  return responseJson({ ok: false, error: message }, 400);
@@ -1628,9 +1734,7 @@ async function handleCanvasAddGraph(req: Request): Promise<Response> {
1628
1734
  const body = await readJson(req);
1629
1735
  const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Graph';
1630
1736
  const graphType = typeof body.graphType === 'string' ? body.graphType : typeof body.type === 'string' ? body.type : 'line';
1631
- const data = Array.isArray(body.data)
1632
- ? body.data.filter((item: unknown) => item && typeof item === 'object') as Array<Record<string, unknown>>
1633
- : null;
1737
+ const data = parseGraphPayloadData(body.data);
1634
1738
  if (!data) {
1635
1739
  return responseJson({ ok: false, error: 'Missing required field: data.' }, 400);
1636
1740
  }
@@ -1646,6 +1750,12 @@ async function handleCanvasAddGraph(req: Request): Promise<Response> {
1646
1750
  const series = Array.isArray(body.series)
1647
1751
  ? body.series.filter((s: unknown): s is string => typeof s === 'string')
1648
1752
  : null;
1753
+ const position = getRecord(body.position);
1754
+ const size = getRecord(body.size);
1755
+ const x = pickFiniteNumber(body, 'x') ?? (position ? pickFiniteNumber(position, 'x') : undefined);
1756
+ const y = pickFiniteNumber(body, 'y') ?? (position ? pickFiniteNumber(position, 'y') : undefined);
1757
+ const width = pickPositiveNumber(body, 'width') ?? (size ? pickPositiveNumber(size, 'width') : undefined);
1758
+ const nodeHeight = pickPositiveNumber(body, 'nodeHeight') ?? (size ? pickPositiveNumber(size, 'height') : undefined);
1649
1759
  const result = createCanvasGraphNode({
1650
1760
  title,
1651
1761
  graphType,
@@ -1665,13 +1775,13 @@ async function handleCanvasAddGraph(req: Request): Promise<Response> {
1665
1775
  ...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
1666
1776
  ...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
1667
1777
  ...(typeof body.height === 'number' ? { height: body.height } : {}),
1668
- ...(typeof body.x === 'number' ? { x: body.x } : {}),
1669
- ...(typeof body.y === 'number' ? { y: body.y } : {}),
1670
- ...(typeof body.width === 'number' ? { width: body.width } : {}),
1671
- ...(typeof body.nodeHeight === 'number' ? { heightPx: body.nodeHeight } : {}),
1778
+ ...(x !== undefined ? { x } : {}),
1779
+ ...(y !== undefined ? { y } : {}),
1780
+ ...(width !== undefined ? { width } : {}),
1781
+ ...(nodeHeight !== undefined ? { heightPx: nodeHeight } : {}),
1672
1782
  });
1673
1783
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1674
- return responseJson({ ok: true, ...result, ...serializeCanvasNode(result.node) });
1784
+ return responseJson({ ...buildNodeResponse(result.node), url: result.url, spec: result.spec });
1675
1785
  } catch (error) {
1676
1786
  const message = error instanceof Error ? error.message : String(error);
1677
1787
  return responseJson({ ok: false, error: message }, 400);
@@ -3880,6 +3990,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3880
3990
  return handleCanvasFocus(req);
3881
3991
  }
3882
3992
 
3993
+ if (url.pathname === '/api/canvas/fit' && req.method === 'POST') {
3994
+ return handleCanvasFit(req);
3995
+ }
3996
+
3883
3997
  if (url.pathname === '/api/canvas/clear' && req.method === 'POST') {
3884
3998
  for (const node of canvasState.getLayout().nodes) {
3885
3999
  closeNodeAppSession(node);