@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/dist/ssdiagram.js CHANGED
@@ -67,6 +67,9 @@ var SSDiagram = (() => {
67
67
  }
68
68
  target[key] = value;
69
69
  }
70
+ function getJsonKey(target, key) {
71
+ return Object.prototype.hasOwnProperty.call(target, key) ? target[key] : void 0;
72
+ }
70
73
 
71
74
  // src/core/model.ts
72
75
  function toPortDynamicMode(value) {
@@ -110,15 +113,8 @@ var SSDiagram = (() => {
110
113
  return normalizeLink({ ...link, id }, `$.links[${index}]`);
111
114
  });
112
115
  const rawZones = mapElements(input.zones, "$.zones");
113
- const usedZoneIds = /* @__PURE__ */ new Set();
114
- const zones = rawZones.map((zone, index) => {
115
- const normalized = normalizeZone(zone, `$.zones[${index}]`);
116
- if (usedZoneIds.has(normalized.id)) {
117
- throw new DiagramDocumentError(`duplicate zone id "${normalized.id}"`, `$.zones[${index}].id`);
118
- }
119
- usedZoneIds.add(normalized.id);
120
- return normalized;
121
- });
116
+ const zones = rawZones.map((zone, index) => normalizeZone(zone, `$.zones[${index}]`));
117
+ validateUniqueZones(zones);
122
118
  const document2 = {
123
119
  version: DIAGRAM_DOCUMENT_VERSION,
124
120
  nodes,
@@ -239,8 +235,9 @@ var SSDiagram = (() => {
239
235
  return {
240
236
  id: requireIdentifier(input.id, `${path}.id`),
241
237
  name: requireString(input.name, `${path}.name`),
242
- x: requireNumber(input.x, `${path}.x`),
243
- y: requireNumber(input.y, `${path}.y`),
238
+ // Finite, like node coordinates: NaN survives every arithmetic check and writes as null.
239
+ x: requireFiniteNumber(input.x, `${path}.x`),
240
+ y: requireFiniteNumber(input.y, `${path}.y`),
244
241
  width: requirePositiveSize(input.width, `${path}.width`),
245
242
  height: requirePositiveSize(input.height, `${path}.height`),
246
243
  color: input.color === void 0 || input.color === null ? "" : requireString(input.color, `${path}.color`),
@@ -248,7 +245,7 @@ var SSDiagram = (() => {
248
245
  };
249
246
  }
250
247
  function requirePositiveSize(value, path) {
251
- const size = requireNumber(value, path);
248
+ const size = requireFiniteNumber(value, path);
252
249
  if (!(size > 0)) throw new DiagramDocumentError("size must be greater than zero", path);
253
250
  return size;
254
251
  }
@@ -330,6 +327,14 @@ var SSDiagram = (() => {
330
327
  portId: requireIdentifier(endpoint.portId, `${path}.portId`)
331
328
  };
332
329
  }
330
+ function validateUniqueZones(zones) {
331
+ const seen = /* @__PURE__ */ new Set();
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
+ }
333
338
  function validateDocument(document2) {
334
339
  const nodes = /* @__PURE__ */ new Map();
335
340
  for (let index = 0; index < document2.nodes.length; index++) {
@@ -360,6 +365,7 @@ var SSDiagram = (() => {
360
365
  if (endpoints.has(key)) throw new DiagramDocumentError("duplicate link endpoints", path);
361
366
  endpoints.add(key);
362
367
  }
368
+ validateUniqueZones(document2.zones);
363
369
  }
364
370
  function validateUniquePorts(ports, path) {
365
371
  const ids = /* @__PURE__ */ new Set();
@@ -573,7 +579,13 @@ var SSDiagram = (() => {
573
579
  throw error;
574
580
  }
575
581
  }
582
+ assertNoOpenTransaction(operation) {
583
+ if (this.transactions.length > 0) {
584
+ throw new Error(`Cannot ${operation} command history inside a transaction.`);
585
+ }
586
+ }
576
587
  undo() {
588
+ this.assertNoOpenTransaction("undo");
577
589
  const command = this.undoCommands.pop();
578
590
  if (command === void 0) return false;
579
591
  try {
@@ -587,6 +599,7 @@ var SSDiagram = (() => {
587
599
  return true;
588
600
  }
589
601
  redo() {
602
+ this.assertNoOpenTransaction("redo");
590
603
  const command = this.redoCommands.pop();
591
604
  if (command === void 0) return false;
592
605
  try {
@@ -600,9 +613,7 @@ var SSDiagram = (() => {
600
613
  return true;
601
614
  }
602
615
  clear() {
603
- if (this.transactions.length > 0) {
604
- throw new Error("Cannot clear command history inside a transaction.");
605
- }
616
+ this.assertNoOpenTransaction("clear");
606
617
  this.undoCommands.length = 0;
607
618
  this.redoCommands.length = 0;
608
619
  this.notify();
@@ -1045,12 +1056,16 @@ var SSDiagram = (() => {
1045
1056
  this.emit(`<rect x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" fill="none" ${this.strokeAttrs()}/>`);
1046
1057
  }
1047
1058
  // ===== text ==========================================================
1048
- fillText(text, x, y) {
1059
+ fillText(text, x, y, maxWidth) {
1049
1060
  if (text === "") return;
1050
1061
  const anchor = ANCHOR[this.state.textAlign] ?? "start";
1051
1062
  const baseline = BASELINE[this.state.textBaseline] ?? "alphabetic";
1063
+ let constraint = "";
1064
+ if (maxWidth !== void 0 && maxWidth > 0 && this.measureText(text).width > maxWidth) {
1065
+ constraint = ` textLength="${n(maxWidth)}" lengthAdjust="spacingAndGlyphs"`;
1066
+ }
1052
1067
  this.emit(
1053
- `<text x="${n(x)}" y="${n(y)}" style="font:${escapeXml(this.state.font)}" text-anchor="${anchor}" dominant-baseline="${baseline}" ${this.fillAttrs()}>${escapeXml(text)}</text>`
1068
+ `<text x="${n(x)}" y="${n(y)}" style="font:${escapeXml(this.state.font)}" text-anchor="${anchor}" dominant-baseline="${baseline}"${constraint} ${this.fillAttrs()}>${escapeXml(text)}</text>`
1054
1069
  );
1055
1070
  }
1056
1071
  measureText(text) {
@@ -1358,15 +1373,20 @@ var SSDiagram = (() => {
1358
1373
  this.introStart = null;
1359
1374
  this.overviewVisible = true;
1360
1375
  this.ovDragging = false;
1376
+ this.historyShortcut = (direction) => {
1377
+ if (direction === "undo") this.undo();
1378
+ else this.redo();
1379
+ };
1361
1380
  this.ovGeo = null;
1362
1381
  this.validator = null;
1363
1382
  this.handlers = /* @__PURE__ */ new Map();
1364
1383
  this.opts = opts;
1384
+ if (opts.typeColors !== void 0) this.setTypeColorsInternal(opts.typeColors);
1365
1385
  this.host = opts.host;
1366
1386
  this.gridSnapEnabled = opts.gridSnap ?? false;
1367
1387
  this.gridSize = this.normalizeGridSize(opts.gridSize ?? DEFAULT_GRID_SIZE);
1368
- this.history = new DiagramCommandHistory(({ canUndo, canRedo }) => {
1369
- this.emit("undoStackChanged", { canUndo, canRedo });
1388
+ this.history = new DiagramCommandHistory(() => {
1389
+ this.emit("undoStackChanged", { canUndo: this.canUndo(), canRedo: this.canRedo() });
1370
1390
  });
1371
1391
  this.canvas = document.createElement("canvas");
1372
1392
  this.canvas.style.display = "block";
@@ -1749,6 +1769,8 @@ var SSDiagram = (() => {
1749
1769
  clear() {
1750
1770
  this.nodes = [];
1751
1771
  this.links = [];
1772
+ this.zones = [];
1773
+ const hadSelection = this.selectedNode !== null || this.selectedNodes.size > 0 || this.selectedLink !== null || this.selectedPort !== null;
1752
1774
  this.selectedNode = null;
1753
1775
  this.selectedNodes.clear();
1754
1776
  this.selectedLink = null;
@@ -1758,6 +1780,7 @@ var SSDiagram = (() => {
1758
1780
  this.runtimePulse = 0;
1759
1781
  this.globalErrorFlashStart = null;
1760
1782
  this.history.clear();
1783
+ if (hadSelection) this.emitSelectionChanged();
1761
1784
  this.emitRuntimeStateChanged();
1762
1785
  }
1763
1786
  relink(linkId, next) {
@@ -1976,6 +1999,20 @@ var SSDiagram = (() => {
1976
1999
  this.clear();
1977
2000
  this.idSeq = 1;
1978
2001
  this.linkSeq = 1;
2002
+ const seenNodeIds = /* @__PURE__ */ new Set();
2003
+ for (const node of nodes) {
2004
+ const id = node.id ?? "";
2005
+ if (id === "") continue;
2006
+ if (seenNodeIds.has(id)) throw new Error(`ssdiagram: duplicate node id "${id}"`);
2007
+ seenNodeIds.add(id);
2008
+ }
2009
+ const seenLinkIds = /* @__PURE__ */ new Set();
2010
+ for (const link of links) {
2011
+ const id = link.id ?? "";
2012
+ if (id === "") continue;
2013
+ if (seenLinkIds.has(id)) throw new Error(`ssdiagram: duplicate link id "${id}"`);
2014
+ seenLinkIds.add(id);
2015
+ }
1979
2016
  for (const node of nodes) {
1980
2017
  const id = node.id ?? this.nextNodeId();
1981
2018
  this.doAddNode({ ...node, id });
@@ -1992,7 +2029,7 @@ var SSDiagram = (() => {
1992
2029
  if (node.loadError.length === 0) continue;
1993
2030
  const state = createDiagramNodeRuntimeState();
1994
2031
  state.errors.load = { kind: "load", message: node.loadError, pulse: ++this.runtimePulse };
1995
- this.runtimeState.nodes[node.id] = state;
2032
+ setJsonKey(this.runtimeState.nodes, node.id, state);
1996
2033
  }
1997
2034
  if (Object.keys(this.runtimeState.nodes).length > 0) this.emitRuntimeStateChanged();
1998
2035
  this.history.clear();
@@ -2003,7 +2040,15 @@ var SSDiagram = (() => {
2003
2040
  save() {
2004
2041
  return {
2005
2042
  nodes: this.nodes.map((node) => node.toInit(false)),
2006
- links: this.links.map((l) => ({ from: l.from, fromPort: l.fromPort, to: l.to, toPort: l.toPort }))
2043
+ links: this.links.map((l) => ({
2044
+ id: l.id,
2045
+ from: l.from,
2046
+ fromPort: l.fromPort,
2047
+ to: l.to,
2048
+ toPort: l.toPort,
2049
+ style: l.style,
2050
+ metadata: copyJsonObject(l.metadata)
2051
+ }))
2007
2052
  };
2008
2053
  }
2009
2054
  loadDocument(source) {
@@ -2073,7 +2118,20 @@ var SSDiagram = (() => {
2073
2118
  overviewVisible: this.overviewVisible
2074
2119
  };
2075
2120
  }
2121
+ /**
2122
+ * Replaces what Ctrl+Z / Ctrl+Y do. Passing null restores the built-in behaviour, which is
2123
+ * plain undo()/redo() -- so a renderer used on its own keeps its keyboard history.
2124
+ */
2125
+ setHistoryShortcutHandler(handler) {
2126
+ this.historyShortcut = handler ?? ((direction) => {
2127
+ if (direction === "undo") this.undo();
2128
+ else this.redo();
2129
+ });
2130
+ }
2076
2131
  setViewState(state) {
2132
+ if (!Number.isFinite(state.zoom) || !Number.isFinite(state.panX) || !Number.isFinite(state.panY)) {
2133
+ throw new RangeError("ssdiagram: view state zoom and pan must be finite numbers");
2134
+ }
2077
2135
  this.scale = clamp(state.zoom, ZOOM_MIN, ZOOM_MAX);
2078
2136
  this.offX = state.panX;
2079
2137
  this.offY = state.panY;
@@ -2101,6 +2159,7 @@ var SSDiagram = (() => {
2101
2159
  throw new RangeError(`ssdiagram: unsupported screenshot scope "${String(scope)}"`);
2102
2160
  }
2103
2161
  if (scope === "viewport" && Object.keys(options).length === 0) {
2162
+ if (this.drawScheduled) this.draw();
2104
2163
  const copy = document.createElement("canvas");
2105
2164
  copy.width = this.canvas.width;
2106
2165
  copy.height = this.canvas.height;
@@ -2127,7 +2186,10 @@ var SSDiagram = (() => {
2127
2186
  offX: this.offX,
2128
2187
  offY: this.offY,
2129
2188
  overviewVisible: this.overviewVisible,
2130
- background: this.opts.background
2189
+ background: this.opts.background,
2190
+ // Drawing the overview recomputes this from the export frame. Left behind, the next
2191
+ // click on the on-screen minimap panned to a point in the export's coordinates.
2192
+ ovGeo: this.ovGeo
2131
2193
  };
2132
2194
  try {
2133
2195
  this.ctx = outputContext;
@@ -2157,6 +2219,7 @@ var SSDiagram = (() => {
2157
2219
  this.offY = previous.offY;
2158
2220
  this.overviewVisible = previous.overviewVisible;
2159
2221
  this.opts.background = previous.background;
2222
+ this.ovGeo = previous.ovGeo;
2160
2223
  }
2161
2224
  return output;
2162
2225
  }
@@ -2210,10 +2273,15 @@ var SSDiagram = (() => {
2210
2273
  scale: this.scale,
2211
2274
  offX: this.offX,
2212
2275
  offY: this.offY,
2213
- overviewVisible: this.overviewVisible
2276
+ overviewVisible: this.overviewVisible,
2277
+ background: this.opts.background,
2278
+ // Drawing the overview recomputes this from the export frame. Left behind, the next
2279
+ // click on the on-screen minimap panned to a point in the export's coordinates.
2280
+ ovGeo: this.ovGeo
2214
2281
  };
2215
2282
  try {
2216
2283
  this.ctx = surface;
2284
+ if (options.background !== void 0) this.opts.background = options.background;
2217
2285
  this.width = width;
2218
2286
  this.height = height;
2219
2287
  this.dpr = 1;
@@ -2237,6 +2305,8 @@ var SSDiagram = (() => {
2237
2305
  this.offX = previous.offX;
2238
2306
  this.offY = previous.offY;
2239
2307
  this.overviewVisible = previous.overviewVisible;
2308
+ this.opts.background = previous.background;
2309
+ this.ovGeo = previous.ovGeo;
2240
2310
  }
2241
2311
  return surface.toSvg();
2242
2312
  }
@@ -2269,8 +2339,8 @@ var SSDiagram = (() => {
2269
2339
  ...Object.values(this.runtimeState.nodes).flatMap((node) => [node.errors.runtime?.pulse ?? 0, node.errors.load?.pulse ?? 0])
2270
2340
  );
2271
2341
  for (const node of this.nodes) {
2272
- const errors = this.runtimeState.nodes[node.id]?.errors;
2273
- const previousRuntime = previous.nodes[node.id]?.errors.runtime;
2342
+ const errors = getJsonKey(this.runtimeState.nodes, node.id)?.errors;
2343
+ const previousRuntime = getJsonKey(previous.nodes, node.id)?.errors.runtime;
2274
2344
  const runtimeError = errors?.runtime;
2275
2345
  node.runtimeError = runtimeError?.message ?? "";
2276
2346
  node.loadError = errors?.load?.message ?? "";
@@ -2299,8 +2369,8 @@ var SSDiagram = (() => {
2299
2369
  const port = direction === "in" ? node?.inPorts.find((candidate) => candidate.id === portId) : node?.outPorts.find((candidate) => candidate.id === portId);
2300
2370
  if (node === void 0 || port === void 0) return false;
2301
2371
  const state = this.getRuntimeState();
2302
- const nodeState = state.nodes[nodeId] ?? createDiagramNodeRuntimeState();
2303
- state.nodes[nodeId] = nodeState;
2372
+ const nodeState = getJsonKey(state.nodes, nodeId) ?? createDiagramNodeRuntimeState();
2373
+ setJsonKey(state.nodes, nodeId, nodeState);
2304
2374
  const current = nodeState.ports[direction][portId] ?? createDiagramPortRuntimeState();
2305
2375
  nodeState.ports[direction][portId] = { ...current, ...patch };
2306
2376
  this.setRuntimeState(state);
@@ -2322,8 +2392,8 @@ var SSDiagram = (() => {
2322
2392
  node.errorFlashStart = message.length > 0 && options.animate !== false ? performance.now() : null;
2323
2393
  }
2324
2394
  const state = this.getRuntimeState();
2325
- const nodeState = state.nodes[id] ?? createDiagramNodeRuntimeState();
2326
- state.nodes[id] = nodeState;
2395
+ const nodeState = getJsonKey(state.nodes, id) ?? createDiagramNodeRuntimeState();
2396
+ setJsonKey(state.nodes, id, nodeState);
2327
2397
  if (message.length === 0) delete nodeState.errors[kind];
2328
2398
  else if (kind === "load") nodeState.errors.load = { kind, message, pulse: ++this.runtimePulse };
2329
2399
  else nodeState.errors.runtime = { kind, message, pulse: ++this.runtimePulse };
@@ -2340,7 +2410,7 @@ var SSDiagram = (() => {
2340
2410
  }
2341
2411
  if (kind === void 0 || kind === "load") node.loadError = "";
2342
2412
  const state = this.getRuntimeState();
2343
- const nodeState = state.nodes[id];
2413
+ const nodeState = getJsonKey(state.nodes, id);
2344
2414
  if (nodeState !== void 0) {
2345
2415
  if (kind === void 0 || kind === "runtime") delete nodeState.errors.runtime;
2346
2416
  if (kind === void 0 || kind === "load") delete nodeState.errors.load;
@@ -2364,6 +2434,7 @@ var SSDiagram = (() => {
2364
2434
  this.selectNode(node);
2365
2435
  }
2366
2436
  setZoom(scale) {
2437
+ if (!Number.isFinite(scale)) throw new RangeError("ssdiagram: zoom must be a finite number");
2367
2438
  const cx = this.width / 2;
2368
2439
  const cy = this.height / 2;
2369
2440
  const wx = (cx - this.offX) / this.scale;
@@ -2405,9 +2476,21 @@ var SSDiagram = (() => {
2405
2476
  this.scheduleDraw();
2406
2477
  }
2407
2478
  setTypeColors(colors) {
2408
- this.opts.typeColors = { ...colors };
2479
+ this.setTypeColorsInternal(colors);
2409
2480
  this.scheduleDraw();
2410
2481
  }
2482
+ /**
2483
+ * Keyed the way port types are compared everywhere else. Connection compatibility is case- and
2484
+ * space-insensitive, so an imported scheme spelling a socket "candle " connects to "Candle" and
2485
+ * then fell through to a hash colour, showing one logical type in two colours in one diagram.
2486
+ */
2487
+ setTypeColorsInternal(colors) {
2488
+ const normalized = {};
2489
+ for (const [type, color] of Object.entries(colors)) {
2490
+ setJsonKey(normalized, normalizePortType(type), color);
2491
+ }
2492
+ this.opts.typeColors = normalized;
2493
+ }
2411
2494
  setTheme(t2) {
2412
2495
  if (t2.background !== void 0) this.opts.background = t2.background;
2413
2496
  if (t2.gridColor !== void 0) this.opts.gridColor = t2.gridColor;
@@ -2509,7 +2592,8 @@ var SSDiagram = (() => {
2509
2592
  const light = maxL !== void 0 && maxL < 0.5;
2510
2593
  if (type === "") return light ? "#585c62" : "#9aa0a6";
2511
2594
  const map = this.opts.typeColors ?? {};
2512
- const base = map[type] ?? `hsl(${hashHue(type)}, 62%, 58%)`;
2595
+ const normalized = normalizePortType(type);
2596
+ const base = getJsonKey(map, normalized) ?? `hsl(${hashHue(normalized)}, 62%, 58%)`;
2513
2597
  return maxL !== void 0 ? clampLightness(base, maxL) : base;
2514
2598
  }
2515
2599
  // Selection/hover accent. The dark-canvas blue washes out on a light canvas, so a light theme
@@ -2529,6 +2613,7 @@ var SSDiagram = (() => {
2529
2613
  for (const p of [...n2.inPorts, ...n2.outPorts]) {
2530
2614
  if ((wx - p.cx) ** 2 + (wy - p.cy) ** 2 <= (PORT_R + 4) ** 2) return { node: n2, port: p };
2531
2615
  }
2616
+ if (wx >= n2.x && wx <= n2.x + n2.w && wy >= n2.y && wy <= n2.y + n2.h) return null;
2532
2617
  }
2533
2618
  return null;
2534
2619
  }
@@ -2807,6 +2892,9 @@ var SSDiagram = (() => {
2807
2892
  fromPort: l.from.portId,
2808
2893
  to: t2,
2809
2894
  toPort: l.to.portId,
2895
+ // The clipboard is built from saveDocument(), so it carries the style;
2896
+ // rebuilding without it silently turned every pasted dashed link solid.
2897
+ style: l.style,
2810
2898
  metadata: l.metadata
2811
2899
  });
2812
2900
  }
@@ -2815,6 +2903,23 @@ var SSDiagram = (() => {
2815
2903
  return added.map((node) => node.id);
2816
2904
  }
2817
2905
  // ---- input ------------------------------------------------------
2906
+ // Ends hover and everything hanging off it: highlight, cursor and the delayed tooltip.
2907
+ clearHover() {
2908
+ if (this.hoverPort !== null) this.emit("portHover", { node: this.hoverPort.node, port: this.hoverPort.port, hovering: false });
2909
+ if (this.hoverNode !== null) this.emit("nodeHover", { node: this.hoverNode, hovering: false });
2910
+ if (this.hoveredLink !== null) this.emit("linkHover", { link: this.hoveredLink, hovering: false });
2911
+ this.hoverPort = null;
2912
+ this.hoverNode = null;
2913
+ this.hoveredLink = null;
2914
+ this.tipTarget = null;
2915
+ this.tipShow = false;
2916
+ if (this.tipTimer !== null) {
2917
+ clearTimeout(this.tipTimer);
2918
+ this.tipTimer = null;
2919
+ }
2920
+ this.canvas.style.cursor = "default";
2921
+ this.scheduleDraw();
2922
+ }
2818
2923
  cancelLongPress() {
2819
2924
  if (this.lpTimer !== null) {
2820
2925
  clearTimeout(this.lpTimer);
@@ -2847,7 +2952,7 @@ var SSDiagram = (() => {
2847
2952
  this.relinking = null;
2848
2953
  this.relinkCandidate = null;
2849
2954
  this.linkSnap = null;
2850
- this.scheduleDraw();
2955
+ this.clearHover();
2851
2956
  this.emit("contextMenu", { x: pageX, y: pageY, link, node, port });
2852
2957
  }
2853
2958
  listen(target, type, listener, options) {
@@ -2866,14 +2971,16 @@ var SSDiagram = (() => {
2866
2971
  }
2867
2972
  this.cancelLongPress();
2868
2973
  this.relinkCandidate = null;
2869
- this.lpStart = { sx: e.clientX, sy: e.clientY, px: e.clientX, py: e.clientY };
2870
- const downSx = e.clientX, downSy = e.clientY;
2871
- const r0 = this.canvas.getBoundingClientRect();
2872
- const localSx = downSx - r0.left, localSy = downSy - r0.top;
2873
- this.lpTimer = setTimeout(() => {
2874
- this.lpTimer = null;
2875
- this.fireContextMenu(localSx, localSy, downSx, downSy);
2876
- }, this.lpDelayMs);
2974
+ if (e.pointerType !== "mouse") {
2975
+ this.lpStart = { sx: e.clientX, sy: e.clientY, px: e.clientX, py: e.clientY };
2976
+ const downSx = e.clientX, downSy = e.clientY;
2977
+ const r0 = this.canvas.getBoundingClientRect();
2978
+ const localSx = downSx - r0.left, localSy = downSy - r0.top;
2979
+ this.lpTimer = setTimeout(() => {
2980
+ this.lpTimer = null;
2981
+ this.fireContextMenu(localSx, localSy, downSx, downSy);
2982
+ }, this.lpDelayMs);
2983
+ }
2877
2984
  this.canvas.focus();
2878
2985
  const [sx, sy] = localXY(e);
2879
2986
  if (this.ovHit(sx, sy)) {
@@ -2943,13 +3050,13 @@ var SSDiagram = (() => {
2943
3050
  if (node !== null) {
2944
3051
  const add = e.shiftKey || e.ctrlKey || e.metaKey;
2945
3052
  if (this.permissions.select) {
2946
- if (add) {
3053
+ if (add && e.button === 0) {
2947
3054
  this.toggleSelect(node);
2948
3055
  return;
2949
3056
  }
2950
3057
  if (!this.selectedNodes.has(node)) this.selectNode(node);
2951
3058
  }
2952
- if (this.permissions.moveNodes) {
3059
+ if (this.permissions.moveNodes && e.button === 0) {
2953
3060
  this.dragNode = node;
2954
3061
  this.dragAnchor = { wx, wy };
2955
3062
  const moving = this.selectedNodes.has(node) ? [...this.selectedNodes] : [node];
@@ -2964,6 +3071,7 @@ var SSDiagram = (() => {
2964
3071
  if (this.permissions.select) this.selectLink(link);
2965
3072
  return;
2966
3073
  }
3074
+ if (e.button !== 0 && e.button !== 1) return;
2967
3075
  const wantPan = !this.permissions.select || e.button === 1 || e.ctrlKey || e.altKey || e.pointerType === "touch";
2968
3076
  if (wantPan) {
2969
3077
  if (this.permissions.select && !e.shiftKey) this.selectNode(null);
@@ -3087,7 +3195,10 @@ var SSDiagram = (() => {
3087
3195
  this.relinkCandidate = null;
3088
3196
  if (this.dragNode !== null) {
3089
3197
  const moves = this.dragStart.map((it) => ({ id: it.n.id, fromX: it.x, fromY: it.y, toX: it.n.x, toY: it.n.y })).filter((m) => m.fromX !== m.toX || m.fromY !== m.toY);
3090
- for (const it of this.dragStart) this.emit("nodeMoved", { node: it.n });
3198
+ const moved = new Set(moves.map((m) => m.id));
3199
+ for (const it of this.dragStart) {
3200
+ if (moved.has(it.n.id)) this.emit("nodeMoved", { node: it.n });
3201
+ }
3091
3202
  this.dragNode = null;
3092
3203
  this.dragStart = [];
3093
3204
  if (moves.length > 0) {
@@ -3169,26 +3280,12 @@ var SSDiagram = (() => {
3169
3280
  this.emitViewChanged(true);
3170
3281
  this.scheduleDraw();
3171
3282
  }, { passive: false });
3172
- this.listen(this.canvas, "pointerleave", () => {
3173
- if (this.hoverPort !== null) this.emit("portHover", { node: this.hoverPort.node, port: this.hoverPort.port, hovering: false });
3174
- if (this.hoverNode !== null) this.emit("nodeHover", { node: this.hoverNode, hovering: false });
3175
- if (this.hoveredLink !== null) this.emit("linkHover", { link: this.hoveredLink, hovering: false });
3176
- this.hoverPort = null;
3177
- this.hoverNode = null;
3178
- this.hoveredLink = null;
3179
- this.tipTarget = null;
3180
- this.tipShow = false;
3181
- if (this.tipTimer !== null) {
3182
- clearTimeout(this.tipTimer);
3183
- this.tipTimer = null;
3184
- }
3185
- this.canvas.style.cursor = "default";
3186
- this.scheduleDraw();
3187
- });
3283
+ this.listen(this.canvas, "pointerleave", () => this.clearHover());
3188
3284
  this.listen(this.canvas, "dblclick", (e) => {
3189
3285
  const [sx, sy] = localXY(e);
3190
3286
  const [wx, wy] = this.toWorld(sx, sy);
3191
3287
  if (this.portAt(wx, wy) !== null) return;
3288
+ if (this.linkAt(wx, wy) !== null) return;
3192
3289
  const node = this.nodeAt(wx, wy);
3193
3290
  if (node !== null) {
3194
3291
  if (this.permissions.inspect && node.openAction.length > 0) {
@@ -3231,6 +3328,8 @@ var SSDiagram = (() => {
3231
3328
  this.linking = null;
3232
3329
  this.relinking = null;
3233
3330
  this.relinkCandidate = null;
3331
+ this.ovDragging = false;
3332
+ this.linkSnap = null;
3234
3333
  }
3235
3334
  }, { passive: false });
3236
3335
  this.listen(this.canvas, "touchmove", (e) => {
@@ -3291,12 +3390,12 @@ var SSDiagram = (() => {
3291
3390
  }
3292
3391
  if (mod && this.permissions.history && e.code === "KeyZ" && !e.shiftKey) {
3293
3392
  e.preventDefault();
3294
- this.undo();
3393
+ this.historyShortcut("undo");
3295
3394
  return;
3296
3395
  }
3297
3396
  if (mod && this.permissions.history && (e.code === "KeyY" || e.code === "KeyZ" && e.shiftKey)) {
3298
3397
  e.preventDefault();
3299
- this.redo();
3398
+ this.historyShortcut("redo");
3300
3399
  return;
3301
3400
  }
3302
3401
  });
@@ -3374,17 +3473,23 @@ var SSDiagram = (() => {
3374
3473
  }
3375
3474
  ctx.restore();
3376
3475
  if (options.overview) this.drawOverview();
3377
- if (options.runtime) this.drawGlobalError();
3476
+ if (options.runtime) this.drawGlobalError(options.transient);
3378
3477
  if (options.transient) this.drawTooltip();
3379
3478
  if (options.transient && (this.introStart !== null || this.globalErrorFlashStart !== null || this.nodes.some((n2) => n2.errorFlashStart !== null)))
3380
3479
  this.scheduleDraw();
3381
3480
  }
3382
- drawGlobalError() {
3481
+ /**
3482
+ * `transient` gates the flash exactly as it gates every other animation, which an export must
3483
+ * not see: it would bake in whatever point of the pulse it caught, so the same export taken
3484
+ * twice differed in opacity. The gate also keeps the export from consuming the animation --
3485
+ * clearing globalErrorFlashStart from an export cut the flash short on screen.
3486
+ */
3487
+ drawGlobalError(transient) {
3383
3488
  const error = this.runtimeState.globalError;
3384
3489
  if (error === null) return;
3385
3490
  const ctx = this.ctx;
3386
3491
  let flashAlpha = 1;
3387
- if (this.globalErrorFlashStart !== null) {
3492
+ if (transient && this.globalErrorFlashStart !== null) {
3388
3493
  const elapsed = performance.now() - this.globalErrorFlashStart;
3389
3494
  if (elapsed >= ERROR_FLASH_MS) {
3390
3495
  this.globalErrorFlashStart = null;
@@ -3567,7 +3672,7 @@ ${runtime.error}`;
3567
3672
  const ctx = this.ctx;
3568
3673
  const hasLoadError = options.runtime && n2.loadError.length > 0;
3569
3674
  const hasRuntimeError = options.runtime && n2.runtimeError.length > 0;
3570
- const runtime = options.runtime ? this.runtimeState.nodes[n2.id] : void 0;
3675
+ const runtime = options.runtime ? getJsonKey(this.runtimeState.nodes, n2.id) : void 0;
3571
3676
  const active = options.runtime && (this.runtimeState.activeNodeId === n2.id || runtime?.active === true);
3572
3677
  roundRect(ctx, n2.x, n2.y, n2.w, n2.h, 6);
3573
3678
  ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? "#ffd1dc" : n2.color;
@@ -3655,7 +3760,7 @@ ${runtime.error}`;
3655
3760
  }
3656
3761
  }
3657
3762
  portRuntimeState(node, port) {
3658
- return this.runtimeState.nodes[node.id]?.ports[port.direction][port.id] ?? null;
3763
+ return getJsonKey(this.runtimeState.nodes, node.id)?.ports[port.direction][port.id] ?? null;
3659
3764
  }
3660
3765
  // Symmetric jump-over: any segment of THIS link hops the perpendicular
3661
3766
  // segments of links drawn before it (H over earlier V, V over earlier
@@ -4133,9 +4238,10 @@ ${runtime.error}`;
4133
4238
  }
4134
4239
 
4135
4240
  // src/i18n.ts
4136
- var i18n = typeof window === "undefined" ? {} : window.__designerI18n ?? {};
4137
4241
  function t(key, fallback) {
4138
- return i18n[key] ?? fallback;
4242
+ if (typeof window === "undefined") return fallback;
4243
+ const value = window.__designerI18n?.[key];
4244
+ return value === void 0 || value === "" ? fallback : value;
4139
4245
  }
4140
4246
 
4141
4247
  // src/diagram/context-menu.ts
@@ -4145,7 +4251,8 @@ ${runtime.error}`;
4145
4251
  var SEPARATOR_CLASS = "ssdiagram-context-menu-separator";
4146
4252
  var LABEL_CLASS = "ssdiagram-context-menu-label";
4147
4253
  var ARROW_CLASS = "ssdiagram-context-menu-arrow";
4148
- var SEPARATE_AFTER = /* @__PURE__ */ new Set(["redo", "paste", "delete"]);
4254
+ var CHECK_CLASS = "ssdiagram-context-menu-check";
4255
+ var SEPARATE_AFTER = /* @__PURE__ */ new Set(["redo", "paste", "delete", "overview"]);
4149
4256
  function label(id) {
4150
4257
  switch (id) {
4151
4258
  case "undo":
@@ -4161,7 +4268,7 @@ ${runtime.error}`;
4161
4268
  case "open":
4162
4269
  return t("ctxOpen", "Open");
4163
4270
  case "delete":
4164
- return t("delete", "Delete");
4271
+ return t("ctxDelete", "Delete");
4165
4272
  case "export":
4166
4273
  return t("ctxExportAs", "Export as");
4167
4274
  case "exportDocument":
@@ -4170,6 +4277,8 @@ ${runtime.error}`;
4170
4277
  return t("ctxExportPng", "PNG image");
4171
4278
  case "exportSvg":
4172
4279
  return t("ctxExportSvg", "SVG image");
4280
+ case "overview":
4281
+ return t("ctxOverview", "Overview");
4173
4282
  case "properties":
4174
4283
  return t("properties", "Properties");
4175
4284
  case "help":
@@ -4180,6 +4289,10 @@ ${runtime.error}`;
4180
4289
  position: "fixed",
4181
4290
  zIndex: "2147483000",
4182
4291
  minWidth: "176px",
4292
+ // Translations run longer than English -- German and Russian routinely half again. Without
4293
+ // a cap the panel just grows, and on a narrow viewport it leaves the screen entirely, which
4294
+ // no amount of repositioning can recover.
4295
+ maxWidth: "min(320px, calc(100vw - 16px))",
4183
4296
  padding: "4px",
4184
4297
  border: "1px solid var(--ssdiagram-menu-border, var(--ssdiagram-control-border, var(--t-border, #3a4250)))",
4185
4298
  borderRadius: "7px",
@@ -4201,8 +4314,10 @@ ${runtime.error}`;
4201
4314
  background: "transparent",
4202
4315
  color: "inherit",
4203
4316
  font: "inherit",
4204
- textAlign: "left",
4205
- whiteSpace: "nowrap",
4317
+ // Logical, not physical: on an RTL page these follow the writing direction instead of
4318
+ // pinning the text left and truncating from the wrong end.
4319
+ textAlign: "start",
4320
+ overflowWrap: "anywhere",
4206
4321
  cursor: "pointer",
4207
4322
  boxSizing: "border-box"
4208
4323
  };
@@ -4212,6 +4327,7 @@ ${runtime.error}`;
4212
4327
  constructor(options) {
4213
4328
  this.root = null;
4214
4329
  this.submenu = null;
4330
+ this.submenuTriggers = /* @__PURE__ */ new Map();
4215
4331
  this.detach = [];
4216
4332
  this.container = options.container;
4217
4333
  this.execute = options.execute;
@@ -4225,12 +4341,16 @@ ${runtime.error}`;
4225
4341
  const root = this.panel(owner);
4226
4342
  root.className = ROOT_CLASS;
4227
4343
  root.setAttribute("role", "menu");
4344
+ const gutter = items.some((item) => !("group" in item) && item.checked !== void 0);
4228
4345
  items.forEach((item, index) => {
4229
- if ("group" in item) root.appendChild(this.groupEntry(owner, item));
4230
- else root.appendChild(this.commandEntry(owner, item.command, item.enabled, true));
4346
+ if ("group" in item) root.appendChild(this.groupEntry(owner, item, gutter));
4347
+ else root.appendChild(this.commandEntry(owner, item.command, item.enabled, true, item.checked, gutter));
4231
4348
  const id = "group" in item ? item.group : item.command;
4232
4349
  if (SEPARATE_AFTER.has(id) && index < items.length - 1) root.appendChild(this.separator(owner));
4233
4350
  });
4351
+ this.on(root, "contextmenu", (event) => {
4352
+ event.preventDefault();
4353
+ });
4234
4354
  this.container.appendChild(root);
4235
4355
  this.root = root;
4236
4356
  this.place(root, x, y);
@@ -4239,6 +4359,7 @@ ${runtime.error}`;
4239
4359
  hide() {
4240
4360
  for (const dispose of this.detach.splice(0)) dispose();
4241
4361
  this.submenu = null;
4362
+ this.submenuTriggers.clear();
4242
4363
  this.root?.remove();
4243
4364
  this.root = null;
4244
4365
  }
@@ -4260,17 +4381,26 @@ ${runtime.error}`;
4260
4381
  });
4261
4382
  return rule;
4262
4383
  }
4263
- item(owner, text, enabled, arrow) {
4384
+ item(owner, text, enabled, arrow, checked, gutter) {
4264
4385
  const button = owner.createElement("button");
4265
4386
  button.type = "button";
4266
4387
  button.className = ITEM_CLASS;
4267
- button.setAttribute("role", "menuitem");
4388
+ button.setAttribute("role", checked === void 0 ? "menuitem" : "menuitemcheckbox");
4389
+ if (checked !== void 0) button.setAttribute("aria-checked", String(checked));
4268
4390
  Object.assign(button.style, ITEM_STYLE);
4269
4391
  if (!enabled) {
4270
4392
  button.disabled = true;
4271
4393
  button.style.color = DISABLED_COLOR;
4272
4394
  button.style.cursor = "default";
4273
4395
  }
4396
+ if (gutter) {
4397
+ const tick = owner.createElement("span");
4398
+ tick.className = CHECK_CLASS;
4399
+ tick.textContent = checked === true ? "\u2713" : "";
4400
+ tick.setAttribute("aria-hidden", "true");
4401
+ Object.assign(tick.style, { flex: "none", width: "12px", textAlign: "center" });
4402
+ button.appendChild(tick);
4403
+ }
4274
4404
  const caption = owner.createElement("span");
4275
4405
  caption.className = LABEL_CLASS;
4276
4406
  caption.textContent = text;
@@ -4279,7 +4409,7 @@ ${runtime.error}`;
4279
4409
  if (arrow) {
4280
4410
  const chevron = owner.createElement("span");
4281
4411
  chevron.className = ARROW_CLASS;
4282
- chevron.textContent = "\u203A";
4412
+ chevron.textContent = this.rtl() ? "\u2039" : "\u203A";
4283
4413
  chevron.setAttribute("aria-hidden", "true");
4284
4414
  Object.assign(chevron.style, { flex: "none", opacity: "0.7", fontSize: "14px" });
4285
4415
  button.appendChild(chevron);
@@ -4291,8 +4421,8 @@ ${runtime.error}`;
4291
4421
  // the submenu must not: reaching it means the pointer has entered that very panel, and
4292
4422
  // closing it there would pull it out from under the pointer and drop the click on the
4293
4423
  // canvas behind.
4294
- commandEntry(owner, command, enabled, closesSubmenu) {
4295
- const button = this.item(owner, label(command), enabled, false);
4424
+ commandEntry(owner, command, enabled, closesSubmenu, checked, gutter) {
4425
+ const button = this.item(owner, label(command), enabled, false, checked, gutter);
4296
4426
  this.on(button, "pointerenter", () => {
4297
4427
  if (closesSubmenu) this.closeSubmenu();
4298
4428
  if (enabled) button.style.background = HOVER_BACKGROUND;
@@ -4307,20 +4437,27 @@ ${runtime.error}`;
4307
4437
  });
4308
4438
  return button;
4309
4439
  }
4310
- groupEntry(owner, group) {
4440
+ groupEntry(owner, group, gutter) {
4311
4441
  const host = owner.createElement("div");
4312
4442
  host.style.position = "relative";
4313
- const button = this.item(owner, label(group.group), group.enabled, true);
4443
+ const button = this.item(owner, label(group.group), group.enabled, true, void 0, gutter);
4444
+ button.setAttribute("aria-haspopup", "menu");
4445
+ button.setAttribute("aria-expanded", "false");
4314
4446
  host.appendChild(button);
4315
4447
  const panel = this.panel(owner);
4316
4448
  panel.className = SUBMENU_CLASS;
4317
4449
  panel.setAttribute("role", "menu");
4318
- Object.assign(panel.style, { position: "absolute", left: "100%", top: "-5px", display: "none" });
4319
- for (const child of group.commands) panel.appendChild(this.commandEntry(owner, child.command, child.enabled, false));
4450
+ panel.setAttribute("aria-label", label(group.group));
4451
+ Object.assign(panel.style, { position: "absolute", insetInlineStart: "100%", top: "-5px", display: "none" });
4452
+ const childGutter = group.commands.some((child) => child.checked !== void 0);
4453
+ for (const child of group.commands) {
4454
+ panel.appendChild(this.commandEntry(owner, child.command, child.enabled, false, child.checked, childGutter));
4455
+ }
4320
4456
  host.appendChild(panel);
4321
4457
  const open = () => {
4322
4458
  if (!group.enabled) return;
4323
4459
  button.style.background = HOVER_BACKGROUND;
4460
+ button.setAttribute("aria-expanded", "true");
4324
4461
  panel.style.display = "block";
4325
4462
  this.submenu = panel;
4326
4463
  this.keepOnScreen(panel);
@@ -4330,25 +4467,48 @@ ${runtime.error}`;
4330
4467
  this.on(host, "pointerleave", () => {
4331
4468
  if (this.submenu !== panel) return;
4332
4469
  button.style.background = "transparent";
4470
+ button.setAttribute("aria-expanded", "false");
4333
4471
  panel.style.display = "none";
4334
4472
  this.submenu = null;
4335
4473
  });
4474
+ this.submenuTriggers.set(panel, button);
4336
4475
  return host;
4337
4476
  }
4338
4477
  closeSubmenu() {
4339
4478
  if (this.submenu === null) return;
4340
4479
  this.submenu.style.display = "none";
4480
+ this.submenuTriggers.get(this.submenu)?.setAttribute("aria-expanded", "false");
4341
4481
  this.submenu = null;
4342
4482
  }
4483
+ /**
4484
+ * The window the menu actually lives in. A component created from one document and mounted
4485
+ * into another -- a same-origin iframe, a popup -- must be measured and dismissed against
4486
+ * that document's window, not the one this module happened to be evaluated in.
4487
+ */
4488
+ get view() {
4489
+ const owner = this.container.ownerDocument ?? (typeof document === "undefined" ? null : document);
4490
+ const view = owner?.defaultView ?? (typeof window === "undefined" ? null : window);
4491
+ return view ?? null;
4492
+ }
4493
+ // Reads the resolved writing direction so the submenu's side and its arrow agree with it.
4494
+ rtl() {
4495
+ if (typeof getComputedStyle !== "function") return false;
4496
+ try {
4497
+ return getComputedStyle(this.container).direction === "rtl";
4498
+ } catch {
4499
+ return false;
4500
+ }
4501
+ }
4343
4502
  /** Keeps the panel inside the viewport, flipping it left when it would run off the edge. */
4344
4503
  keepOnScreen(panel) {
4345
- if (typeof panel.getBoundingClientRect !== "function" || typeof window === "undefined") return;
4504
+ const view = this.view;
4505
+ if (typeof panel.getBoundingClientRect !== "function" || view === null) return;
4346
4506
  const box = panel.getBoundingClientRect();
4347
- if (box.right > window.innerWidth - 4) {
4348
- panel.style.left = "auto";
4349
- panel.style.right = "100%";
4507
+ if (box.right > view.innerWidth - 4 || box.left < 4) {
4508
+ panel.style.insetInlineStart = "auto";
4509
+ panel.style.insetInlineEnd = "100%";
4350
4510
  }
4351
- if (box.bottom > window.innerHeight - 4) {
4511
+ if (box.bottom > view.innerHeight - 4) {
4352
4512
  panel.style.top = "auto";
4353
4513
  panel.style.bottom = "-5px";
4354
4514
  }
@@ -4356,10 +4516,11 @@ ${runtime.error}`;
4356
4516
  place(root, x, y) {
4357
4517
  root.style.left = `${x}px`;
4358
4518
  root.style.top = `${y}px`;
4359
- if (typeof root.getBoundingClientRect !== "function" || typeof window === "undefined") return;
4519
+ const view = this.view;
4520
+ if (typeof root.getBoundingClientRect !== "function" || view === null) return;
4360
4521
  const box = root.getBoundingClientRect();
4361
- if (box.right > window.innerWidth - 4) root.style.left = `${Math.max(4, x - box.width)}px`;
4362
- if (box.bottom > window.innerHeight - 4) root.style.top = `${Math.max(4, y - box.height)}px`;
4522
+ if (box.right > view.innerWidth - 4) root.style.left = `${Math.max(4, x - box.width)}px`;
4523
+ if (box.bottom > view.innerHeight - 4) root.style.top = `${Math.max(4, y - box.height)}px`;
4363
4524
  }
4364
4525
  watchForDismissal(owner) {
4365
4526
  const dismiss = () => this.hide();
@@ -4373,10 +4534,11 @@ ${runtime.error}`;
4373
4534
  this.on(owner, "keydown", (event) => {
4374
4535
  if (event.key === "Escape") this.hide();
4375
4536
  });
4376
- if (typeof window !== "undefined") {
4377
- this.on(window, "blur", dismiss);
4378
- this.on(window, "resize", dismiss);
4379
- this.on(window, "scroll", dismiss, true);
4537
+ const view = this.view;
4538
+ if (view !== null) {
4539
+ this.on(view, "blur", dismiss);
4540
+ this.on(view, "resize", dismiss);
4541
+ this.on(view, "scroll", dismiss, true);
4380
4542
  }
4381
4543
  }
4382
4544
  on(target, type, listener, capture = false) {
@@ -4415,7 +4577,8 @@ ${runtime.error}`;
4415
4577
  if (set === void 0) {
4416
4578
  return;
4417
4579
  }
4418
- for (const handler of set) {
4580
+ for (const handler of [...set]) {
4581
+ if (!set.has(handler)) continue;
4419
4582
  try {
4420
4583
  handler(payload);
4421
4584
  } catch (err) {
@@ -4431,11 +4594,23 @@ ${runtime.error}`;
4431
4594
  return (this.handlers.get(event)?.size ?? 0) > 0;
4432
4595
  }
4433
4596
  clearEventHandlers() {
4597
+ for (const set of this.handlers.values()) set.clear();
4434
4598
  this.handlers.clear();
4435
4599
  }
4436
4600
  };
4437
4601
 
4438
4602
  // src/diagram/types.ts
4603
+ function copyJsonValue2(value) {
4604
+ if (Array.isArray(value)) return value.map(copyJsonValue2);
4605
+ if (value !== null && typeof value === "object") return copyJsonObject2(value);
4606
+ return value;
4607
+ }
4608
+ function copyJsonObject2(value) {
4609
+ if (value === void 0) return {};
4610
+ const result = {};
4611
+ for (const [key, item] of Object.entries(value)) setJsonKey(result, key, copyJsonValue2(item));
4612
+ return result;
4613
+ }
4439
4614
  var PortType = class {
4440
4615
  constructor(init) {
4441
4616
  this.name = init.name;
@@ -4473,7 +4648,7 @@ ${runtime.error}`;
4473
4648
  this.id = init.id;
4474
4649
  this.name = init.name;
4475
4650
  this.description = init.description ?? "";
4476
- this.groupName = init.groupName ?? "Common";
4651
+ this.groupName = init.groupName ?? DEFAULT_GROUP_NAME;
4477
4652
  this.inPorts = (init.inPorts ?? []).map((p) => p instanceof Port ? p : new Port(p));
4478
4653
  this.outPorts = (init.outPorts ?? []).map((p) => p instanceof Port ? p : new Port(p));
4479
4654
  this.icon = init.icon ?? "";
@@ -4528,6 +4703,7 @@ ${runtime.error}`;
4528
4703
  });
4529
4704
  }
4530
4705
  };
4706
+ var DEFAULT_GROUP_NAME = "Common";
4531
4707
  var Link = class {
4532
4708
  constructor(init) {
4533
4709
  this.id = init.id ?? "";
@@ -4535,7 +4711,7 @@ ${runtime.error}`;
4535
4711
  this.outPort = init.outPort;
4536
4712
  this.inNode = init.inNode;
4537
4713
  this.inPort = init.inPort;
4538
- this.metadata = init.metadata ?? {};
4714
+ this.metadata = copyJsonObject2(init.metadata);
4539
4715
  }
4540
4716
  };
4541
4717
 
@@ -4549,9 +4725,13 @@ ${runtime.error}`;
4549
4725
  "open",
4550
4726
  "delete",
4551
4727
  { group: "export", commands: ["exportDocument", "exportPng", "exportSvg"] },
4728
+ "overview",
4552
4729
  "properties",
4553
4730
  "help"
4554
4731
  ];
4732
+ var CONTEXT_CHECKED = {
4733
+ overview: (diagram) => diagram.getViewState().overviewVisible
4734
+ };
4555
4735
  var StockSharpDiagram = class extends EventEmitter {
4556
4736
  constructor(options) {
4557
4737
  super();
@@ -4564,7 +4744,10 @@ ${runtime.error}`;
4564
4744
  this.destroyed = false;
4565
4745
  this.fullscreen = false;
4566
4746
  this.fullscreenButtonVisible = true;
4567
- this.fullscreenLabels = { enter: "Enter fullscreen", exit: "Exit fullscreen" };
4747
+ // null means the host never named the button, so its wording comes from the shared bundle.
4748
+ // Keeping the two apart is what lets an explicit label win while an untouched control still
4749
+ // follows the page's language instead of being stuck in English.
4750
+ this.fullscreenLabels = null;
4568
4751
  this.contextActions = new DiagramActionRegistry();
4569
4752
  this.contextMenu = null;
4570
4753
  this.div = options.div;
@@ -4579,11 +4762,15 @@ ${runtime.error}`;
4579
4762
  gridSize: options.gridSize
4580
4763
  });
4581
4764
  this.fullscreenButtonVisible = options.showFullscreenButton ?? true;
4582
- this.fullscreenLabels = options.fullscreenLabels ?? this.fullscreenLabels;
4765
+ this.fullscreenLabels = options.fullscreenLabels ?? null;
4583
4766
  this.prepareFullscreenButtonHost();
4584
4767
  this.fullscreenButton = this.createFullscreenButton();
4585
4768
  this.updateFullscreenButton();
4586
4769
  this.setContextMenuEnabled(options.showContextMenu ?? true);
4770
+ this.canvas.setHistoryShortcutHandler((direction) => {
4771
+ if (direction === "undo") this.undo();
4772
+ else this.redo();
4773
+ });
4587
4774
  this.registerContextActions();
4588
4775
  this.bindCanvasEvents();
4589
4776
  this.disposables.push(this.catalog.on("portTypesChanged", () => this.applySocketTheme()));
@@ -4828,8 +5015,22 @@ ${runtime.error}`;
4828
5015
  this.fullscreenLabels = labels;
4829
5016
  this.updateFullscreenButton();
4830
5017
  }
5018
+ /**
5019
+ * Re-reads the shared bundle for text the control renders itself. Call it after changing
5020
+ * the page language: the context menu is rebuilt on every open and needs nothing, but the
5021
+ * fullscreen button is drawn once and would otherwise keep the old wording.
5022
+ */
5023
+ refreshLabels() {
5024
+ this.updateFullscreenButton();
5025
+ }
4831
5026
  getFullscreenLabels() {
4832
- return { ...this.fullscreenLabels };
5027
+ return { ...this.resolveFullscreenLabels() };
5028
+ }
5029
+ resolveFullscreenLabels() {
5030
+ return this.fullscreenLabels ?? {
5031
+ enter: t("fullscreenEnter", "Enter fullscreen"),
5032
+ exit: t("fullscreenExit", "Exit fullscreen")
5033
+ };
4833
5034
  }
4834
5035
  /** Updates only the control's state after the host changed its own layout. */
4835
5036
  setFullscreenState(fullscreen) {
@@ -4954,10 +5155,14 @@ ${runtime.error}`;
4954
5155
  }
4955
5156
  getContextCommands() {
4956
5157
  const context = this.contextActionContext();
4957
- const state = (command) => ({
4958
- command,
4959
- enabled: this.contextActions.canExecute(command, context)
4960
- });
5158
+ const state = (command) => {
5159
+ const checked = CONTEXT_CHECKED[command];
5160
+ return {
5161
+ command,
5162
+ enabled: this.contextActions.canExecute(command, context),
5163
+ ...checked === void 0 ? {} : { checked: checked(this) }
5164
+ };
5165
+ };
4961
5166
  return CONTEXT_MENU.map((entry) => {
4962
5167
  if (typeof entry === "string") return state(entry);
4963
5168
  const commands = entry.commands.map(state);
@@ -5084,7 +5289,8 @@ ${runtime.error}`;
5084
5289
  }
5085
5290
  updateFullscreenButton() {
5086
5291
  const fullscreen = this.fullscreen;
5087
- const label2 = fullscreen ? this.fullscreenLabels.exit : this.fullscreenLabels.enter;
5292
+ const labels = this.resolveFullscreenLabels();
5293
+ const label2 = fullscreen ? labels.exit : labels.enter;
5088
5294
  const path = fullscreen ? "M4 9h5V4M20 9h-5V4M4 15h5v5M20 15h-5v5" : "M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5";
5089
5295
  this.fullscreenButton.className = fullscreen ? "ssdiagram-fullscreen-button is-active" : "ssdiagram-fullscreen-button";
5090
5296
  this.fullscreenButton.title = label2;
@@ -5247,6 +5453,12 @@ ${runtime.error}`;
5247
5453
  canExecute: ({ document: document2 }) => document2.nodes.length > 0,
5248
5454
  execute: () => this.emit("exportRequested", { format: "svg" })
5249
5455
  },
5456
+ // Always available: it shows the overview as readily as it hides it, and an empty
5457
+ // diagram is exactly when someone may want the panel out of the way.
5458
+ overview: {
5459
+ canExecute: () => true,
5460
+ execute: () => this.setOverviewVisible(!this.getViewState().overviewVisible)
5461
+ },
5250
5462
  properties: {
5251
5463
  canExecute: ({ nodes }) => nodes.length > 0,
5252
5464
  execute: ({ nodes }) => this.emit("nodeProperties", { nodes })
@@ -5669,7 +5881,8 @@ ${runtime.error}`;
5669
5881
  }
5670
5882
  };
5671
5883
  function groupNameOf(node) {
5672
- return node.groupName.length > 0 ? node.groupName : "Common";
5884
+ const group = node.groupName;
5885
+ return group.length > 0 && group !== DEFAULT_GROUP_NAME ? group : t("paletteCommonGroup", DEFAULT_GROUP_NAME);
5673
5886
  }
5674
5887
  function normalizeTypeId(typeId) {
5675
5888
  return typeId.toLowerCase();
@@ -5687,12 +5900,43 @@ ${runtime.error}`;
5687
5900
  function iconUrl(name) {
5688
5901
  return name ? `/diagram-icons/${name}.svg` : "";
5689
5902
  }
5690
- function luminance(hex) {
5691
- const m = hex.match(/^#?([0-9a-fA-F]{6})$/);
5692
- if (m === null)
5693
- return 0;
5694
- const n2 = parseInt(m[1], 16);
5695
- return 0.299 * (n2 >> 16 & 255) + 0.587 * (n2 >> 8 & 255) + 0.114 * (n2 & 255);
5903
+ var CSS_COLOR_KEYWORDS = {
5904
+ white: [255, 255, 255],
5905
+ black: [0, 0, 0],
5906
+ transparent: [255, 255, 255]
5907
+ };
5908
+ function luminance(color) {
5909
+ const value = color.trim().toLowerCase();
5910
+ if (value === "")
5911
+ return -1;
5912
+ const keyword = CSS_COLOR_KEYWORDS[value];
5913
+ if (keyword !== void 0)
5914
+ return 0.299 * keyword[0] + 0.587 * keyword[1] + 0.114 * keyword[2];
5915
+ const long = value.match(/^#?([0-9a-f]{6})$/);
5916
+ if (long !== null) {
5917
+ const n2 = parseInt(long[1], 16);
5918
+ return 0.299 * (n2 >> 16 & 255) + 0.587 * (n2 >> 8 & 255) + 0.114 * (n2 & 255);
5919
+ }
5920
+ const short = value.match(/^#?([0-9a-f]{3})([0-9a-f])?$/);
5921
+ if (short !== null) {
5922
+ const [r, g, b] = [...short[1]].map((digit) => parseInt(digit + digit, 16));
5923
+ return 0.299 * r + 0.587 * g + 0.114 * b;
5924
+ }
5925
+ const long8 = value.match(/^#?([0-9a-f]{6})[0-9a-f]{2}$/);
5926
+ if (long8 !== null) {
5927
+ const n2 = parseInt(long8[1], 16);
5928
+ return 0.299 * (n2 >> 16 & 255) + 0.587 * (n2 >> 8 & 255) + 0.114 * (n2 & 255);
5929
+ }
5930
+ const rgb = value.match(/^rgba?\(([^)]+)\)$/);
5931
+ if (rgb !== null) {
5932
+ const parts = rgb[1].split(/[\s,/]+/).filter((part) => part !== "");
5933
+ if (parts.length >= 3) {
5934
+ const [r, g, b] = parts.slice(0, 3).map((part) => part.endsWith("%") ? parseFloat(part) / 100 * 255 : parseFloat(part));
5935
+ if ([r, g, b].every((channel) => Number.isFinite(channel)))
5936
+ return 0.299 * r + 0.587 * g + 0.114 * b;
5937
+ }
5938
+ }
5939
+ return -1;
5696
5940
  }
5697
5941
  function makePort(p) {
5698
5942
  return new Port({
@@ -5947,6 +6191,7 @@ ${runtime.error}`;
5947
6191
  diagram.setTheme({
5948
6192
  diagramBackground: bg,
5949
6193
  gridColor,
6194
+ // Unreadable colours keep the dark default rather than being treated as black.
5950
6195
  linkMaxLightness: luminance(bg) > 140 ? 0.42 : 1
5951
6196
  });
5952
6197
  };
@@ -5957,7 +6202,7 @@ ${runtime.error}`;
5957
6202
  diagram.setReadOnly(true);
5958
6203
  diagram.setLinkValidator(() => true);
5959
6204
  diagram.setOverviewVisible(false);
5960
- const missingTypeTemplate = div.dataset.diagramMissingElement ?? 'Element type "{typeId}" is missing from the palette.';
6205
+ const missingTypeTemplate = div.dataset.diagramMissingElement ?? t("embedMissingElement", 'Element type "{typeId}" is missing from the palette.');
5961
6206
  const converted = toDiagramNodes(
5962
6207
  scheme,
5963
6208
  catalog,
@@ -6042,9 +6287,9 @@ ${runtime.error}`;
6042
6287
  function errorTexts(div) {
6043
6288
  const parts = (div.dataset.diagramErrors ?? "").split("|");
6044
6289
  return {
6045
- load: parts[0] || "Diagram source could not be loaded.",
6046
- empty: parts[1] || "Diagram is empty or malformed.",
6047
- draw: parts[2] || "Diagram could not be rendered."
6290
+ load: parts[0] || t("embedErrorLoad", "Diagram source could not be loaded."),
6291
+ empty: parts[1] || t("embedErrorEmpty", "Diagram is empty or malformed."),
6292
+ draw: parts[2] || t("embedErrorDraw", "Diagram could not be rendered.")
6048
6293
  };
6049
6294
  }
6050
6295
  function note(div, message, revision) {
@@ -6107,16 +6352,25 @@ ${runtime.error}`;
6107
6352
  root.querySelectorAll(".ss-diagram-host").forEach((host) => {
6108
6353
  if (host.dataset.rendered === "1")
6109
6354
  return;
6355
+ const started = (run) => {
6356
+ host.dataset.rendered = "1";
6357
+ void run.then(
6358
+ (handle) => {
6359
+ if (handle === null) delete host.dataset.rendered;
6360
+ },
6361
+ () => {
6362
+ delete host.dataset.rendered;
6363
+ }
6364
+ );
6365
+ };
6110
6366
  const src = host.dataset.diagramSrc;
6111
6367
  if (src) {
6112
- host.dataset.rendered = "1";
6113
- void renderFromSource(host, PALETTE_URL, src, options);
6368
+ started(renderFromSource(host, PALETTE_URL, src, options));
6114
6369
  return;
6115
6370
  }
6116
6371
  const inline = host.querySelector('script[type="application/json"]');
6117
6372
  if (inline) {
6118
- host.dataset.rendered = "1";
6119
- void renderFromInline(host, PALETTE_URL, inline.textContent ?? "", options);
6373
+ started(renderFromInline(host, PALETTE_URL, inline.textContent ?? "", options));
6120
6374
  }
6121
6375
  });
6122
6376
  }