@stocksharp/diagram 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/canvas-renderer.js +185 -34
- package/dist/esm/canvas-renderer.js.map +1 -1
- package/dist/esm/core/document.js +24 -12
- package/dist/esm/core/document.js.map +1 -1
- package/dist/esm/core/history.js +12 -3
- package/dist/esm/core/history.js.map +1 -1
- package/dist/esm/core/json.js +10 -0
- package/dist/esm/core/json.js.map +1 -1
- package/dist/esm/diagram/context-menu.js +28 -10
- package/dist/esm/diagram/context-menu.js.map +1 -1
- package/dist/esm/diagram/event-emitter.js +12 -1
- package/dist/esm/diagram/event-emitter.js.map +1 -1
- package/dist/esm/diagram/palette.js +9 -1
- package/dist/esm/diagram/palette.js.map +1 -1
- package/dist/esm/diagram/stocksharp-diagram.js +8 -0
- package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
- package/dist/esm/diagram/types.js +29 -2
- package/dist/esm/diagram/types.js.map +1 -1
- package/dist/esm/embed.js +63 -10
- package/dist/esm/embed.js.map +1 -1
- package/dist/esm/i18n.js.map +1 -1
- package/dist/esm/svg-surface.js +10 -2
- package/dist/esm/svg-surface.js.map +1 -1
- package/dist/ssdiagram.js +253 -74
- package/dist/ssdiagram.js.map +3 -3
- package/dist/types/canvas-renderer.d.ts +19 -1
- package/dist/types/canvas-renderer.d.ts.map +1 -1
- package/dist/types/core/document.d.ts.map +1 -1
- package/dist/types/core/history.d.ts +1 -0
- package/dist/types/core/history.d.ts.map +1 -1
- package/dist/types/core/json.d.ts +8 -0
- package/dist/types/core/json.d.ts.map +1 -1
- package/dist/types/diagram/context-menu.d.ts +6 -0
- package/dist/types/diagram/context-menu.d.ts.map +1 -1
- package/dist/types/diagram/event-emitter.d.ts.map +1 -1
- package/dist/types/diagram/palette.d.ts.map +1 -1
- package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
- package/dist/types/diagram/types.d.ts +5 -0
- package/dist/types/diagram/types.d.ts.map +1 -1
- package/dist/types/embed.d.ts.map +1 -1
- package/dist/types/i18n.d.ts +2 -0
- package/dist/types/i18n.d.ts.map +1 -1
- package/dist/types/svg-surface.d.ts +1 -1
- package/dist/types/svg-surface.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/canvas-renderer.ts +173 -34
- package/src/core/document.ts +25 -12
- package/src/core/history.ts +13 -3
- package/src/core/json.ts +11 -0
- package/src/diagram/context-menu.ts +30 -10
- package/src/diagram/event-emitter.ts +10 -1
- package/src/diagram/palette.ts +9 -2
- package/src/diagram/stocksharp-diagram.ts +5 -0
- package/src/diagram/types.ts +29 -3
- package/src/embed.ts +71 -10
- package/src/i18n.ts +2 -0
- package/src/svg-surface.ts +11 -2
|
@@ -6,7 +6,7 @@ import { SvgSurface } from './svg-surface.js';
|
|
|
6
6
|
// selection, delete, zoom/pan, load/save round-trip.
|
|
7
7
|
import { createDiagramDocument, parseDiagramDocument } from './core/document.js';
|
|
8
8
|
import { DiagramCommandHistory } from './core/history.js';
|
|
9
|
-
import { setJsonKey } from './core/json.js';
|
|
9
|
+
import { getJsonKey, setJsonKey } from './core/json.js';
|
|
10
10
|
import { toPortDynamicMode } from './core/model.js';
|
|
11
11
|
import { cloneDiagramRuntimeState, createDiagramNodeRuntimeState, createDiagramPortRuntimeState, createDiagramRuntimeState, createEditableDiagramPermissions, createReadOnlyDiagramPermissions, } from './core/state.js';
|
|
12
12
|
const SCREEN_RENDER_OPTIONS = {
|
|
@@ -275,15 +275,26 @@ export class Diagram {
|
|
|
275
275
|
this.introStart = null;
|
|
276
276
|
this.overviewVisible = true;
|
|
277
277
|
this.ovDragging = false;
|
|
278
|
+
this.historyShortcut = (direction) => { if (direction === 'undo')
|
|
279
|
+
this.undo();
|
|
280
|
+
else
|
|
281
|
+
this.redo(); };
|
|
278
282
|
this.ovGeo = null;
|
|
279
283
|
this.validator = null;
|
|
280
284
|
this.handlers = new Map();
|
|
281
285
|
this.opts = opts;
|
|
286
|
+
// Same normalization setTypeColors applies, so a palette handed in at construction is
|
|
287
|
+
// keyed like one handed in later.
|
|
288
|
+
if (opts.typeColors !== undefined)
|
|
289
|
+
this.setTypeColorsInternal(opts.typeColors);
|
|
282
290
|
this.host = opts.host;
|
|
283
291
|
this.gridSnapEnabled = opts.gridSnap ?? false;
|
|
284
292
|
this.gridSize = this.normalizeGridSize(opts.gridSize ?? DEFAULT_GRID_SIZE);
|
|
285
|
-
this.history = new DiagramCommandHistory((
|
|
286
|
-
|
|
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
|
|
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) => ({
|
|
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
|
|
1277
|
-
const previousRuntime = previous.nodes
|
|
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
|
-
|
|
1312
|
-
|
|
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
|
|
1341
|
-
state.nodes
|
|
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
|
|
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.
|
|
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
|
|
1650
|
+
const normalized = normalizePortType(type);
|
|
1651
|
+
const base = getJsonKey(map, normalized) ?? `hsl(${hashHue(normalized)}, 62%, 58%)`;
|
|
1547
1652
|
return maxL !== undefined ? clampLightness(base, maxL) : base;
|
|
1548
1653
|
}
|
|
1549
1654
|
// Selection/hover accent. The dark-canvas blue washes out on a light canvas, so a light theme
|
|
@@ -1558,12 +1663,18 @@ export class Diagram {
|
|
|
1558
1663
|
}
|
|
1559
1664
|
// ---- hit testing (world coords) ---------------------------------
|
|
1560
1665
|
portAt(wx, wy) {
|
|
1666
|
+
// Walks front to back and stops at the first node whose BODY covers the point, so a socket
|
|
1667
|
+
// hidden underneath a node drawn on top of it can no longer win the hit test. Without that
|
|
1668
|
+
// stop, clicking the visible body of the front node selected the one behind it, and on an
|
|
1669
|
+
// output socket started dragging a wire out of a port that is not on screen.
|
|
1561
1670
|
for (let i = this.nodes.length - 1; i >= 0; i -= 1) {
|
|
1562
1671
|
const n = this.nodes[i];
|
|
1563
1672
|
for (const p of [...n.inPorts, ...n.outPorts]) {
|
|
1564
1673
|
if ((wx - p.cx) ** 2 + (wy - p.cy) ** 2 <= (PORT_R + 4) ** 2)
|
|
1565
1674
|
return { node: n, port: p };
|
|
1566
1675
|
}
|
|
1676
|
+
if (wx >= n.x && wx <= n.x + n.w && wy >= n.y && wy <= n.y + n.h)
|
|
1677
|
+
return null;
|
|
1567
1678
|
}
|
|
1568
1679
|
return null;
|
|
1569
1680
|
}
|
|
@@ -1868,6 +1979,9 @@ export class Diagram {
|
|
|
1868
1979
|
fromPort: l.from.portId,
|
|
1869
1980
|
to: t,
|
|
1870
1981
|
toPort: l.to.portId,
|
|
1982
|
+
// The clipboard is built from saveDocument(), so it carries the style;
|
|
1983
|
+
// rebuilding without it silently turned every pasted dashed link solid.
|
|
1984
|
+
style: l.style,
|
|
1871
1985
|
metadata: l.metadata,
|
|
1872
1986
|
});
|
|
1873
1987
|
}
|
|
@@ -1958,14 +2072,21 @@ export class Diagram {
|
|
|
1958
2072
|
// move-beyond-tolerance, pointerup, or any other gesture.
|
|
1959
2073
|
this.cancelLongPress();
|
|
1960
2074
|
this.relinkCandidate = null;
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
this.
|
|
1968
|
-
|
|
2075
|
+
// Touch only. A mouse has a button for this, and arming the timer for it meant holding
|
|
2076
|
+
// the left button still -- which is what positioning a node precisely looks like --
|
|
2077
|
+
// opened the menu after half a second and wiped the drag in progress: the pixels the
|
|
2078
|
+
// node had already travelled stayed, but never reached history, so Ctrl+Z could not
|
|
2079
|
+
// take them back.
|
|
2080
|
+
if (e.pointerType !== 'mouse') {
|
|
2081
|
+
this.lpStart = { sx: e.clientX, sy: e.clientY, px: e.clientX, py: e.clientY };
|
|
2082
|
+
const downSx = e.clientX, downSy = e.clientY;
|
|
2083
|
+
const r0 = this.canvas.getBoundingClientRect();
|
|
2084
|
+
const localSx = downSx - r0.left, localSy = downSy - r0.top;
|
|
2085
|
+
this.lpTimer = setTimeout(() => {
|
|
2086
|
+
this.lpTimer = null;
|
|
2087
|
+
this.fireContextMenu(localSx, localSy, downSx, downSy);
|
|
2088
|
+
}, this.lpDelayMs);
|
|
2089
|
+
}
|
|
1969
2090
|
this.canvas.focus();
|
|
1970
2091
|
const [sx, sy] = localXY(e);
|
|
1971
2092
|
if (this.ovHit(sx, sy)) {
|
|
@@ -2050,14 +2171,16 @@ export class Diagram {
|
|
|
2050
2171
|
if (node !== null) {
|
|
2051
2172
|
const add = e.shiftKey || e.ctrlKey || e.metaKey;
|
|
2052
2173
|
if (this.permissions.select) {
|
|
2053
|
-
|
|
2174
|
+
// A right-click still selects what it landed on, so the menu that follows acts
|
|
2175
|
+
// on the node under the cursor -- but only the primary button may drag it.
|
|
2176
|
+
if (add && e.button === 0) {
|
|
2054
2177
|
this.toggleSelect(node);
|
|
2055
2178
|
return;
|
|
2056
2179
|
}
|
|
2057
2180
|
if (!this.selectedNodes.has(node))
|
|
2058
2181
|
this.selectNode(node);
|
|
2059
2182
|
}
|
|
2060
|
-
if (this.permissions.moveNodes) {
|
|
2183
|
+
if (this.permissions.moveNodes && e.button === 0) {
|
|
2061
2184
|
this.dragNode = node;
|
|
2062
2185
|
this.dragAnchor = { wx, wy };
|
|
2063
2186
|
const moving = this.selectedNodes.has(node) ? [...this.selectedNodes] : [node];
|
|
@@ -2073,6 +2196,10 @@ export class Diagram {
|
|
|
2073
2196
|
this.selectLink(link);
|
|
2074
2197
|
return;
|
|
2075
2198
|
}
|
|
2199
|
+
// A secondary button on empty canvas is on its way to the context menu: it must not
|
|
2200
|
+
// clear the selection the menu is about to act on, nor start a rubber band.
|
|
2201
|
+
if (e.button !== 0 && e.button !== 1)
|
|
2202
|
+
return;
|
|
2076
2203
|
// empty space: middle-button / Ctrl / Alt / touch = pan;
|
|
2077
2204
|
// else rubber-band select. Touch always pans because mobile
|
|
2078
2205
|
// users can't hold modifier keys and rubber-band drag-select
|
|
@@ -2228,8 +2355,14 @@ export class Diagram {
|
|
|
2228
2355
|
const moves = this.dragStart
|
|
2229
2356
|
.map((it) => ({ id: it.n.id, fromX: it.x, fromY: it.y, toX: it.n.x, toY: it.n.y }))
|
|
2230
2357
|
.filter((m) => m.fromX !== m.toX || m.fromY !== m.toY);
|
|
2231
|
-
for
|
|
2232
|
-
|
|
2358
|
+
// Announce the same set the undo step records. Emitting for every node the drag
|
|
2359
|
+
// touched reported a move for a plain selection click -- one per selected node --
|
|
2360
|
+
// and a host doing dirty tracking marked the strategy modified for nothing.
|
|
2361
|
+
const moved = new Set(moves.map((m) => m.id));
|
|
2362
|
+
for (const it of this.dragStart) {
|
|
2363
|
+
if (moved.has(it.n.id))
|
|
2364
|
+
this.emit('nodeMoved', { node: it.n });
|
|
2365
|
+
}
|
|
2233
2366
|
this.dragNode = null;
|
|
2234
2367
|
this.dragStart = [];
|
|
2235
2368
|
if (moves.length > 0) {
|
|
@@ -2321,6 +2454,10 @@ export class Diagram {
|
|
|
2321
2454
|
const [wx, wy] = this.toWorld(sx, sy);
|
|
2322
2455
|
if (this.portAt(wx, wy) !== null)
|
|
2323
2456
|
return;
|
|
2457
|
+
// A wire is something, not empty canvas: without this the zoomToFit fallback below
|
|
2458
|
+
// threw away the zoom and pan the user had set, on a double click aimed at a link.
|
|
2459
|
+
if (this.linkAt(wx, wy) !== null)
|
|
2460
|
+
return;
|
|
2324
2461
|
const node = this.nodeAt(wx, wy);
|
|
2325
2462
|
if (node !== null) {
|
|
2326
2463
|
if (this.permissions.inspect && node.openAction.length > 0) {
|
|
@@ -2367,6 +2504,11 @@ export class Diagram {
|
|
|
2367
2504
|
this.linking = null;
|
|
2368
2505
|
this.relinking = null;
|
|
2369
2506
|
this.relinkCandidate = null;
|
|
2507
|
+
// The two the block used to miss: a finger that started on the minimap kept
|
|
2508
|
+
// panning the viewport against the pinch, and a cancelled wire drag left its
|
|
2509
|
+
// magnet highlight burned onto a socket until something else redrew it.
|
|
2510
|
+
this.ovDragging = false;
|
|
2511
|
+
this.linkSnap = null;
|
|
2370
2512
|
}
|
|
2371
2513
|
}, { passive: false });
|
|
2372
2514
|
this.listen(this.canvas, 'touchmove', (e) => {
|
|
@@ -2429,15 +2571,18 @@ export class Diagram {
|
|
|
2429
2571
|
this.setSelection(this.nodes.slice());
|
|
2430
2572
|
return;
|
|
2431
2573
|
}
|
|
2432
|
-
// Ctrl+Z = undo; Ctrl+Y or Ctrl+Shift+Z = redo.
|
|
2574
|
+
// Ctrl+Z = undo; Ctrl+Y or Ctrl+Shift+Z = redo. Routed through a handler rather than
|
|
2575
|
+
// called directly: a component wrapping this renderer has its own rules about when
|
|
2576
|
+
// history may run and its own events to announce it, and the keyboard must obey them
|
|
2577
|
+
// like every other entry point.
|
|
2433
2578
|
if (mod && this.permissions.history && e.code === 'KeyZ' && !e.shiftKey) {
|
|
2434
2579
|
e.preventDefault();
|
|
2435
|
-
this.undo
|
|
2580
|
+
this.historyShortcut('undo');
|
|
2436
2581
|
return;
|
|
2437
2582
|
}
|
|
2438
2583
|
if (mod && this.permissions.history && (e.code === 'KeyY' || (e.code === 'KeyZ' && e.shiftKey))) {
|
|
2439
2584
|
e.preventDefault();
|
|
2440
|
-
this.redo
|
|
2585
|
+
this.historyShortcut('redo');
|
|
2441
2586
|
return;
|
|
2442
2587
|
}
|
|
2443
2588
|
});
|
|
@@ -2532,20 +2677,26 @@ export class Diagram {
|
|
|
2532
2677
|
if (options.overview)
|
|
2533
2678
|
this.drawOverview();
|
|
2534
2679
|
if (options.runtime)
|
|
2535
|
-
this.drawGlobalError();
|
|
2680
|
+
this.drawGlobalError(options.transient);
|
|
2536
2681
|
if (options.transient)
|
|
2537
2682
|
this.drawTooltip();
|
|
2538
2683
|
if (options.transient && (this.introStart !== null || this.globalErrorFlashStart !== null
|
|
2539
2684
|
|| this.nodes.some((n) => n.errorFlashStart !== null)))
|
|
2540
2685
|
this.scheduleDraw(); // keep entrance / error feedback animating
|
|
2541
2686
|
}
|
|
2542
|
-
|
|
2687
|
+
/**
|
|
2688
|
+
* `transient` gates the flash exactly as it gates every other animation, which an export must
|
|
2689
|
+
* not see: it would bake in whatever point of the pulse it caught, so the same export taken
|
|
2690
|
+
* twice differed in opacity. The gate also keeps the export from consuming the animation --
|
|
2691
|
+
* clearing globalErrorFlashStart from an export cut the flash short on screen.
|
|
2692
|
+
*/
|
|
2693
|
+
drawGlobalError(transient) {
|
|
2543
2694
|
const error = this.runtimeState.globalError;
|
|
2544
2695
|
if (error === null)
|
|
2545
2696
|
return;
|
|
2546
2697
|
const ctx = this.ctx;
|
|
2547
2698
|
let flashAlpha = 1;
|
|
2548
|
-
if (this.globalErrorFlashStart !== null) {
|
|
2699
|
+
if (transient && this.globalErrorFlashStart !== null) {
|
|
2549
2700
|
const elapsed = performance.now() - this.globalErrorFlashStart;
|
|
2550
2701
|
if (elapsed >= ERROR_FLASH_MS) {
|
|
2551
2702
|
this.globalErrorFlashStart = null;
|
|
@@ -2734,7 +2885,7 @@ export class Diagram {
|
|
|
2734
2885
|
const ctx = this.ctx;
|
|
2735
2886
|
const hasLoadError = options.runtime && n.loadError.length > 0;
|
|
2736
2887
|
const hasRuntimeError = options.runtime && n.runtimeError.length > 0;
|
|
2737
|
-
const runtime = options.runtime ? this.runtimeState.nodes
|
|
2888
|
+
const runtime = options.runtime ? getJsonKey(this.runtimeState.nodes, n.id) : undefined;
|
|
2738
2889
|
const active = options.runtime
|
|
2739
2890
|
&& (this.runtimeState.activeNodeId === n.id || runtime?.active === true);
|
|
2740
2891
|
roundRect(ctx, n.x, n.y, n.w, n.h, 6);
|
|
@@ -2834,7 +2985,7 @@ export class Diagram {
|
|
|
2834
2985
|
}
|
|
2835
2986
|
}
|
|
2836
2987
|
portRuntimeState(node, port) {
|
|
2837
|
-
return this.runtimeState.nodes
|
|
2988
|
+
return getJsonKey(this.runtimeState.nodes, node.id)?.ports[port.direction][port.id] ?? null;
|
|
2838
2989
|
}
|
|
2839
2990
|
// Symmetric jump-over: any segment of THIS link hops the perpendicular
|
|
2840
2991
|
// segments of links drawn before it (H over earlier V, V over earlier
|