@stocksharp/diagram 0.5.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 (67) hide show
  1. package/README.md +72 -11
  2. package/dist/esm/canvas-renderer.js +210 -54
  3. package/dist/esm/canvas-renderer.js.map +1 -1
  4. package/dist/esm/core/document.js +24 -12
  5. package/dist/esm/core/document.js.map +1 -1
  6. package/dist/esm/core/history.js +12 -3
  7. package/dist/esm/core/history.js.map +1 -1
  8. package/dist/esm/core/json.js +10 -0
  9. package/dist/esm/core/json.js.map +1 -1
  10. package/dist/esm/diagram/context-menu.js +93 -28
  11. package/dist/esm/diagram/context-menu.js.map +1 -1
  12. package/dist/esm/diagram/event-emitter.js +12 -1
  13. package/dist/esm/diagram/event-emitter.js.map +1 -1
  14. package/dist/esm/diagram/palette.js +9 -1
  15. package/dist/esm/diagram/palette.js.map +1 -1
  16. package/dist/esm/diagram/stocksharp-diagram.js +50 -8
  17. package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
  18. package/dist/esm/diagram/types.js +29 -2
  19. package/dist/esm/diagram/types.js.map +1 -1
  20. package/dist/esm/embed.js +70 -14
  21. package/dist/esm/embed.js.map +1 -1
  22. package/dist/esm/i18n.js +15 -4
  23. package/dist/esm/i18n.js.map +1 -1
  24. package/dist/esm/index.js.map +1 -1
  25. package/dist/esm/svg-surface.js +10 -2
  26. package/dist/esm/svg-surface.js.map +1 -1
  27. package/dist/ssdiagram.js +376 -122
  28. package/dist/ssdiagram.js.map +3 -3
  29. package/dist/types/canvas-renderer.d.ts +20 -1
  30. package/dist/types/canvas-renderer.d.ts.map +1 -1
  31. package/dist/types/core/document.d.ts.map +1 -1
  32. package/dist/types/core/history.d.ts +1 -0
  33. package/dist/types/core/history.d.ts.map +1 -1
  34. package/dist/types/core/json.d.ts +8 -0
  35. package/dist/types/core/json.d.ts.map +1 -1
  36. package/dist/types/diagram/api.d.ts +7 -1
  37. package/dist/types/diagram/api.d.ts.map +1 -1
  38. package/dist/types/diagram/context-menu.d.ts +8 -0
  39. package/dist/types/diagram/context-menu.d.ts.map +1 -1
  40. package/dist/types/diagram/event-emitter.d.ts.map +1 -1
  41. package/dist/types/diagram/palette.d.ts.map +1 -1
  42. package/dist/types/diagram/stocksharp-diagram.d.ts +7 -0
  43. package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
  44. package/dist/types/diagram/types.d.ts +5 -0
  45. package/dist/types/diagram/types.d.ts.map +1 -1
  46. package/dist/types/embed.d.ts.map +1 -1
  47. package/dist/types/i18n.d.ts +10 -0
  48. package/dist/types/i18n.d.ts.map +1 -1
  49. package/dist/types/index.d.ts +1 -0
  50. package/dist/types/index.d.ts.map +1 -1
  51. package/dist/types/svg-surface.d.ts +1 -1
  52. package/dist/types/svg-surface.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/src/canvas-renderer.ts +190 -45
  55. package/src/core/document.ts +25 -12
  56. package/src/core/history.ts +13 -3
  57. package/src/core/json.ts +11 -0
  58. package/src/diagram/api.ts +7 -0
  59. package/src/diagram/context-menu.ts +112 -27
  60. package/src/diagram/event-emitter.ts +10 -1
  61. package/src/diagram/palette.ts +9 -2
  62. package/src/diagram/stocksharp-diagram.ts +50 -8
  63. package/src/diagram/types.ts +29 -3
  64. package/src/embed.ts +78 -14
  65. package/src/i18n.ts +36 -6
  66. package/src/index.ts +5 -0
  67. package/src/svg-surface.ts +11 -2
package/README.md CHANGED
@@ -154,7 +154,9 @@ missing type. Sites can localize that message through
154
154
  `data-diagram-missing-element="Missing: {typeId}"` on the host.
155
155
 
