@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
@@ -6,7 +6,7 @@ import { SvgSurface } from './svg-surface.js';
6
6
  // selection, delete, zoom/pan, load/save round-trip.
7
7
  import { createDiagramDocument, parseDiagramDocument } from './core/document.js';
8
8
  import { DiagramCommandHistory } from './core/history.js';
9
- import { setJsonKey } from './core/json.js';
9
+ import { getJsonKey, setJsonKey } from './core/json.js';
10
10
  import { toPortDynamicMode } from './core/model.js';
11
11
  import { cloneDiagramRuntimeState, createDiagramNodeRuntimeState, createDiagramPortRuntimeState, createDiagramRuntimeState, createEditableDiagramPermissions, createReadOnlyDiagramPermissions, } from './core/state.js';
12
12
  const SCREEN_RENDER_OPTIONS = {
@@ -275,15 +275,26 @@ export class Diagram {
275
275
  this.introStart = null;
276
276
  this.overviewVisible = true;
277
277
  this.ovDragging = false;
278
+ this.historyShortcut = (direction) => { if (direction === 'undo')
279
+ this.undo();
280
+ else
281
+ this.redo(); };
278
282
  this.ovGeo = null;
279
283
  this.validator = null;
280
284
  this.handlers = new Map();
281
285
  this.opts = opts;
286
+ // Same normalization setTypeColors applies, so a palette handed in at construction is
287
+ // keyed like one handed in later.
288
+ if (opts.typeColors !== undefined)
289
+ this.setTypeColorsInternal(opts.typeColors);
282
290
  this.host = opts.host;
283
291
  this.gridSnapEnabled = opts.gridSnap ?? false;
284
292
  this.gridSize = this.normalizeGridSize(opts.gridSize ?? DEFAULT_GRID_SIZE);
285
- this.history = new DiagramCommandHistory(({ canUndo, canRedo }) => {
286
- this.emit('undoStackChanged', { canUndo, canRedo });
293
+ this.history = new DiagramCommandHistory(() => {
294
+ // The gated values, not the raw stack: setInteractionPermissions and setReadOnly
295
+ // already publish canUndo()/canRedo(), and a toolbar fed the raw pair enabled its
296
+ // Undo button for a call that undo() would refuse.
297
+ this.emit('undoStackChanged', { canUndo: this.canUndo(), canRedo: this.canRedo() });
287
298
  });
288
299
  this.canvas = document.createElement('canvas');
289
300
  this.canvas.style.display = 'block';
@@ -691,6 +702,14 @@ export class Diagram {
691
702
  clear() {
692
703
  this.nodes = [];
693
704
  this.links = [];
705
+ // Zones belong to the document like everything else here. Leaving them behind meant a
706
+ // discarded strategy kept drawing its zones over the next one and, worse, saveDocument()
707
+ // wrote them into that unrelated strategy's file.
708
+ this.zones = [];
709
+ // Every other path that empties the selection announces it. Staying silent here left a
710
+ // host's property panel showing a node from the document that had just been thrown away.
711
+ const hadSelection = this.selectedNode !== null || this.selectedNodes.size > 0
712
+ || this.selectedLink !== null || this.selectedPort !== null;
694
713
  this.selectedNode = null;
695
714
  this.selectedNodes.clear();
696
715
  this.selectedLink = null;
@@ -700,6 +719,8 @@ export class Diagram {
700
719
  this.runtimePulse = 0;
701
720
  this.globalErrorFlashStart = null;
702
721
  this.history.clear();
722
+ if (hadSelection)
723
+ this.emitSelectionChanged();
703
724
  this.emitRuntimeStateChanged();
704
725
  }
705
726
  relink(linkId, next) {
@@ -970,6 +991,28 @@ export class Diagram {
970
991
  // Initial load is "model seed" — should NOT be undoable, and
971
992
  // shouldn't leave the user with 12 entries to Ctrl+Z through
972
993
  // before their stack reaches their first real action.
994
+ // Validate before applying anything. doAddNode returns silently on a duplicate id, so a
995
+ // second node with the same id simply vanished and every wire meant for it attached to the
996
+ // first -- a quietly miswired strategy. The link check used to sit mid-loop, which threw
997
+ // with the diagram already half loaded and no loadFinished.
998
+ const seenNodeIds = new Set();
999
+ for (const node of nodes) {
1000
+ const id = node.id ?? '';
1001
+ if (id === '')
1002
+ continue;
1003
+ if (seenNodeIds.has(id))
1004
+ throw new Error(`ssdiagram: duplicate node id "${id}"`);
1005
+ seenNodeIds.add(id);
1006
+ }
1007
+ const seenLinkIds = new Set();
1008
+ for (const link of links) {
1009
+ const id = link.id ?? '';
1010
+ if (id === '')
1011
+ continue;
1012
+ if (seenLinkIds.has(id))
1013
+ throw new Error(`ssdiagram: duplicate link id "${id}"`);
1014
+ seenLinkIds.add(id);
1015
+ }
973
1016
  for (const node of nodes) {
974
1017
  const id = node.id ?? this.nextNodeId();
975
1018
  this.doAddNode({ ...node, id });
@@ -988,7 +1031,7 @@ export class Diagram {
988
1031
  continue;
989
1032
  const state = createDiagramNodeRuntimeState();
990
1033
  state.errors.load = { kind: 'load', message: node.loadError, pulse: ++this.runtimePulse };
991
- this.runtimeState.nodes[node.id] = state;
1034
+ setJsonKey(this.runtimeState.nodes, node.id, state);
992
1035
  }
993
1036
  if (Object.keys(this.runtimeState.nodes).length > 0)
994
1037
  this.emitRuntimeStateChanged();
@@ -1000,7 +1043,15 @@ export class Diagram {
1000
1043
  save() {
1001
1044
  return {
1002
1045
  nodes: this.nodes.map((node) => node.toInit(false)),
1003
- links: this.links.map((l) => ({ from: l.from, fromPort: l.fromPort, to: l.to, toPort: l.toPort })),
1046
+ links: this.links.map((l) => ({
1047
+ id: l.id,
1048
+ from: l.from,
1049
+ fromPort: l.fromPort,
1050
+ to: l.to,
1051
+ toPort: l.toPort,
1052
+ style: l.style,
1053
+ metadata: copyJsonObject(l.metadata),
1054
+ })),
1004
1055
  };
1005
1056
  }
1006
1057
  loadDocument(source) {
@@ -1068,7 +1119,20 @@ export class Diagram {
1068
1119
  overviewVisible: this.overviewVisible,
1069
1120
  };
1070
1121
  }
1122
+ /**
1123
+ * Replaces what Ctrl+Z / Ctrl+Y do. Passing null restores the built-in behaviour, which is
1124
+ * plain undo()/redo() -- so a renderer used on its own keeps its keyboard history.
1125
+ */
1126
+ setHistoryShortcutHandler(handler) {
1127
+ this.historyShortcut = handler ?? ((direction) => { if (direction === 'undo')
1128
+ this.undo();
1129
+ else
1130
+ this.redo(); });
1131
+ }
1071
1132
  setViewState(state) {
1133
+ if (!Number.isFinite(state.zoom) || !Number.isFinite(state.panX) || !Number.isFinite(state.panY)) {
1134
+ throw new RangeError('ssdiagram: view state zoom and pan must be finite numbers');
1135
+ }
1072
1136
  this.scale = clamp(state.zoom, ZOOM_MIN, ZOOM_MAX);
1073
1137
  this.offX = state.panX;
1074
1138
  this.offY = state.panY;
@@ -1092,6 +1156,12 @@ export class Diagram {
1092
1156
  throw new RangeError(`ssdiagram: unsupported screenshot scope "${String(scope)}"`);
1093
1157
  }
1094
1158
  if (scope === 'viewport' && Object.keys(options).length === 0) {
1159
+ // Mutations only schedule a repaint, so the bitmap on screen is still the previous
1160
+ // frame until it lands. Copying it as-is handed back a picture of the state before the
1161
+ // edit -- or a blank one right after construction -- while the very same call with any
1162
+ // option took the redraw path and came out current.
1163
+ if (this.drawScheduled)
1164
+ this.draw();
1095
1165
  const copy = document.createElement('canvas');
1096
1166
  copy.width = this.canvas.width;
1097
1167
  copy.height = this.canvas.height;
@@ -1121,6 +1191,9 @@ export class Diagram {
1121
1191
  offY: this.offY,
1122
1192
  overviewVisible: this.overviewVisible,
1123
1193
  background: this.opts.background,
1194
+ // Drawing the overview recomputes this from the export frame. Left behind, the next
1195
+ // click on the on-screen minimap panned to a point in the export's coordinates.
1196
+ ovGeo: this.ovGeo,
1124
1197
  };
1125
1198
  try {
1126
1199
  this.ctx = outputContext;
@@ -1152,6 +1225,7 @@ export class Diagram {
1152
1225
  this.offY = previous.offY;
1153
1226
  this.overviewVisible = previous.overviewVisible;
1154
1227
  this.opts.background = previous.background;
1228
+ this.ovGeo = previous.ovGeo;
1155
1229
  }
1156
1230
  return output;
1157
1231
  }
@@ -1215,9 +1289,19 @@ export class Diagram {
1215
1289
  offX: this.offX,
1216
1290
  offY: this.offY,
1217
1291
  overviewVisible: this.overviewVisible,
1292
+ background: this.opts.background,
1293
+ // Drawing the overview recomputes this from the export frame. Left behind, the next
1294
+ // click on the on-screen minimap panned to a point in the export's coordinates.
1295
+ ovGeo: this.ovGeo,
1218
1296
  };
1219
1297
  try {
1220
1298
  this.ctx = surface;
1299
+ // draw() fills the frame with the theme background unconditionally, straight over the
1300
+ // backdrop the surface was given -- so passing the override to SvgSurface alone left it
1301
+ // invisible and the SVG disagreed with the PNG of the same options. takeScreenshot
1302
+ // swaps the theme value for the duration of the export; this does the same.
1303
+ if (options.background !== undefined)
1304
+ this.opts.background = options.background;
1221
1305
  this.width = width;
1222
1306
  this.height = height;
1223
1307
  // 1: an SVG has no device pixels to multiply by, and the viewBox carries the size.
@@ -1243,6 +1327,8 @@ export class Diagram {
1243
1327
  this.offX = previous.offX;
1244
1328
  this.offY = previous.offY;
1245
1329
  this.overviewVisible = previous.overviewVisible;
1330
+ this.opts.background = previous.background;
1331
+ this.ovGeo = previous.ovGeo;
1246
1332
  }
1247
1333
  return surface.toSvg();
1248
1334
  }
@@ -1273,8 +1359,8 @@ export class Diagram {
1273
1359
  this.runtimePulse = Math.max(this.runtimePulse, this.runtimeState.globalError?.pulse ?? 0, ...Object.values(this.runtimeState.nodes)
1274
1360
  .flatMap((node) => [node.errors.runtime?.pulse ?? 0, node.errors.load?.pulse ?? 0]));
1275
1361
  for (const node of this.nodes) {
1276
- const errors = this.runtimeState.nodes[node.id]?.errors;
1277
- const previousRuntime = previous.nodes[node.id]?.errors.runtime;
1362
+ const errors = getJsonKey(this.runtimeState.nodes, node.id)?.errors;
1363
+ const previousRuntime = getJsonKey(previous.nodes, node.id)?.errors.runtime;
1278
1364
  const runtimeError = errors?.runtime;
1279
1365
  node.runtimeError = runtimeError?.message ?? '';
1280
1366
  node.loadError = errors?.load?.message ?? '';
@@ -1308,8 +1394,10 @@ export class Diagram {
1308
1394
  if (node === undefined || port === undefined)
1309
1395
  return false;
1310
1396
  const state = this.getRuntimeState();
1311
- const nodeState = state.nodes[nodeId] ?? createDiagramNodeRuntimeState();
1312
- state.nodes[nodeId] = nodeState;
1397
+ // Node ids come from documents, so "__proto__" is reachable: a bare assignment there
1398
+ // writes the prototype instead of creating an entry, and the next read of it throws.
1399
+ const nodeState = getJsonKey(state.nodes, nodeId) ?? createDiagramNodeRuntimeState();
1400
+ setJsonKey(state.nodes, nodeId, nodeState);
1313
1401
  const current = nodeState.ports[direction][portId] ?? createDiagramPortRuntimeState();
1314
1402
  nodeState.ports[direction][portId] = { ...current, ...patch };
1315
1403
  this.setRuntimeState(state);
@@ -1337,8 +1425,8 @@ export class Diagram {
1337
1425
  : null;
1338
1426
  }
1339
1427
  const state = this.getRuntimeState();
1340
- const nodeState = state.nodes[id] ?? createDiagramNodeRuntimeState();
1341
- state.nodes[id] = nodeState;
1428
+ const nodeState = getJsonKey(state.nodes, id) ?? createDiagramNodeRuntimeState();
1429
+ setJsonKey(state.nodes, id, nodeState);
1342
1430
  // Only this kind's slot is written, so an error of the other kind that is
1343
1431
  // already on the node survives.
1344
1432
  if (message.length === 0)
@@ -1362,7 +1450,7 @@ export class Diagram {
1362
1450
  if (kind === undefined || kind === 'load')
1363
1451
  node.loadError = '';
1364
1452
  const state = this.getRuntimeState();
1365
- const nodeState = state.nodes[id];
1453
+ const nodeState = getJsonKey(state.nodes, id);
1366
1454
  if (nodeState !== undefined) {
1367
1455
  if (kind === undefined || kind === 'runtime')
1368
1456
  delete nodeState.errors.runtime;
@@ -1384,6 +1472,10 @@ export class Diagram {
1384
1472
  this.selectNode(node);
1385
1473
  }
1386
1474
  setZoom(scale) {
1475
+ // clamp() passes NaN straight through, and a NaN scale poisons offX/offY on the very next
1476
+ // line -- after which hit-testing, drawing and saveViewState are all dead, permanently.
1477
+ if (!Number.isFinite(scale))
1478
+ throw new RangeError('ssdiagram: zoom must be a finite number');
1387
1479
  const cx = this.width / 2;
1388
1480
  const cy = this.height / 2;
1389
1481
  const wx = (cx - this.offX) / this.scale;
@@ -1424,9 +1516,21 @@ export class Diagram {
1424
1516
  this.scheduleDraw();
1425
1517
  }
1426
1518
  setTypeColors(colors) {
1427
- this.opts.typeColors = { ...colors };
1519
+ this.setTypeColorsInternal(colors);
1428
1520
  this.scheduleDraw();
1429
1521
  }
1522
+ /**
1523
+ * Keyed the way port types are compared everywhere else. Connection compatibility is case- and
1524
+ * space-insensitive, so an imported scheme spelling a socket "candle " connects to "Candle" and
1525
+ * then fell through to a hash colour, showing one logical type in two colours in one diagram.
1526
+ */
1527
+ setTypeColorsInternal(colors) {
1528
+ const normalized = {};
1529
+ for (const [type, color] of Object.entries(colors)) {
1530
+ setJsonKey(normalized, normalizePortType(type), color);
1531
+ }
1532
+ this.opts.typeColors = normalized;
1533
+ }
1430
1534
  setTheme(t) {
1431
1535
  if (t.background !== undefined)
1432
1536
  this.opts.background = t.background;
@@ -1543,7 +1647,8 @@ export class Diagram {
1543
1647
  if (type === '')
1544
1648
  return light ? '#585c62' : '#9aa0a6';
1545
1649
  const map = this.opts.typeColors ?? {};
1546
- const base = map[type] ?? `hsl(${hashHue(type)}, 62%, 58%)`;
1650
+ const normalized = normalizePortType(type);
1651
+ const base = getJsonKey(map, normalized) ?? `hsl(${hashHue(normalized)}, 62%, 58%)`;
1547
1652
  return maxL !== undefined ? clampLightness(base, maxL) : base;
1548
1653
  }
1549
1654
  // Selection/hover accent. The dark-canvas blue washes out on a light canvas, so a light theme
@@ -1558,12 +1663,18 @@ export class Diagram {
1558
1663
  }
1559
1664
  // ---- hit testing (world coords) ---------------------------------
1560
1665
  portAt(wx, wy) {
1666
+ // Walks front to back and stops at the first node whose BODY covers the point, so a socket
1667
+ // hidden underneath a node drawn on top of it can no longer win the hit test. Without that
1668
+ // stop, clicking the visible body of the front node selected the one behind it, and on an
1669
+ // output socket started dragging a wire out of a port that is not on screen.
1561
1670
  for (let i = this.nodes.length - 1; i >= 0; i -= 1) {
1562
1671
  const n = this.nodes[i];
1563
1672
  for (const p of [...n.inPorts, ...n.outPorts]) {
1564
1673
  if ((wx - p.cx) ** 2 + (wy - p.cy) ** 2 <= (PORT_R + 4) ** 2)
1565
1674
  return { node: n, port: p };
1566
1675
  }
1676
+ if (wx >= n.x && wx <= n.x + n.w && wy >= n.y && wy <= n.y + n.h)
1677
+ return null;
1567
1678
  }
1568
1679
  return null;
1569
1680
  }
@@ -1868,6 +1979,9 @@ export class Diagram {
1868
1979
  fromPort: l.from.portId,
1869
1980
  to: t,
1870
1981
  toPort: l.to.portId,
1982
+ // The clipboard is built from saveDocument(), so it carries the style;
1983
+ // rebuilding without it silently turned every pasted dashed link solid.
1984
+ style: l.style,
1871
1985
  metadata: l.metadata,
1872
1986
  });
1873
1987
  }
@@ -1958,14 +2072,21 @@ export class Diagram {
1958
2072
  // move-beyond-tolerance, pointerup, or any other gesture.
1959
2073
  this.cancelLongPress();
1960
2074
  this.relinkCandidate = null;
1961
- this.lpStart = { sx: e.clientX, sy: e.clientY, px: e.clientX, py: e.clientY };
1962
- const downSx = e.clientX, downSy = e.clientY;
1963
- const r0 = this.canvas.getBoundingClientRect();
1964
- const localSx = downSx - r0.left, localSy = downSy - r0.top;
1965
- this.lpTimer = setTimeout(() => {
1966
- this.lpTimer = null;
1967
- this.fireContextMenu(localSx, localSy, downSx, downSy);
1968
- }, this.lpDelayMs);
2075
+ // Touch only. A mouse has a button for this, and arming the timer for it meant holding
2076
+ // the left button still -- which is what positioning a node precisely looks like --
2077
+ // opened the menu after half a second and wiped the drag in progress: the pixels the
2078
+ // node had already travelled stayed, but never reached history, so Ctrl+Z could not
2079
+ // take them back.
2080
+ if (e.pointerType !== 'mouse') {
2081
+ this.lpStart = { sx: e.clientX, sy: e.clientY, px: e.clientX, py: e.clientY };
2082
+ const downSx = e.clientX, downSy = e.clientY;
2083
+ const r0 = this.canvas.getBoundingClientRect();
2084
+ const localSx = downSx - r0.left, localSy = downSy - r0.top;
2085
+ this.lpTimer = setTimeout(() => {
2086
+ this.lpTimer = null;
2087
+ this.fireContextMenu(localSx, localSy, downSx, downSy);
2088
+ }, this.lpDelayMs);
2089
+ }
1969
2090
  this.canvas.focus();
1970
2091
  const [sx, sy] = localXY(e);
1971
2092
  if (this.ovHit(sx, sy)) {
@@ -2050,14 +2171,16 @@ export class Diagram {
2050
2171
  if (node !== null) {
2051
2172
  const add = e.shiftKey || e.ctrlKey || e.metaKey;
2052
2173
  if (this.permissions.select) {
2053
- if (add) {
2174
+ // A right-click still selects what it landed on, so the menu that follows acts
2175
+ // on the node under the cursor -- but only the primary button may drag it.
2176
+ if (add && e.button === 0) {
2054
2177
  this.toggleSelect(node);
2055
2178
  return;
2056
2179
  }
2057
2180
  if (!this.selectedNodes.has(node))
2058
2181
  this.selectNode(node);
2059
2182
  }
2060
- if (this.permissions.moveNodes) {
2183
+ if (this.permissions.moveNodes && e.button === 0) {
2061
2184
  this.dragNode = node;
2062
2185
  this.dragAnchor = { wx, wy };
2063
2186
  const moving = this.selectedNodes.has(node) ? [...this.selectedNodes] : [node];
@@ -2073,6 +2196,10 @@ export class Diagram {
2073
2196
  this.selectLink(link);
2074
2197
  return;
2075
2198
  }
2199
+ // A secondary button on empty canvas is on its way to the context menu: it must not
2200
+ // clear the selection the menu is about to act on, nor start a rubber band.
2201
+ if (e.button !== 0 && e.button !== 1)
2202
+ return;
2076
2203
  // empty space: middle-button / Ctrl / Alt / touch = pan;
2077
2204
  // else rubber-band select. Touch always pans because mobile
2078
2205
  // users can't hold modifier keys and rubber-band drag-select
@@ -2228,8 +2355,14 @@ export class Diagram {
2228
2355
  const moves = this.dragStart
2229
2356
  .map((it) => ({ id: it.n.id, fromX: it.x, fromY: it.y, toX: it.n.x, toY: it.n.y }))
2230
2357
  .filter((m) => m.fromX !== m.toX || m.fromY !== m.toY);
2231
- for (const it of this.dragStart)
2232
- this.emit('nodeMoved', { node: it.n });
2358
+ // Announce the same set the undo step records. Emitting for every node the drag
2359
+ // touched reported a move for a plain selection click -- one per selected node --
2360
+ // and a host doing dirty tracking marked the strategy modified for nothing.
2361
+ const moved = new Set(moves.map((m) => m.id));
2362
+ for (const it of this.dragStart) {
2363
+ if (moved.has(it.n.id))
2364
+ this.emit('nodeMoved', { node: it.n });
2365
+ }
2233
2366
  this.dragNode = null;
2234
2367
  this.dragStart = [];
2235
2368
  if (moves.length > 0) {
@@ -2321,6 +2454,10 @@ export class Diagram {
2321
2454
  const [wx, wy] = this.toWorld(sx, sy);
2322
2455
  if (this.portAt(wx, wy) !== null)
2323
2456
  return;
2457
+ // A wire is something, not empty canvas: without this the zoomToFit fallback below
2458
+ // threw away the zoom and pan the user had set, on a double click aimed at a link.
2459
+ if (this.linkAt(wx, wy) !== null)
2460
+ return;
2324
2461
  const node = this.nodeAt(wx, wy);
2325
2462
  if (node !== null) {
2326
2463
  if (this.permissions.inspect && node.openAction.length > 0) {
@@ -2367,6 +2504,11 @@ export class Diagram {
2367
2504
  this.linking = null;
2368
2505
  this.relinking = null;
2369
2506
  this.relinkCandidate = null;
2507
+ // The two the block used to miss: a finger that started on the minimap kept
2508
+ // panning the viewport against the pinch, and a cancelled wire drag left its
2509
+ // magnet highlight burned onto a socket until something else redrew it.
2510
+ this.ovDragging = false;
2511
+ this.linkSnap = null;
2370
2512
  }
2371
2513
  }, { passive: false });
2372
2514
  this.listen(this.canvas, 'touchmove', (e) => {
@@ -2429,15 +2571,18 @@ export class Diagram {
2429
2571
  this.setSelection(this.nodes.slice());
2430
2572
  return;
2431
2573
  }
2432
- // Ctrl+Z = undo; Ctrl+Y or Ctrl+Shift+Z = redo.
2574
+ // Ctrl+Z = undo; Ctrl+Y or Ctrl+Shift+Z = redo. Routed through a handler rather than
2575
+ // called directly: a component wrapping this renderer has its own rules about when
2576
+ // history may run and its own events to announce it, and the keyboard must obey them
2577
+ // like every other entry point.
2433
2578
  if (mod && this.permissions.history && e.code === 'KeyZ' && !e.shiftKey) {
2434
2579
  e.preventDefault();
2435
- this.undo();
2580
+ this.historyShortcut('undo');
2436
2581
  return;
2437
2582
  }
2438
2583
  if (mod && this.permissions.history && (e.code === 'KeyY' || (e.code === 'KeyZ' && e.shiftKey))) {
2439
2584
  e.preventDefault();
2440
- this.redo();
2585
+ this.historyShortcut('redo');
2441
2586
  return;
2442
2587
  }
2443
2588
  });
@@ -2532,20 +2677,26 @@ export class Diagram {
2532
2677
  if (options.overview)
2533
2678
  this.drawOverview();
2534
2679
  if (options.runtime)
2535
- this.drawGlobalError();
2680
+ this.drawGlobalError(options.transient);
2536
2681
  if (options.transient)
2537
2682
  this.drawTooltip();
2538
2683
  if (options.transient && (this.introStart !== null || this.globalErrorFlashStart !== null
2539
2684
  || this.nodes.some((n) => n.errorFlashStart !== null)))
2540
2685
  this.scheduleDraw(); // keep entrance / error feedback animating
2541
2686
  }
2542
- drawGlobalError() {
2687
+ /**
2688
+ * `transient` gates the flash exactly as it gates every other animation, which an export must
2689
+ * not see: it would bake in whatever point of the pulse it caught, so the same export taken
2690
+ * twice differed in opacity. The gate also keeps the export from consuming the animation --
2691
+ * clearing globalErrorFlashStart from an export cut the flash short on screen.
2692
+ */
2693
+ drawGlobalError(transient) {
2543
2694
  const error = this.runtimeState.globalError;
2544
2695
  if (error === null)
2545
2696
  return;
2546
2697
  const ctx = this.ctx;
2547
2698
  let flashAlpha = 1;
2548
- if (this.globalErrorFlashStart !== null) {
2699
+ if (transient && this.globalErrorFlashStart !== null) {
2549
2700
  const elapsed = performance.now() - this.globalErrorFlashStart;
2550
2701
  if (elapsed >= ERROR_FLASH_MS) {
2551
2702
  this.globalErrorFlashStart = null;
@@ -2734,7 +2885,7 @@ export class Diagram {
2734
2885
  const ctx = this.ctx;
2735
2886
  const hasLoadError = options.runtime && n.loadError.length > 0;
2736
2887
  const hasRuntimeError = options.runtime && n.runtimeError.length > 0;
2737
- const runtime = options.runtime ? this.runtimeState.nodes[n.id] : undefined;
2888
+ const runtime = options.runtime ? getJsonKey(this.runtimeState.nodes, n.id) : undefined;
2738
2889
  const active = options.runtime
2739
2890
  && (this.runtimeState.activeNodeId === n.id || runtime?.active === true);
2740
2891
  roundRect(ctx, n.x, n.y, n.w, n.h, 6);
@@ -2834,7 +2985,7 @@ export class Diagram {
2834
2985
  }
2835
2986
  }
2836
2987
  portRuntimeState(node, port) {
2837
- return this.runtimeState.nodes[node.id]?.ports[port.direction][port.id] ?? null;
2988
+ return getJsonKey(this.runtimeState.nodes, node.id)?.ports[port.direction][port.id] ?? null;
2838
2989
  }
2839
2990
  // Symmetric jump-over: any segment of THIS link hops the perpendicular
2840
2991
  // segments of links drawn before it (H over earlier V, V over earlier