@stocksharp/diagram 0.6.0 → 0.7.0

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.
Files changed (57) hide show
  1. package/dist/esm/canvas-renderer.js +185 -34
  2. package/dist/esm/canvas-renderer.js.map +1 -1
  3. package/dist/esm/core/document.js +24 -12
  4. package/dist/esm/core/document.js.map +1 -1
  5. package/dist/esm/core/history.js +12 -3
  6. package/dist/esm/core/history.js.map +1 -1
  7. package/dist/esm/core/json.js +10 -0
  8. package/dist/esm/core/json.js.map +1 -1
  9. package/dist/esm/diagram/context-menu.js +28 -10
  10. package/dist/esm/diagram/context-menu.js.map +1 -1
  11. package/dist/esm/diagram/event-emitter.js +12 -1
  12. package/dist/esm/diagram/event-emitter.js.map +1 -1
  13. package/dist/esm/diagram/palette.js +9 -1
  14. package/dist/esm/diagram/palette.js.map +1 -1
  15. package/dist/esm/diagram/stocksharp-diagram.js +8 -0
  16. package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
  17. package/dist/esm/diagram/types.js +29 -2
  18. package/dist/esm/diagram/types.js.map +1 -1
  19. package/dist/esm/embed.js +63 -10
  20. package/dist/esm/embed.js.map +1 -1
  21. package/dist/esm/i18n.js.map +1 -1
  22. package/dist/esm/svg-surface.js +10 -2
  23. package/dist/esm/svg-surface.js.map +1 -1
  24. package/dist/ssdiagram.js +253 -74
  25. package/dist/ssdiagram.js.map +3 -3
  26. package/dist/types/canvas-renderer.d.ts +19 -1
  27. package/dist/types/canvas-renderer.d.ts.map +1 -1
  28. package/dist/types/core/document.d.ts.map +1 -1
  29. package/dist/types/core/history.d.ts +1 -0
  30. package/dist/types/core/history.d.ts.map +1 -1
  31. package/dist/types/core/json.d.ts +8 -0
  32. package/dist/types/core/json.d.ts.map +1 -1
  33. package/dist/types/diagram/context-menu.d.ts +6 -0
  34. package/dist/types/diagram/context-menu.d.ts.map +1 -1
  35. package/dist/types/diagram/event-emitter.d.ts.map +1 -1
  36. package/dist/types/diagram/palette.d.ts.map +1 -1
  37. package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
  38. package/dist/types/diagram/types.d.ts +5 -0
  39. package/dist/types/diagram/types.d.ts.map +1 -1
  40. package/dist/types/embed.d.ts.map +1 -1
  41. package/dist/types/i18n.d.ts +2 -0
  42. package/dist/types/i18n.d.ts.map +1 -1
  43. package/dist/types/svg-surface.d.ts +1 -1
  44. package/dist/types/svg-surface.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/canvas-renderer.ts +173 -34
  47. package/src/core/document.ts +25 -12
  48. package/src/core/history.ts +13 -3
  49. package/src/core/json.ts +11 -0
  50. package/src/diagram/context-menu.ts +30 -10
  51. package/src/diagram/event-emitter.ts +10 -1
  52. package/src/diagram/palette.ts +9 -2
  53. package/src/diagram/stocksharp-diagram.ts +5 -0
  54. package/src/diagram/types.ts +29 -3
  55. package/src/embed.ts +71 -10
  56. package/src/i18n.ts +2 -0
  57. package/src/svg-surface.ts +11 -2
@@ -8,7 +8,7 @@ import { SvgSurface } from './svg-surface.js';
8
8
 
9
9
  import { createDiagramDocument, parseDiagramDocument } from './core/document.js';
10
10
  import { DiagramCommandHistory } from './core/history.js';
11
- import { setJsonKey } from './core/json.js';
11
+ import { getJsonKey, setJsonKey } from './core/json.js';
12
12
  import { toPortDynamicMode } from './core/model.js';