156
156
  Every text the embedded control shows comes from the host, so a translated page
157
- carries no English leftovers:
157
+ carries no English leftovers. These attributes are per-instance overrides on top
158
+ of the page's translation bundle (see *Translating the control* below); set them
159
+ when one diagram has to read differently from the rest of the page:
158
160
 
159
161
  | Host attribute | What it renames |
160
162
  |---|---|
@@ -163,7 +165,9 @@ carries no English leftovers:
163
165
  | `data-diagram-missing-element="Missing: {typeId}"` | the placeholder for an element the palette lacks |
164
166
 
165
167
  The same labels are available to custom integrations as the `fullscreenLabels`
166
- option and `setFullscreenLabels()` on `StockSharpDiagram`.
168
+ option and `setFullscreenLabels()` on `StockSharpDiagram`. With neither the
169
+ attribute nor the option, the wording comes from the bundle, so a page that has
170
+ already been translated needs no per-diagram markup at all.
167
171
 
168
172
  ### Node actions and errors
169
173
 
@@ -190,10 +194,20 @@ the menu was opened over a socket.
190
194
 
191
195
  ### The context menu
192
196
 
193
- The control has no menu widget: right-click suppresses the browser's own menu
194
- and emits `contextMenuRequested` with page coordinates, whatever was hit, and
195
- the menu contents already resolved. Draw it however the host draws menus, then
196
- send the choice back through `executeContextCommand()`.
197
+ Right-click opens the control's own menu, with an Export submenu. It is on by
198
+ default, because right-click suppresses the browser's menu either way and a
199
+ control that takes one away owes one back. It needs no stylesheet: it is styled
200
+ inline over `--ssdiagram-*` custom properties (`--ssdiagram-menu-background`,
201
+ `--ssdiagram-menu-border`, `--ssdiagram-menu-color`, `--ssdiagram-menu-hover`,
202
+ `--ssdiagram-menu-disabled-color`, `--ssdiagram-menu-font`), falling back to the
203
+ `--ssdiagram-control-*` values the fullscreen button uses. Its class names —
204
+ `ssdiagram-context-menu`, `-item`, `-label`, `-arrow`, `-submenu`, `-separator` —
205
+ are stable if you would rather use CSS.
206
+
207
+ To draw your own instead, pass `showContextMenu: false` (or call
208
+ `setContextMenuEnabled(false)`) and handle `contextMenuRequested`, which is
209
+ emitted either way — and emitted *before* the built-in menu opens, so a handler
210
+ can still switch it off.
197
211
 
198
212
  `commands` is a tree in display order. An entry with a `group` is a submenu;
199
213
  anything else is a command. `enabled` is computed for both — a submenu is off
