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