13
13
  import type {
14
14
  DiagramDocumentZone,
@@ -98,7 +98,10 @@ export type DiagramNodeSnapshot = DiagramNodeInit
98
98
 
99
99
  export interface DiagramSnapshot {
100
100
  nodes: DiagramNodeSnapshot[];
101
- links: Array<Required<Pick<LinkInit, 'from' | 'fromPort' | 'to' | 'toPort'>>>;
101
+ // Endpoints alone made the snapshot lossy: a save()/load() round-trip dropped every link's
102
+ // identity, style and metadata, and the links carried in loadFinished / undoRequested /
103
+ // redoRequested arrived with an empty id, so selectLink(payload.link.id) matched nothing.
104
+ links: Array<Required<Pick<LinkInit, 'id' | 'from' | 'fromPort' | 'to' | 'toPort' | 'style'>> & Pick<LinkInit, 'metadata'>>;
102
105
  }
103
106
 
104
107
  export interface DiagramOptions {
@@ -581,6 +584,8 @@ export class Diagram {
581
584
  private introStart: number | null = null;
582
585
  private overviewVisible = true;
583
586
  private ovDragging = false;
587
+ private historyShortcut: (direction: 'undo' | 'redo') => void =
588
+ (direction) => { if (direction === 'undo') this.undo(); else this.redo(); };
584
589
  private ovGeo: { x: number; y: number; w: number; h: number; s: number; minX: number; minY: number } | null = null;
585
590
 
586
591
  private validator: LinkValidator | null = null;
@@ -588,11 +593,17 @@ export class Diagram {
588
593
 
589
594
  constructor(opts: DiagramOptions) {
590
595
  this.opts = opts;
596
+ // Same normalization setTypeColors applies, so a palette handed in at construction is
597
+ // keyed like one handed in later.
598
+ if (opts.typeColors !== undefined) this.setTypeColorsInternal(opts.typeColors);
591
599
  this.host = opts.host;
592
600
  this.gridSnapEnabled = opts.gridSnap ?? false;
593
601
  this.gridSize = this.normalizeGridSize(opts.gridSize ?? DEFAULT_GRID_SIZE);
594
- this.history = new DiagramCommandHistory(({ canUndo, canRedo }) => {
595
- this.emit('undoStackChanged', { canUndo, canRedo });
602
+ this.history = new DiagramCommandHistory(() => {
603
+ // The gated values, not the raw stack: setInteractionPermissions and setReadOnly
604
+ // already publish canUndo()/canRedo(), and a toolbar fed the raw pair enabled its
605
+ // Undo button for a call that undo() would refuse.
606
+ this.emit('undoStackChanged', { canUndo: this.canUndo(), canRedo: this.canRedo() });
596
607
  });
597
608
  this.canvas = document.createElement('canvas');
598
609
  this.canvas.style.display = 'block';
@@ -949,12 +960,21 @@ export class Diagram {
949
960
  }
950
961
  clear(): void {
951
962
  this.nodes = []; this.links = [];
963
+ // Zones belong to the document like everything else here. Leaving them behind meant a
964
+ // discarded strategy kept drawing its zones over the next one and, worse, saveDocument()
965
+ // wrote them into that unrelated strategy's file.
966
+ this.zones = [];
967
+ // Every other path that empties the selection announces it. Staying silent here left a
968
+ // host's property panel showing a node from the document that had just been thrown away.
969
+ const hadSelection = this.selectedNode !== null || this.selectedNodes.size > 0
970
+ || this.selectedLink !== null || this.selectedPort !== null;
952
971
  this.selectedNode = null; this.selectedNodes.clear(); this.selectedLink = null; this.selectedPort = null;
953
972
  this.documentMetadata = {};
954
973
  this.runtimeState = createDiagramRuntimeState();
955
974
  this.runtimePulse = 0;
956
975
  this.globalErrorFlashStart = null;
957
976
  this.history.clear();
977
+ if (hadSelection) this.emitSelectionChanged();
958
978
  this.emitRuntimeStateChanged();
959
979
  }
960
980
  relink(
@@ -1204,6 +1224,25 @@ export class Diagram {
1204
1224
  // Initial load is "model seed" — should NOT be undoable, and
1205
1225
  // shouldn't leave the user with 12 entries to Ctrl+Z through
1206
1226
  // before their stack reaches their first real action.
1227
+ // Validate before applying anything. doAddNode returns silently on a duplicate id, so a
1228
+ // second node with the same id simply vanished and every wire meant for it attached to the
1229
+ // first -- a quietly miswired strategy. The link check used to sit mid-loop, which threw
1230
+ // with the diagram already half loaded and no loadFinished.
1231
+ const seenNodeIds = new Set<string>();
1232
+ for (const node of nodes) {
1233
+ const id = node.id ?? '';
1234
+ if (id === '') continue;
1235
+ if (seenNodeIds.has(id)) throw new Error(`ssdiagram: duplicate node id "${id}"`);
1236
+ seenNodeIds.add(id);
1237
+ }
1238
+ const seenLinkIds = new Set<string>();
1239
+ for (const link of links) {
1240
+ const id = link.id ?? '';
1241
+ if (id === '') continue;
1242
+ if (seenLinkIds.has(id)) throw new Error(`ssdiagram: duplicate link id "${id}"`);
1243
+ seenLinkIds.add(id);
1244
+ }
1245
+
1207
1246
  for (const node of nodes) {
1208
1247
  const id = node.id ?? this.nextNodeId();
1209
1248
  this.doAddNode({ ...node, id });
@@ -1220,7 +1259,7 @@ export class Diagram {
1220
1259
  if (node.loadError.length === 0) continue;
1221
1260
  const state = createDiagramNodeRuntimeState();
1222
1261
  state.errors.load = { kind: 'load', message: node.loadError, pulse: ++this.runtimePulse };
1223
- this.runtimeState.nodes[node.id] = state;
1262
+ setJsonKey(this.runtimeState.nodes as unknown as JsonObject, node.id, state as unknown as JsonValue);
1224
1263
  }
1225
1264
  if (Object.keys(this.runtimeState.nodes).length > 0) this.emitRuntimeStateChanged();
1226
1265
  this.history.clear();
@@ -1231,7 +1270,15 @@ export class Diagram {
1231
1270
  save(): DiagramSnapshot {
1232
1271
  return {
1233
1272
  nodes: this.nodes.map((node) => node.toInit(false) as DiagramNodeSnapshot),
1234
- links: this.links.map((l) => ({ from: l.from, fromPort: l.fromPort, to: l.to, toPort: l.toPort })),
1273
+ links: this.links.map((l) => ({
1274
+ id: l.id,
1275
+ from: l.from,
1276
+ fromPort: l.fromPort,
1277
+ to: l.to,
1278
+ toPort: l.toPort,
1279
+ style: l.style,
1280
+ metadata: copyJsonObject(l.metadata),
1281
+ })),
1235
1282
  };
1236
1283
  }
1237
1284
 
@@ -1303,7 +1350,18 @@ export class Diagram {
1303
1350
  overviewVisible: this.overviewVisible,
1304
1351
  };
1305
1352
  }
1353
+ /**
1354
+ * Replaces what Ctrl+Z / Ctrl+Y do. Passing null restores the built-in behaviour, which is
1355
+ * plain undo()/redo() -- so a renderer used on its own keeps its keyboard history.
1356
+ */
1357
+ setHistoryShortcutHandler(handler: ((direction: 'undo' | 'redo') => void) | null): void {
1358
+ this.historyShortcut = handler ?? ((direction) => { if (direction === 'undo') this.undo(); else this.redo(); });
1359
+ }
1360
+
1306
1361
  setViewState(state: DiagramViewState): void {
1362
+ if (!Number.isFinite(state.zoom) || !Number.isFinite(state.panX) || !Number.isFinite(state.panY)) {
1363
+ throw new RangeError('ssdiagram: view state zoom and pan must be finite numbers');
1364
+ }
1307
1365
  this.scale = clamp(state.zoom, ZOOM_MIN, ZOOM_MAX);
1308
1366
  this.offX = state.panX;
1309
1367
  this.offY = state.panY;
@@ -1327,6 +1385,11 @@ export class Diagram {
1327
1385
  }
1328
1386
 
1329
1387
  if (scope === 'viewport' && Object.keys(options).length === 0) {
1388
+ // Mutations only schedule a repaint, so the bitmap on screen is still the previous
1389
+ // frame until it lands. Copying it as-is handed back a picture of the state before the
1390
+ // edit -- or a blank one right after construction -- while the very same call with any
1391
+ // option took the redraw path and came out current.
1392
+ if (this.drawScheduled) this.draw();
1330
1393
  const copy = document.createElement('canvas');
1331
1394
  copy.width = this.canvas.width;
1332
1395
  copy.height = this.canvas.height;
@@ -1357,6 +1420,9 @@ export class Diagram {
1357
1420
  offY: this.offY,
1358
1421
  overviewVisible: this.overviewVisible,
1359
1422
  background: this.opts.background,
1423
+ // Drawing the overview recomputes this from the export frame. Left behind, the next
1424
+ // click on the on-screen minimap panned to a point in the export's coordinates.
1425
+ ovGeo: this.ovGeo,
1360
1426
  };
1361
1427
  try {
1362
1428
  this.ctx = outputContext;
@@ -1386,6 +1452,7 @@ export class Diagram {
1386
1452
  this.offY = previous.offY;
1387
1453
  this.overviewVisible = previous.overviewVisible;
1388
1454
  this.opts.background = previous.background;
1455
+ this.ovGeo = previous.ovGeo;
1389
1456
  }
1390
1457
  return output;
1391
1458
  }
@@ -1456,10 +1523,19 @@ export class Diagram {
1456
1523
  offX: this.offX,
1457
1524
  offY: this.offY,
1458
1525
  overviewVisible: this.overviewVisible,
1526
+ background: this.opts.background,
1527
+ // Drawing the overview recomputes this from the export frame. Left behind, the next
1528
+ // click on the on-screen minimap panned to a point in the export's coordinates.
1529
+ ovGeo: this.ovGeo,
1459
1530
  };
1460
1531
 
1461
1532
  try {
1462
1533
  this.ctx = surface;
1534
+ // draw() fills the frame with the theme background unconditionally, straight over the
1535
+ // backdrop the surface was given -- so passing the override to SvgSurface alone left it
1536
+ // invisible and the SVG disagreed with the PNG of the same options. takeScreenshot
1537
+ // swaps the theme value for the duration of the export; this does the same.
1538
+ if (options.background !== undefined) this.opts.background = options.background;
1463
1539
  this.width = width;
1464
1540
  this.height = height;
1465
1541
  // 1: an SVG has no device pixels to multiply by, and the viewBox carries the size.
@@ -1485,6 +1561,8 @@ export class Diagram {
1485
1561
  this.offX = previous.offX;
1486
1562
  this.offY = previous.offY;
1487
1563
  this.overviewVisible = previous.overviewVisible;
1564
+ this.opts.background = previous.background;
1565
+ this.ovGeo = previous.ovGeo;
1488
1566
  }
1489
1567
 
1490
1568
  return surface.toSvg();
@@ -1521,8 +1599,8 @@ export class Diagram {
1521
1599
  .flatMap((node) => [node.errors.runtime?.pulse ?? 0, node.errors.load?.pulse ?? 0]),
1522
1600
  );
1523
1601
  for (const node of this.nodes) {
1524
- const errors = this.runtimeState.nodes[node.id]?.errors;
1525
- const previousRuntime = previous.nodes[node.id]?.errors.runtime;
1602
+ const errors = getJsonKey(this.runtimeState.nodes, node.id)?.errors;
1603
+ const previousRuntime = getJsonKey(previous.nodes, node.id)?.errors.runtime;
1526
1604
  const runtimeError = errors?.runtime;
1527
1605
  node.runtimeError = runtimeError?.message ?? '';
1528
1606
  node.loadError = errors?.load?.message ?? '';
@@ -1558,8 +1636,10 @@ export class Diagram {
1558
1636
  : node?.outPorts.find((candidate) => candidate.id === portId);
1559
1637
  if (node === undefined || port === undefined) return false;
1560
1638
  const state = this.getRuntimeState();
1561
- const nodeState = state.nodes[nodeId] ?? createDiagramNodeRuntimeState();
1562
- state.nodes[nodeId] = nodeState;
1639
+ // Node ids come from documents, so "__proto__" is reachable: a bare assignment there
1640
+ // writes the prototype instead of creating an entry, and the next read of it throws.
1641
+ const nodeState = getJsonKey(state.nodes, nodeId) ?? createDiagramNodeRuntimeState();
1642
+ setJsonKey(state.nodes as unknown as JsonObject, nodeId, nodeState as unknown as JsonValue);
1563
1643
  const current = nodeState.ports[direction][portId] ?? createDiagramPortRuntimeState();
1564
1644
  nodeState.ports[direction][portId] = { ...current, ...patch };
1565
1645
  this.setRuntimeState(state);
@@ -1585,8 +1665,8 @@ export class Diagram {
1585
1665
  : null;
1586
1666
  }
1587
1667
  const state = this.getRuntimeState();
1588
- const nodeState = state.nodes[id] ?? createDiagramNodeRuntimeState();
1589
- state.nodes[id] = nodeState;
1668
+ const nodeState = getJsonKey(state.nodes, id) ?? createDiagramNodeRuntimeState();
1669
+ setJsonKey(state.nodes as unknown as JsonObject, id, nodeState as unknown as JsonValue);
1590
1670
  // Only this kind's slot is written, so an error of the other kind that is
1591
1671
  // already on the node survives.
1592
1672
  if (message.length === 0) delete nodeState.errors[kind];
@@ -1605,7 +1685,7 @@ export class Diagram {
1605
1685
  }
1606
1686
  if (kind === undefined || kind === 'load') node.loadError = '';
1607
1687
  const state = this.getRuntimeState();
1608
- const nodeState = state.nodes[id];
1688
+ const nodeState = getJsonKey(state.nodes, id);
1609
1689
  if (nodeState !== undefined) {
1610
1690
  if (kind === undefined || kind === 'runtime') delete nodeState.errors.runtime;
1611
1691
  if (kind === undefined || kind === 'load') delete nodeState.errors.load;
@@ -1626,6 +1706,9 @@ export class Diagram {
1626
1706
  }
1627
1707
 
1628
1708
  setZoom(scale: number): void {
1709
+ // clamp() passes NaN straight through, and a NaN scale poisons offX/offY on the very next
1710
+ // line -- after which hit-testing, drawing and saveViewState are all dead, permanently.
1711
+ if (!Number.isFinite(scale)) throw new RangeError('ssdiagram: zoom must be a finite number');
1629
1712
  const cx = this.width / 2;
1630
1713
  const cy = this.height / 2;
1631
1714
  const wx = (cx - this.offX) / this.scale;
@@ -1658,9 +1741,22 @@ export class Diagram {
1658
1741
  this.scheduleDraw();
1659
1742
  }
1660
1743
  setTypeColors(colors: Readonly<Record<string, string>>): void {
1661
- this.opts.typeColors = { ...colors };
1744
+ this.setTypeColorsInternal(colors);
1662
1745
  this.scheduleDraw();
1663
1746
  }
1747
+
1748
+ /**
1749
+ * Keyed the way port types are compared everywhere else. Connection compatibility is case- and
1750
+ * space-insensitive, so an imported scheme spelling a socket "candle " connects to "Candle" and
1751
+ * then fell through to a hash colour, showing one logical type in two colours in one diagram.
1752
+ */
1753
+ private setTypeColorsInternal(colors: Readonly<Record<string, string>>): void {
1754
+ const normalized: Record<string, string> = {};
1755
+ for (const [type, color] of Object.entries(colors)) {
1756
+ setJsonKey(normalized, normalizePortType(type), color);
1757
+ }
1758
+ this.opts.typeColors = normalized;
1759
+ }
1664
1760
  setTheme(t: {
1665
1761
  background?: string;
1666
1762
  gridColor?: string;
@@ -1771,7 +1867,8 @@ export class Diagram {
1771
1867
  const light = maxL !== undefined && maxL < 0.5;
1772
1868
  if (type === '') return light ? '#585c62' : '#9aa0a6';
1773
1869
  const map = this.opts.typeColors ?? {};
1774
- const base = map[type] ?? `hsl(${hashHue(type)}, 62%, 58%)`;
1870
+ const normalized = normalizePortType(type);
1871
+ const base = getJsonKey(map, normalized) ?? `hsl(${hashHue(normalized)}, 62%, 58%)`;
1775
1872
  return maxL !== undefined ? clampLightness(base, maxL) : base;
1776
1873
  }
1777
1874
  // Selection/hover accent. The dark-canvas blue washes out on a light canvas, so a light theme
@@ -1787,11 +1884,16 @@ export class Diagram {
1787
1884
 
1788
1885
  // ---- hit testing (world coords) ---------------------------------
1789
1886
  private portAt(wx: number, wy: number): { node: NodeModel; port: PortModel } | null {
1887
+ // Walks front to back and stops at the first node whose BODY covers the point, so a socket
1888
+ // hidden underneath a node drawn on top of it can no longer win the hit test. Without that
1889
+ // stop, clicking the visible body of the front node selected the one behind it, and on an
1890
+ // output socket started dragging a wire out of a port that is not on screen.
1790
1891
  for (let i = this.nodes.length - 1; i >= 0; i -= 1) {
1791
1892
  const n = this.nodes[i];
1792
1893
  for (const p of [...n.inPorts, ...n.outPorts]) {
1793
1894
  if ((wx - p.cx) ** 2 + (wy - p.cy) ** 2 <= (PORT_R + 4) ** 2) return { node: n, port: p };
1794
1895
  }
1896
+ if (wx >= n.x && wx <= n.x + n.w && wy >= n.y && wy <= n.y + n.h) return null;
1795
1897
  }
1796
1898
  return null;
1797
1899
  }
@@ -2077,6 +2179,9 @@ export class Diagram {
2077
2179
  fromPort: l.from.portId,
2078
2180
  to: t,
2079
2181
  toPort: l.to.portId,
2182
+ // The clipboard is built from saveDocument(), so it carries the style;
2183
+ // rebuilding without it silently turned every pasted dashed link solid.
2184
+ style: l.style,
2080
2185
  metadata: l.metadata,
2081
2186
  });
2082
2187
  }
@@ -2163,14 +2268,21 @@ export class Diagram {
2163
2268
  // move-beyond-tolerance, pointerup, or any other gesture.
2164
2269
  this.cancelLongPress();
2165
2270
  this.relinkCandidate = null;
2166
- this.lpStart = { sx: e.clientX, sy: e.clientY, px: e.clientX, py: e.clientY };
2167
- const downSx = e.clientX, downSy = e.clientY;
2168
- const r0 = this.canvas.getBoundingClientRect();
2169
- const localSx = downSx - r0.left, localSy = downSy - r0.top;
2170
- this.lpTimer = setTimeout(() => {
2171
- this.lpTimer = null;
2172
- this.fireContextMenu(localSx, localSy, downSx, downSy);
2173
- }, this.lpDelayMs);
2271
+ // Touch only. A mouse has a button for this, and arming the timer for it meant holding
2272
+ // the left button still -- which is what positioning a node precisely looks like --
2273
+ // opened the menu after half a second and wiped the drag in progress: the pixels the
2274
+ // node had already travelled stayed, but never reached history, so Ctrl+Z could not
2275
+ // take them back.
2276
+ if (e.pointerType !== 'mouse') {
2277
+ this.lpStart = { sx: e.clientX, sy: e.clientY, px: e.clientX, py: e.clientY };
2278
+ const downSx = e.clientX, downSy = e.clientY;
2279
+ const r0 = this.canvas.getBoundingClientRect();
2280
+ const localSx = downSx - r0.left, localSy = downSy - r0.top;
2281
+ this.lpTimer = setTimeout(() => {
2282
+ this.lpTimer = null;
2283
+ this.fireContextMenu(localSx, localSy, downSx, downSy);
2284
+ }, this.lpDelayMs);
2285
+ }
2174
2286
  this.canvas.focus();
2175
2287
  const [sx, sy] = localXY(e);
2176
2288
  if (this.ovHit(sx, sy)) { this.ovDragging = true; this.ovPanTo(sx, sy); return; }
@@ -2249,10 +2361,12 @@ export class Diagram {
2249
2361
  if (node !== null) {
2250
2362
  const add = e.shiftKey || e.ctrlKey || e.metaKey;
2251
2363
  if (this.permissions.select) {
2252
- if (add) { this.toggleSelect(node); return; }
2364
+ // A right-click still selects what it landed on, so the menu that follows acts
2365
+ // on the node under the cursor -- but only the primary button may drag it.
2366
+ if (add && e.button === 0) { this.toggleSelect(node); return; }
2253
2367
  if (!this.selectedNodes.has(node)) this.selectNode(node);
2254
2368
  }
2255
- if (this.permissions.moveNodes) {
2369
+ if (this.permissions.moveNodes && e.button === 0) {
2256
2370
  this.dragNode = node;
2257
2371
  this.dragAnchor = { wx, wy };
2258
2372
  const moving = this.selectedNodes.has(node) ? [...this.selectedNodes] : [node];
@@ -2267,6 +2381,9 @@ export class Diagram {
2267
2381
  if (this.permissions.select) this.selectLink(link);
2268
2382
  return;
2269
2383
  }
2384
+ // A secondary button on empty canvas is on its way to the context menu: it must not
2385
+ // clear the selection the menu is about to act on, nor start a rubber band.
2386
+ if (e.button !== 0 && e.button !== 1) return;
2270
2387
  // empty space: middle-button / Ctrl / Alt / touch = pan;
2271
2388
  // else rubber-band select. Touch always pans because mobile
2272
2389
  // users can't hold modifier keys and rubber-band drag-select
@@ -2399,7 +2516,13 @@ export class Diagram {
2399
2516
  const moves = this.dragStart
2400
2517
  .map((it) => ({ id: it.n.id, fromX: it.x, fromY: it.y, toX: it.n.x, toY: it.n.y }))
2401
2518
  .filter((m) => m.fromX !== m.toX || m.fromY !== m.toY);
2402
- for (const it of this.dragStart) this.emit('nodeMoved', { node: it.n });
2519
+ // Announce the same set the undo step records. Emitting for every node the drag
2520
+ // touched reported a move for a plain selection click -- one per selected node --
2521
+ // and a host doing dirty tracking marked the strategy modified for nothing.
2522
+ const moved = new Set(moves.map((m) => m.id));
2523
+ for (const it of this.dragStart) {
2524
+ if (moved.has(it.n.id)) this.emit('nodeMoved', { node: it.n });
2525
+ }
2403
2526
  this.dragNode = null;
2404
2527
  this.dragStart = [];
2405
2528
  if (moves.length > 0) {
@@ -2488,6 +2611,9 @@ export class Diagram {
2488
2611
  const [sx, sy] = localXY(e);
2489
2612
  const [wx, wy] = this.toWorld(sx, sy);
2490
2613
  if (this.portAt(wx, wy) !== null) return;
2614
+ // A wire is something, not empty canvas: without this the zoomToFit fallback below
2615
+ // threw away the zoom and pan the user had set, on a double click aimed at a link.
2616
+ if (this.linkAt(wx, wy) !== null) return;
2491
2617
  const node = this.nodeAt(wx, wy);
2492
2618
  if (node !== null) {
2493
2619
  if (this.permissions.inspect && node.openAction.length > 0) {
@@ -2529,6 +2655,10 @@ export class Diagram {
2529
2655
  this.dragNode = null; this.dragStart = [];
2530
2656
  this.panning = false; this.rubber = null; this.linking = null;
2531
2657
  this.relinking = null; this.relinkCandidate = null;
2658
+ // The two the block used to miss: a finger that started on the minimap kept
2659
+ // panning the viewport against the pinch, and a cancelled wire drag left its
2660
+ // magnet highlight burned onto a socket until something else redrew it.
2661
+ this.ovDragging = false; this.linkSnap = null;
2532
2662
  }
2533
2663
  }, { passive: false });
2534
2664
  this.listen(this.canvas, 'touchmove', (e) => {
@@ -2575,10 +2705,13 @@ export class Diagram {
2575
2705
  e.preventDefault(); this.cutSelection(); return;
2576
2706
  }
2577
2707
  if (mod && this.permissions.select && e.code === 'KeyA') { e.preventDefault(); this.setSelection(this.nodes.slice()); return; }
2578
- // Ctrl+Z = undo; Ctrl+Y or Ctrl+Shift+Z = redo.
2579
- if (mod && this.permissions.history && e.code === 'KeyZ' && !e.shiftKey) { e.preventDefault(); this.undo(); return; }
2708
+ // Ctrl+Z = undo; Ctrl+Y or Ctrl+Shift+Z = redo. Routed through a handler rather than
2709
+ // called directly: a component wrapping this renderer has its own rules about when
2710
+ // history may run and its own events to announce it, and the keyboard must obey them
2711
+ // like every other entry point.
2712
+ if (mod && this.permissions.history && e.code === 'KeyZ' && !e.shiftKey) { e.preventDefault(); this.historyShortcut('undo'); return; }
2580
2713
  if (mod && this.permissions.history && (e.code === 'KeyY' || (e.code === 'KeyZ' && e.shiftKey))) {
2581
- e.preventDefault(); this.redo(); return;
2714
+ e.preventDefault(); this.historyShortcut('redo'); return;
2582
2715
  }
2583
2716
  });
2584
2717
  }
@@ -2663,18 +2796,24 @@ export class Diagram {
2663
2796
  }
2664
2797
  ctx.restore();
2665
2798
  if (options.overview) this.drawOverview();
2666
- if (options.runtime) this.drawGlobalError();
2799
+ if (options.runtime) this.drawGlobalError(options.transient);
2667
2800
  if (options.transient) this.drawTooltip();
2668
2801
  if (options.transient && (this.introStart !== null || this.globalErrorFlashStart !== null
2669
2802
  || this.nodes.some((n) => n.errorFlashStart !== null)))
2670
2803
  this.scheduleDraw(); // keep entrance / error feedback animating
2671
2804
  }
2672
- private drawGlobalError(): void {
2805
+ /**
2806
+ * `transient` gates the flash exactly as it gates every other animation, which an export must
2807
+ * not see: it would bake in whatever point of the pulse it caught, so the same export taken
2808
+ * twice differed in opacity. The gate also keeps the export from consuming the animation --
2809
+ * clearing globalErrorFlashStart from an export cut the flash short on screen.
2810
+ */
2811
+ private drawGlobalError(transient: boolean): void {
2673
2812
  const error = this.runtimeState.globalError;
2674
2813
  if (error === null) return;
2675
2814
  const ctx = this.ctx;
2676
2815
  let flashAlpha = 1;
2677
- if (this.globalErrorFlashStart !== null) {
2816
+ if (transient && this.globalErrorFlashStart !== null) {
2678
2817
  const elapsed = performance.now() - this.globalErrorFlashStart;
2679
2818
  if (elapsed >= ERROR_FLASH_MS) {
2680
2819
  this.globalErrorFlashStart = null;
@@ -2842,7 +2981,7 @@ export class Diagram {
2842
2981
  const ctx = this.ctx;
2843
2982
  const hasLoadError = options.runtime && n.loadError.length > 0;
2844
2983
  const hasRuntimeError = options.runtime && n.runtimeError.length > 0;
2845
- const runtime = options.runtime ? this.runtimeState.nodes[n.id] : undefined;
2984
+ const runtime = options.runtime ? getJsonKey(this.runtimeState.nodes, n.id) : undefined;
2846
2985
  const active = options.runtime
2847
2986
  && (this.runtimeState.activeNodeId === n.id || runtime?.active === true);
2848
2987
  roundRect(ctx, n.x, n.y, n.w, n.h, 6);
@@ -2934,7 +3073,7 @@ export class Diagram {
2934
3073
  }
2935
3074
  }
2936
3075
  private portRuntimeState(node: NodeModel, port: PortModel): DiagramPortRuntimeState | null {
2937
- return this.runtimeState.nodes[node.id]?.ports[port.direction][port.id] ?? null;
3076
+ return getJsonKey(this.runtimeState.nodes, node.id)?.ports[port.direction][port.id] ?? null;
2938
3077
  }
2939
3078
  // Symmetric jump-over: any segment of THIS link hops the perpendicular
2940
3079
  // segments of links drawn before it (H over earlier V, V over earlier
@@ -76,15 +76,8 @@ export function createDiagramDocument(input: DiagramDocumentInput = {}): Diagram
76
76
  return normalizeLink({ ...link, id }, `$.links[${index}]`);
77
77
  });
78
78
  const rawZones = mapElements(input.zones, '$.zones');
79
- const usedZoneIds = new Set<string>();
80
- const zones = rawZones.map((zone, index) => {
81
- const normalized = normalizeZone(zone, `$.zones[${index}]`);
82
- if (usedZoneIds.has(normalized.id)) {
83
- throw new DiagramDocumentError(`duplicate zone id "${normalized.id}"`, `$.zones[${index}].id`);
84
- }
85
- usedZoneIds.add(normalized.id);
86
- return normalized;
87
- });
79
+ const zones = rawZones.map((zone, index) => normalizeZone(zone, `$.zones[${index}]`));
80
+ validateUniqueZones(zones);
88
81
 
89
82
  const document: DiagramDocument = {
90
83
  version: DIAGRAM_DOCUMENT_VERSION,
@@ -224,8 +217,9 @@ function normalizeZone(value: unknown, path: string): DiagramDocumentZone {
224
217
  return {
225
218
  id: requireIdentifier(input.id, `${path}.id`),
226
219
  name: requireString(input.name, `${path}.name`),
227
- x: requireNumber(input.x, `${path}.x`),
228
- y: requireNumber(input.y, `${path}.y`),
220
+ // Finite, like node coordinates: NaN survives every arithmetic check and writes as null.
221
+ x: requireFiniteNumber(input.x, `${path}.x`),
222
+ y: requireFiniteNumber(input.y, `${path}.y`),
229
223
  width: requirePositiveSize(input.width, `${path}.width`),
230
224
  height: requirePositiveSize(input.height, `${path}.height`),
231
225
  color: input.color === undefined || input.color === null ? '' : requireString(input.color, `${path}.color`),
@@ -236,7 +230,9 @@ function normalizeZone(value: unknown, path: string): DiagramDocumentZone {
236
230
  // A zone with no area cannot be pointed at, and a negative one paints outside itself, so the
237
231
  // size is refused rather than clamped - a caller that computed it wrong wants to hear about it.
238
232
  function requirePositiveSize(value: unknown, path: string): number {
239
- const size = requireNumber(value, path);
233
+ // Finite, not merely positive: Infinity satisfies `> 0` and then serializes to null, so the
234
+ // document would save without complaint and fail to parse back.
235
+ const size = requireFiniteNumber(value, path);
240
236
  if (!(size > 0)) throw new DiagramDocumentError('size must be greater than zero', path);
241
237
  return size;
242
238
  }
@@ -325,6 +321,21 @@ function parseEndpoint(value: unknown, path: string): DiagramDocumentEndpoint {
325
321
  };
326
322
  }
327
323
 
324
+ /**
325
+ * Zone ids have to be unique wherever a document comes from. Keeping the check here, rather than
326
+ * inside createDiagramDocument alone, is what stops a file being loadable and unsaveable: the
327
+ * parser used to let a duplicate through and the very next saveDocument() -- which rebuilds
328
+ * through createDiagramDocument -- threw on it.
329
+ */
330
+ function validateUniqueZones(zones: readonly DiagramDocumentZone[]): void {
331
+ const seen = new Set<string>();
332
+ for (let index = 0; index < zones.length; index++) {
333
+ const zone = zones[index];
334
+ if (seen.has(zone.id)) throw new DiagramDocumentError(`duplicate zone id "${zone.id}"`, `$.zones[${index}].id`);
335
+ seen.add(zone.id);
336
+ }
337
+ }
338
+
328
339
  function validateDocument(document: DiagramDocument): void {
329
340
  const nodes = new Map<string, DiagramDocumentNode>();
330
341
  for (let index = 0; index < document.nodes.length; index++) {
@@ -356,6 +367,8 @@ function validateDocument(document: DiagramDocument): void {
356
367
  if (endpoints.has(key)) throw new DiagramDocumentError('duplicate link endpoints', path);
357
368
  endpoints.add(key);
358
369
  }
370
+
371
+ validateUniqueZones(document.zones);
359
372
  }
360
373
 
361
374
  function validateUniquePorts(ports: readonly DiagramDocumentPort[], path: string): void {
@@ -79,7 +79,18 @@ export class DiagramCommandHistory {
79
79
  }
80
80
  }
81
81
 
82
+ private assertNoOpenTransaction(operation: string): void {
83
+ if (this.transactions.length > 0) {
84
+ throw new Error(`Cannot ${operation} command history inside a transaction.`);
85
+ }
86
+ }
87
+
82
88
  undo(): boolean {
89
+ // Same guard clear() already has: running history while a transaction is open takes a
90
+ // committed step out from under the frame being built and breaks the LIFO invariant the
91
+ // stacks rely on. Reachable synchronously from a host handler for an event emitted from
92
+ // inside a transaction, so it is not a theoretical misuse.
93
+ this.assertNoOpenTransaction('undo');
83
94
  const command = this.undoCommands.pop();
84
95
  if (command === undefined) return false;
85
96
  try {
@@ -94,6 +105,7 @@ export class DiagramCommandHistory {
94
105
  }
95
106
 
96
107
  redo(): boolean {
108
+ this.assertNoOpenTransaction('redo');
97
109
  const command = this.redoCommands.pop();
98
110
  if (command === undefined) return false;
99
111
  try {
@@ -108,9 +120,7 @@ export class DiagramCommandHistory {
108
120
  }
109
121
 
110
122
  clear(): void {
111
- if (this.transactions.length > 0) {
112
- throw new Error('Cannot clear command history inside a transaction.');
113
- }
123
+ this.assertNoOpenTransaction('clear');
114
124
  this.undoCommands.length = 0;
115
125
  this.redoCommands.length = 0;
116
126
  this.notify();
package/src/core/json.ts CHANGED
@@ -14,3 +14,14 @@ export function setJsonKey<T>(target: Record<string, T>, key: string, value: T):
14
14
  }
15
15
  target[key] = value;
16
16
  }
17
+
18
+ /**
19
+ * The read that matches setJsonKey.
20
+ *
21
+ * `target[key]` for `__proto__` answers with the inherited prototype rather than the entry, so a
22
+ * plain lookup reports a value for a key that was never stored -- and callers that fall back on
23
+ * `?? create()` never do, then use `Object.prototype` as if it were their own record.
24
+ */
25
+ export function getJsonKey<T>(target: Record<string, T>, key: string): T | undefined {
26
+ return Object.prototype.hasOwnProperty.call(target, key) ? target[key] : undefined;
27
+ }