@@ -209,8 +223,10 @@ diagram.on('contextMenuRequested', ({ x, y, commands }) => {
209
223
  menu.on('pick', (command) => diagram.executeContextCommand(command));
210
224
  ```
211
225
 
212
- `undo`, `redo`, `cut`, `copy`, `paste` and `delete` are carried out by the
213
- control itself. The rest are requests it cannot answer alone: `open`,
226
+ `undo`, `redo`, `cut`, `copy`, `paste`, `delete` and `overview` are carried out
227
+ by the control itself. `overview` toggles the corner minimap, and reports its
228
+ current state as `checked` on the command so a menu can tick it rather than offer
229
+ a dead show/hide pair. The rest are requests it cannot answer alone: `open`,
214
230
  `properties` and `help` raise `nodeOpen` / `nodeProperties` / `nodeHelp`, and
215
231
  the `export` submenu raises `exportRequested` with the chosen format. Nothing
216
232
  is produced until the host acts on it — only the host knows where the result
@@ -226,9 +242,54 @@ diagram.on('exportRequested', ({ format }) => {
226
242
  ```
227
243
 
228
244
  Export is offered whenever the diagram has a node, including in read-only mode,
229
- because it only reads. Menu labels come from the host's i18n bundle
230
- (`ctxExportAs`, `ctxExportDocument`, `ctxExportPng`, `ctxExportSvg`, and the
231
- keys named after the other commands).
245
+ because it only reads.
246
+
247
+ #### Translating the control
248
+
249
+ There is one mechanism, and it is `window.__designerI18n`: an object typed by the
250
+ exported `DesignerI18n`, holding every string the package renders itself — the
251
+ menu, the fullscreen button's tooltip, and the embed failure notes. It is read on
252
+ every lookup, so it can be assigned at any point, before or after the bundle
253
+ loads, and reassigned later to change language. A key that is missing or empty
254
+ falls back to English.
255
+
256
+ Per-instance settings — the `fullscreenLabels` option, `setFullscreenLabels()`,
257
+ and the `data-diagram-*` attributes above — override the bundle for one diagram.
258
+ Resolution is: explicit setting, then bundle, then the built-in English.
259
+
260
+ The context menu is rebuilt on every open and needs nothing after a language
261
+ change. The fullscreen button is drawn once, so call `refreshLabels()` to
262
+ re-read the bundle for it.
263
+
264
+ ```ts
265
+ import type { DesignerI18n } from '@stocksharp/diagram';
266
+
267
+ const labels: DesignerI18n = { cut: 'Вырезать', ctxDelete: 'Удалить', /* … */ };
268
+ (window as unknown as { __designerI18n: DesignerI18n }).__designerI18n = labels;
269
+ ```
270
+
271
+ The key is not always the command name, so here is the whole menu:
272
+
273
+ | command | key | English |
274
+ |---|---|---|
275
+ | `undo` / `redo` | `undo` / `redo` | Undo / Redo |
276
+ | `cut` / `copy` / `paste` | `cut` / `copy` / `paste` | Cut / Copy / Paste |
277
+ | `open` | `ctxOpen` | Open |
278
+ | `delete` | `ctxDelete` | Delete |
279
+ | *(the submenu)* | `ctxExportAs` | Export as |
280
+ | `exportDocument` | `ctxExportDocument` | Scheme |
281
+ | `exportPng` / `exportSvg` | `ctxExportPng` / `ctxExportSvg` | PNG image / SVG image |
282
+ | `overview` | `ctxOverview` | Overview |
283
+ | `properties` | `properties` | Properties |
284
+ | `help` | `ctxHelp` | Help |
285
+ | *(fullscreen button)* | `fullscreenEnter` / `fullscreenExit` | Enter / Exit fullscreen |
286
+ | *(embed failures)* | `embedErrorLoad` / `embedErrorEmpty` / `embedErrorDraw` | the three notes shown in place of a diagram |
287
+ | *(embed placeholder)* | `embedMissingElement` | the missing-element tooltip |
288
+
289
+ `ctxDelete` is separate from the solution explorer's `delete` on purpose: that
290
+ one removes a whole strategy, this one the selected nodes and links, and a
291
+ language that declines the object cannot serve both from one key. The same goes
292
+ for `ctxExportAs` against `ctxExport`.
232
293
 
233
294
  Runtime failures flash the node border before leaving it red. Errors found
234
295
  while loading a scheme use a red background. Hovering either state shows the
@@ -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
  }
@@ -1876,6 +1990,26 @@ export class Diagram {
1876
1990
  return added.map((node) => node.id);
1877
1991
  }
1878
1992
  // ---- input ------------------------------------------------------
1993
+ // Ends hover and everything hanging off it: highlight, cursor and the delayed tooltip.
1994
+ clearHover() {
1995
+ if (this.hoverPort !== null)
1996
+ this.emit('portHover', { node: this.hoverPort.node, port: this.hoverPort.port, hovering: false });
1997
+ if (this.hoverNode !== null)
1998
+ this.emit('nodeHover', { node: this.hoverNode, hovering: false });
1999
+ if (this.hoveredLink !== null)
2000
+ this.emit('linkHover', { link: this.hoveredLink, hovering: false });
2001
+ this.hoverPort = null;
2002
+ this.hoverNode = null;
2003
+ this.hoveredLink = null;
2004
+ this.tipTarget = null;
2005
+ this.tipShow = false;
2006
+ if (this.tipTimer !== null) {
2007
+ clearTimeout(this.tipTimer);
2008
+ this.tipTimer = null;
2009
+ }
2010
+ this.canvas.style.cursor = 'default';
2011
+ this.scheduleDraw();
2012
+ }
1879
2013
  cancelLongPress() {
1880
2014
  if (this.lpTimer !== null) {
1881
2015
  clearTimeout(this.lpTimer);
@@ -1914,7 +2048,10 @@ export class Diagram {
1914
2048
  this.relinking = null;
1915
2049
  this.relinkCandidate = null;
1916
2050
  this.linkSnap = null;
1917
- this.scheduleDraw();
2051
+ // A menu is about to cover this spot, and it opens under a cursor that has not moved --
2052
+ // which fires no boundary event, so nothing else would end the hover. Without this the
2053
+ // tooltip armed by the last pointermove appears 400ms later, through the open menu.
2054
+ this.clearHover();
1918
2055
  this.emit('contextMenu', { x: pageX, y: pageY, link, node, port });
1919
2056
  }
1920
2057
  listen(target, type, listener, options) {
@@ -1935,14 +2072,21 @@ export class Diagram {
1935
2072
  // move-beyond-tolerance, pointerup, or any other gesture.
1936
2073
  this.cancelLongPress();
1937
2074
  this.relinkCandidate = null;
1938
- this.lpStart = { sx: e.clientX, sy: e.clientY, px: e.clientX, py: e.clientY };
1939
- const downSx = e.clientX, downSy = e.clientY;
1940
- const r0 = this.canvas.getBoundingClientRect();
1941
- const localSx = downSx - r0.left, localSy = downSy - r0.top;
1942
- this.lpTimer = setTimeout(() => {
1943
- this.lpTimer = null;
1944
- this.fireContextMenu(localSx, localSy, downSx, downSy);
1945
- }, 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
+ }
1946
2090
  this.canvas.focus();
1947
2091
  const [sx, sy] = localXY(e);
1948
2092
  if (this.ovHit(sx, sy)) {
@@ -2027,14 +2171,16 @@ export class Diagram {
2027
2171
  if (node !== null) {
2028
2172
  const add = e.shiftKey || e.ctrlKey || e.metaKey;
2029
2173
  if (this.permissions.select) {
2030
- 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) {
2031
2177
  this.toggleSelect(node);
2032
2178
  return;
2033
2179
  }
2034
2180
  if (!this.selectedNodes.has(node))
2035
2181
  this.selectNode(node);
2036
2182
  }
2037
- if (this.permissions.moveNodes) {
2183
+ if (this.permissions.moveNodes && e.button === 0) {
2038
2184
  this.dragNode = node;
2039
2185
  this.dragAnchor = { wx, wy };
2040
2186
  const moving = this.selectedNodes.has(node) ? [...this.selectedNodes] : [node];
@@ -2050,6 +2196,10 @@ export class Diagram {
2050
2196
  this.selectLink(link);
2051
2197
  return;
2052
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;
2053
2203
  // empty space: middle-button / Ctrl / Alt / touch = pan;
2054
2204
  // else rubber-band select. Touch always pans because mobile
2055
2205
  // users can't hold modifier keys and rubber-band drag-select
@@ -2205,8 +2355,14 @@ export class Diagram {
2205
2355
  const moves = this.dragStart
2206
2356
  .map((it) => ({ id: it.n.id, fromX: it.x, fromY: it.y, toX: it.n.x, toY: it.n.y }))
2207
2357
  .filter((m) => m.fromX !== m.toX || m.fromY !== m.toY);
2208
- for (const it of this.dragStart)
2209
- 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
+ }
2210
2366
  this.dragNode = null;
2211
2367
  this.dragStart = [];
2212
2368
  if (moves.length > 0) {
@@ -2292,30 +2448,16 @@ export class Diagram {
2292
2448
  this.emitViewChanged(true);
2293
2449
  this.scheduleDraw();
2294
2450
  }, { passive: false });
2295
- this.listen(this.canvas, 'pointerleave', () => {
2296
- if (this.hoverPort !== null)
2297
- this.emit('portHover', { node: this.hoverPort.node, port: this.hoverPort.port, hovering: false });
2298
- if (this.hoverNode !== null)
2299
- this.emit('nodeHover', { node: this.hoverNode, hovering: false });
2300
- if (this.hoveredLink !== null)
2301
- this.emit('linkHover', { link: this.hoveredLink, hovering: false });
2302
- this.hoverPort = null;
2303
- this.hoverNode = null;
2304
- this.hoveredLink = null;
2305
- this.tipTarget = null;
2306
- this.tipShow = false;
2307
- if (this.tipTimer !== null) {
2308
- clearTimeout(this.tipTimer);
2309
- this.tipTimer = null;
2310
- }
2311
- this.canvas.style.cursor = 'default';
2312
- this.scheduleDraw();
2313
- });
2451
+ this.listen(this.canvas, 'pointerleave', () => this.clearHover());
2314
2452
  this.listen(this.canvas, 'dblclick', (e) => {
2315
2453
  const [sx, sy] = localXY(e);
2316
2454
  const [wx, wy] = this.toWorld(sx, sy);
2317
2455
  if (this.portAt(wx, wy) !== null)
2318
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;
2319
2461
  const node = this.nodeAt(wx, wy);
2320
2462
  if (node !== null) {
2321
2463
  if (this.permissions.inspect && node.openAction.length > 0) {
@@ -2362,6 +2504,11 @@ export class Diagram {
2362
2504
  this.linking = null;
2363
2505
  this.relinking = null;
2364
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;
2365
2512
  }
2366
2513
  }, { passive: false });
2367
2514
  this.listen(this.canvas, 'touchmove', (e) => {
@@ -2424,15 +2571,18 @@ export class Diagram {
2424
2571
  this.setSelection(this.nodes.slice());
2425
2572
  return;
2426
2573
  }
2427
- // 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.
2428
2578
  if (mod && this.permissions.history && e.code === 'KeyZ' && !e.shiftKey) {
2429
2579
  e.preventDefault();
2430
- this.undo();
2580
+ this.historyShortcut('undo');
2431
2581
  return;
2432
2582
  }
2433
2583
  if (mod && this.permissions.history && (e.code === 'KeyY' || (e.code === 'KeyZ' && e.shiftKey))) {
2434
2584
  e.preventDefault();
2435
- this.redo();
2585
+ this.historyShortcut('redo');
2436
2586
  return;
2437
2587
  }
2438
2588
  });
@@ -2527,20 +2677,26 @@ export class Diagram {
2527
2677
  if (options.overview)
2528
2678
  this.drawOverview();
2529
2679
  if (options.runtime)
2530
- this.drawGlobalError();
2680
+ this.drawGlobalError(options.transient);
2531
2681
  if (options.transient)
2532
2682
  this.drawTooltip();
2533
2683
  if (options.transient && (this.introStart !== null || this.globalErrorFlashStart !== null
2534
2684
  || this.nodes.some((n) => n.errorFlashStart !== null)))
2535
2685
  this.scheduleDraw(); // keep entrance / error feedback animating
2536
2686
  }
2537
- 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) {
2538
2694
  const error = this.runtimeState.globalError;
2539
2695
  if (error === null)
2540
2696
  return;
2541
2697
  const ctx = this.ctx;
2542
2698
  let flashAlpha = 1;
2543
- if (this.globalErrorFlashStart !== null) {
2699
+ if (transient && this.globalErrorFlashStart !== null) {
2544
2700
  const elapsed = performance.now() - this.globalErrorFlashStart;
2545
2701
  if (elapsed >= ERROR_FLASH_MS) {
2546
2702
  this.globalErrorFlashStart = null;
@@ -2729,7 +2885,7 @@ export class Diagram {
2729
2885
  const ctx = this.ctx;
2730
2886
  const hasLoadError = options.runtime && n.loadError.length > 0;
2731
2887
  const hasRuntimeError = options.runtime && n.runtimeError.length > 0;
2732
- const runtime = options.runtime ? this.runtimeState.nodes[n.id] : undefined;
2888
+ const runtime = options.runtime ? getJsonKey(this.runtimeState.nodes, n.id) : undefined;
2733
2889
  const active = options.runtime
2734
2890
  && (this.runtimeState.activeNodeId === n.id || runtime?.active === true);
2735
2891
  roundRect(ctx, n.x, n.y, n.w, n.h, 6);
@@ -2829,7 +2985,7 @@ export class Diagram {
2829
2985
  }
2830
2986
  }
2831
2987
  portRuntimeState(node, port) {
2832
- 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;
2833
2989
  }
2834
2990
  // Symmetric jump-over: any segment of THIS link hops the perpendicular
2835
2991
  // segments of links drawn before it (H over earlier V, V over earlier