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