pmx-canvas 0.1.8 → 0.1.10

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() });
@@ -1214,16 +1305,14 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1214
1305
  ? body.path
1215
1306
  : body.content;
1216
1307
  let added: ReturnType<typeof addCanvasNode>;
1308
+ const geometry = resolveCreateGeometry(body);
1217
1309
  try {
1218
1310
  added = addCanvasNode({
1219
1311
  type: type as CanvasNodeState['type'],
1220
1312
  ...(typeof body.title === 'string' ? { title: body.title } : {}),
1221
1313
  ...(typeof content === 'string' ? { content } : {}),
1222
1314
  ...(extraData ? { data: extraData } : {}),
1223
- ...(typeof body.x === 'number' ? { x: body.x } : {}),
1224
- ...(typeof body.y === 'number' ? { y: body.y } : {}),
1225
- ...(typeof body.width === 'number' ? { width: body.width } : {}),
1226
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
1315
+ ...geometry,
1227
1316
  defaultWidth: 360,
1228
1317
  defaultHeight: 200,
1229
1318
  fileMode: 'auto',
@@ -1374,15 +1463,19 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
1374
1463
  if (existing.type === 'webpage' && body.refresh === true) {
1375
1464
  return handleCanvasRefreshWebpageNode(nodeId, req);
1376
1465
  }
1377
- const patch: Record<string, unknown> = {};
1378
- if (body.position) patch.position = body.position;
1379
- if (body.size) patch.size = body.size;
1466
+ const patch: Record<string, unknown> = resolvePatchGeometry(body, existing);
1380
1467
  if (body.collapsed !== undefined) patch.collapsed = body.collapsed;
1381
1468
  if (body.pinned !== undefined) patch.pinned = Boolean(body.pinned);
1382
1469
  if (body.dockPosition === null || body.dockPosition === 'left' || body.dockPosition === 'right') {
1383
1470
  patch.dockPosition = body.dockPosition;
1384
1471
  }
1385
- 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') {
1386
1479
  const data = { ...existing.data };
1387
1480
  if (body.title !== undefined) {
1388
1481
  data.title = String(body.title);
@@ -1419,7 +1512,8 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
1419
1512
  if (error) return responseJson({ ok: false, error }, 400);
1420
1513
  canvasState.updateNode(nodeId, patch as Partial<CanvasNodeState>);
1421
1514
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1422
- return responseJson({ ok: true, id: nodeId });
1515
+ const updated = canvasState.getNode(nodeId);
1516
+ return responseJson(updated ? buildNodeResponse(updated) : { ok: true, id: nodeId });
1423
1517
  }
1424
1518
 
1425
1519
  // ── Arrange nodes ───────────────────────────────────────────
@@ -1460,6 +1554,22 @@ async function handleCanvasFocus(req: Request): Promise<Response> {
1460
1554
  return responseJson({ ok: true, focused: nodeId, panned: !noPan });
1461
1555
  }
1462
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
+
1463
1573
  async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
1464
1574
  const body = await readJson(req);
1465
1575
  const title = typeof body.title === 'string' ? body.title.trim() : '';
@@ -1552,9 +1662,7 @@ async function handleCanvasValidateSpec(req: Request): Promise<Response> {
1552
1662
  }));
1553
1663
  }
1554
1664
 
1555
- const data = Array.isArray(body.data)
1556
- ? body.data.filter((item: unknown) => item && typeof item === 'object') as Array<Record<string, unknown>>
1557
- : null;
1665
+ const data = parseGraphPayloadData(body.data);
1558
1666
  if (!data) {
1559
1667
  return responseJson({ ok: false, error: 'Graph validation requires a data array.' }, 400);
1560
1668
  }
@@ -1606,18 +1714,16 @@ async function handleCanvasAddJsonRender(req: Request): Promise<Response> {
1606
1714
  const title = typeof body.title === 'string' ? body.title.trim() : '';
1607
1715
  const rawSpec =
1608
1716
  body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec) ? body.spec : body;
1717
+ const geometry = resolveCreateGeometry(body);
1609
1718
 
1610
1719
  try {
1611
1720
  const result = createCanvasJsonRenderNode({
1612
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);