flexdesk 0.4.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +1036 -301
  2. package/css/base.css +2958 -2
  3. package/css/flexdesk.css +2962 -4
  4. package/css/overrides.css +4 -2
  5. package/css/tokens.css +86 -0
  6. package/dist/canvas.js +390 -0
  7. package/dist/canvas.js.map +7 -0
  8. package/dist/charts.js +782 -782
  9. package/dist/{chunk-LH5TSOZW.js → chunk-6JSOVNID.js} +42 -2
  10. package/dist/{chunk-LH5TSOZW.js.map → chunk-6JSOVNID.js.map} +3 -3
  11. package/dist/{chunk-ELXVW542.js → chunk-6NG7YVBG.js} +3 -471
  12. package/dist/chunk-6NG7YVBG.js.map +7 -0
  13. package/dist/{chunk-F5FYDCOA.js → chunk-CNXP3SIN.js} +2373 -2373
  14. package/dist/{chunk-F5FYDCOA.js.map → chunk-CNXP3SIN.js.map} +7 -7
  15. package/dist/chunk-H4LT43RS.js +475 -0
  16. package/dist/chunk-H4LT43RS.js.map +7 -0
  17. package/dist/{chunk-WMGOJ3I6.js → chunk-KH2GJTKR.js} +3702 -2628
  18. package/dist/chunk-KH2GJTKR.js.map +7 -0
  19. package/dist/chunk-SN4LSOAR.js +532 -0
  20. package/dist/chunk-SN4LSOAR.js.map +7 -0
  21. package/dist/flexdesk.css +2962 -4
  22. package/dist/flow.js +10030 -0
  23. package/dist/flow.js.map +7 -0
  24. package/dist/tiles.js +2195 -2195
  25. package/dist/tokens.css +86 -0
  26. package/dist/widgets.js +5824 -5796
  27. package/dist/widgets.js.map +1 -1
  28. package/dist/wm.js +8077 -8074
  29. package/dist/wm.js.map +7 -7
  30. package/package.json +89 -81
  31. package/src/canvas/canvas_adapter.js +523 -0
  32. package/src/canvas/edge_router.js +392 -0
  33. package/src/canvas/graph/connector_router.js +189 -0
  34. package/src/canvas/graph/history_service.js +170 -0
  35. package/src/canvas/graph/node_platform.js +116 -0
  36. package/src/canvas/index.js +16 -0
  37. package/src/flow/canvas_editor/editor.js +1081 -0
  38. package/src/flow/canvas_editor/index.js +16 -0
  39. package/src/flow/canvas_editor/layout.js +118 -0
  40. package/src/flow/canvas_editor/strings.js +37 -0
  41. package/src/flow/kit/always_before.js +325 -0
  42. package/src/flow/kit/catalogue.js +170 -0
  43. package/src/flow/kit/chip_input.js +461 -0
  44. package/src/flow/kit/dom.js +205 -0
  45. package/src/flow/kit/findings.js +129 -0
  46. package/src/flow/kit/history.js +173 -0
  47. package/src/flow/kit/index.js +38 -0
  48. package/src/flow/kit/keys.js +127 -0
  49. package/src/flow/kit/logic_graph.js +210 -0
  50. package/src/flow/kit/references.js +199 -0
  51. package/src/flow/kit/settings_panel.js +357 -0
  52. package/src/flow/kit/settings_schema.js +175 -0
  53. package/src/flow/kit/step_picker.js +209 -0
  54. package/src/flow/kit/strings.js +102 -0
  55. package/src/flow/kit/value_picker.js +238 -0
  56. package/src/flow/kit/widgets.js +763 -0
  57. package/src/flow/lanes/editor.js +1469 -0
  58. package/src/flow/lanes/index.js +26 -0
  59. package/src/flow/lanes/layout.js +525 -0
  60. package/src/flow/lanes/pipeline.js +327 -0
  61. package/src/flow/lanes/preview.js +109 -0
  62. package/src/flow/lanes/strings.js +98 -0
  63. package/src/flow/outline/editor.js +1511 -0
  64. package/src/flow/outline/index.js +20 -0
  65. package/src/flow/outline/mapping.js +286 -0
  66. package/src/flow/outline/menu.js +154 -0
  67. package/src/flow/outline/operations.js +738 -0
  68. package/src/flow/outline/recognise.js +755 -0
  69. package/src/flow/outline/strings.js +135 -0
  70. package/src/tiling/landing_table.js +13 -5
  71. package/src/tiling/wm.js +3916 -3916
  72. package/src/ui/components/data_table.js +4205 -3294
  73. package/src/ui/components/row_activation.js +70 -0
  74. package/src/ui/components/table_values.js +423 -0
  75. package/dist/chunk-ELXVW542.js.map +0 -7
  76. package/dist/chunk-WMGOJ3I6.js.map +0 -7
@@ -0,0 +1,523 @@
1
+ /**
2
+ * The canvas kernel — BASE LAYER (36 §4.2).
3
+ *
4
+ * Pan, zoom, rubber-band selection, grid snap, viewport events and
5
+ * `clientToCanvas`. Nothing here knows what a node IS: a schema diagram, a
6
+ * flow editor and a forms designer can all stand on it, and a forms designer
7
+ * would use ONLY this layer, with no ports and no edges. That is the test of
8
+ * whether the split is real.
9
+ *
10
+ * Moved into FlexDesk with its behaviour intact; only the class names changed
11
+ * (`twm-canvas`, `twm-canvas__surface`, `__edges`, `__nodes`, `__marquee`), and
12
+ * the one question it asks about the embedder's elements — "is this press on a
13
+ * node?" — no longer names the embedder's class: a node is anything the
14
+ * embedder put in `nodeLayer` (or what `nodeSelector` names, when given).
15
+ *
16
+ * KNOWN GAP, recorded rather than discovered in an accessibility review:
17
+ * everything here is `mousedown`/`pointerdown` with button checks. There is no
18
+ * touch support and no keyboard navigation of the graph; an editor on top of
19
+ * it supplies its own (the canvas editor's nodes are focusable).
20
+ */
21
+
22
+ const MIN_SCALE = 0.15;
23
+ const MAX_SCALE = 4;
24
+
25
+ /** A brighter rule every N cells, so distance across the surface is readable
26
+ * without counting identical lines. */
27
+ const COARSE_GRID_EVERY = 5;
28
+
29
+ /**
30
+ * Below this many screen pixels, `bringIntoView` does not move at all.
31
+ *
32
+ * A pan nobody can see is not a pan, and this method's contract is that it
33
+ * moves only when it must — so half a pixel is on the "must not" side of it by
34
+ * definition, not by rounding policy.
35
+ *
36
+ * It is also insurance on the one arithmetic that could break that contract:
37
+ * the shift lands the near edge ON the padding, and `edge * scale + translate`
38
+ * recomputed from the new translate need not come back exactly equal to it. An
39
+ * ulp the wrong way would make the next identical request pan again — moving
40
+ * the diagram by a ten-thousandth of a pixel and scheduling a layout save for
41
+ * it. Excluded because excluding it costs one comparison.
42
+ */
43
+ const SUB_PIXEL = 0.5;
44
+
45
+ export class CanvasAdapter {
46
+ /**
47
+ * @param {object} o
48
+ * @param {HTMLElement} o.container the box the canvas fills (it needs a height)
49
+ * @param {number} [o.gridSize] the snap grid, in canvas pixels; 0 = no snap
50
+ * @param {(viewport) => void} [o.onViewportChange] after every pan and zoom — and once,
51
+ * synchronously, from this constructor
52
+ * @param {(event: MouseEvent) => void} [o.onBackgroundMenu] a right-CLICK on the empty surface
53
+ * @param {string|null} [o.nodeSelector] what a node is, when it is not simply "an element of
54
+ * `nodeLayer`"; a press inside one is the embedder's, never a pan or a marquee
55
+ */
56
+ constructor({ container, gridSize = 16, onViewportChange, onBackgroundMenu, nodeSelector = null } = {}) {
57
+ this.container = container;
58
+ this.gridSize = gridSize;
59
+ this.onViewportChange = onViewportChange;
60
+ /**
61
+ * A RIGHT-CLICK ON THE EMPTY SURFACE, handed on rather than swallowed.
62
+ *
63
+ * The listener below has always suppressed the browser's menu on the
64
+ * background, and the argument for that is written where it happens:
65
+ * the background is where this class claims the right button, so a
66
+ * press there is a gesture and a gesture does not open a menu. What it
67
+ * could not do was let the embedder put something in its place —
68
+ * `contextmenu` never left this class, so an embedder wanting a canvas
69
+ * menu had to bind its own listener and then race this one for the
70
+ * right-drag flag, which `consumeRightDrag` clears as it reads. Whoever
71
+ * lost the race opened a menu at the end of a pan.
72
+ *
73
+ * So the decision stays here, where the flag is, and the CONTENT of the
74
+ * menu goes to the embedder. Omitted, nothing changes: the event is
75
+ * suppressed and no menu appears. The kernel still knows nothing about
76
+ * what a node or a command is.
77
+ */
78
+ this.onBackgroundMenu = onBackgroundMenu;
79
+ this.nodeSelector = nodeSelector;
80
+ this.scale = 1;
81
+ this.tx = 0;
82
+ this.ty = 0;
83
+ this.selection = new Set();
84
+
85
+ this.root = document.createElement('div');
86
+ this.root.className = 'twm-canvas';
87
+ this.root.tabIndex = 0;
88
+
89
+ this.surface = document.createElement('div');
90
+ this.surface.className = 'twm-canvas__surface';
91
+
92
+ this.edgeLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
93
+ this.edgeLayer.setAttribute('class', 'twm-canvas__edges');
94
+ this.edgeLayer.setAttribute('overflow', 'visible');
95
+
96
+ this.nodeLayer = document.createElement('div');
97
+ this.nodeLayer.className = 'twm-canvas__nodes';
98
+
99
+ this.marquee = document.createElement('div');
100
+ this.marquee.className = 'twm-canvas__marquee';
101
+ this.marquee.hidden = true;
102
+
103
+ this.surface.append(this.edgeLayer, this.nodeLayer);
104
+ this.root.append(this.surface, this.marquee);
105
+ container.appendChild(this.root);
106
+
107
+ this._bind();
108
+ this._apply();
109
+ }
110
+
111
+ // ── coordinates ─────────────────────────────────────────────────────
112
+ /** Client (screen) point -> canvas point. */
113
+ clientToCanvas(clientX, clientY) {
114
+ const box = this.root.getBoundingClientRect();
115
+ return {
116
+ x: (clientX - box.left - this.tx) / this.scale,
117
+ y: (clientY - box.top - this.ty) / this.scale,
118
+ };
119
+ }
120
+
121
+ canvasToClient(x, y) {
122
+ const box = this.root.getBoundingClientRect();
123
+ return {
124
+ x: x * this.scale + this.tx + box.left,
125
+ y: y * this.scale + this.ty + box.top,
126
+ };
127
+ }
128
+
129
+ snap(value) {
130
+ return this.gridSize ? Math.round(value / this.gridSize) * this.gridSize : value;
131
+ }
132
+
133
+ // ── viewport ────────────────────────────────────────────────────────
134
+ setViewport({ scale, tx, ty }) {
135
+ if (scale !== undefined) this.scale = clamp(scale, MIN_SCALE, MAX_SCALE);
136
+ if (tx !== undefined) this.tx = tx;
137
+ if (ty !== undefined) this.ty = ty;
138
+ this._apply();
139
+ }
140
+
141
+ viewport() {
142
+ return { scale: this.scale, tx: this.tx, ty: this.ty };
143
+ }
144
+
145
+ /**
146
+ * Did the right button just PAN rather than click — and clear the answer.
147
+ *
148
+ * Right-drag pans (see `_bind`), so the `contextmenu` that arrives when the
149
+ * button comes back up is the tail of a gesture, not a request for a menu.
150
+ * The kernel's own root listener has always known this. Nothing else could
151
+ * ask, and everything else needs to: a `contextmenu` handler on a node or an
152
+ * edge is on the TARGET, and target-phase listeners run before the root's,
153
+ * so an embedder that opens a menu opens it at the end of every pan that
154
+ * happens to finish over one of its elements.
155
+ *
156
+ * CONSUMING, not peeking, and that is the whole design. An embedder's
157
+ * element handler will often call `stopPropagation()` — deliberately, so one
158
+ * press never opens two menus — which means the root listener below may
159
+ * never run to do the clearing. A plain getter would therefore leave the
160
+ * flag `true` and suppress the NEXT right-click, turning one wrong menu into
161
+ * a menu that has stopped working. Whoever reads it first clears it; nobody
162
+ * sees it twice; there is no ordering left to get wrong.
163
+ *
164
+ * An embedder that consumes a `true` should still `preventDefault()` and
165
+ * show nothing — suppressing the browser's menu is the root listener's job
166
+ * and it has just been cut out of the loop.
167
+ */
168
+ consumeRightDrag() {
169
+ const dragged = this._rightDragged;
170
+ this._rightDragged = false;
171
+ return dragged;
172
+ }
173
+
174
+ zoomAt(clientX, clientY, factor) {
175
+ const before = this.clientToCanvas(clientX, clientY);
176
+ this.scale = clamp(this.scale * factor, MIN_SCALE, MAX_SCALE);
177
+ const after = this.clientToCanvas(clientX, clientY);
178
+ // Keep the point under the cursor fixed: pan by the difference the zoom
179
+ // introduced. Zooming to the centre instead makes a large graph
180
+ // impossible to navigate.
181
+ this.tx += (after.x - before.x) * this.scale;
182
+ this.ty += (after.y - before.y) * this.scale;
183
+ this._apply();
184
+ }
185
+
186
+ fit(bounds, { padding = 60 } = {}) {
187
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) return;
188
+ const box = this.root.getBoundingClientRect();
189
+ const scale = clamp(
190
+ Math.min((box.width - padding * 2) / bounds.width,
191
+ (box.height - padding * 2) / bounds.height),
192
+ MIN_SCALE, MAX_SCALE);
193
+ this.scale = scale;
194
+ this.tx = (box.width - bounds.width * scale) / 2 - bounds.x * scale;
195
+ this.ty = (box.height - bounds.height * scale) / 2 - bounds.y * scale;
196
+ this._apply();
197
+ }
198
+
199
+ /** What is on screen, in CANVAS coordinates. The inverse of the transform
200
+ * `_apply` writes, and the only honest way for an embedder to ask "is this
201
+ * already visible" without duplicating the arithmetic. */
202
+ visibleBox() {
203
+ const view = this.root.getBoundingClientRect();
204
+ return {
205
+ x: -this.tx / this.scale,
206
+ y: -this.ty / this.scale,
207
+ width: view.width / this.scale,
208
+ height: view.height / this.scale,
209
+ };
210
+ }
211
+
212
+ /** Is all of `box` (canvas coordinates) inside the viewport, with `padding`
213
+ * screen pixels to spare on every side? */
214
+ contains(box, { padding = 0 } = {}) {
215
+ if (!box) return false;
216
+ const view = this.root.getBoundingClientRect();
217
+ // The same tolerance `bringIntoView` refuses to move for: a box it has
218
+ // just brought in must not then be reported as still outside.
219
+ return box.x * this.scale + this.tx >= padding - SUB_PIXEL
220
+ && box.y * this.scale + this.ty >= padding - SUB_PIXEL
221
+ && (box.x + box.width) * this.scale + this.tx <= view.width - padding + SUB_PIXEL
222
+ && (box.y + box.height) * this.scale + this.ty <= view.height - padding + SUB_PIXEL;
223
+ }
224
+
225
+ /**
226
+ * PAN — AND ONLY PAN — UNTIL `box` IS IN VIEW. `this.scale` is never read
227
+ * for a decision and never written.
228
+ *
229
+ * `fit` is the other way to put something on screen and it is a DIFFERENT
230
+ * gesture: it derives a scale from the box and applies it, up to the 4×
231
+ * ceiling. So "take me to this node" arrived as a zoom in until one node
232
+ * filled the pane, and "take me to this line" as whatever scale happened to
233
+ * frame two boxes — in either direction. The reader's complaint was *"do not
234
+ * zoom in, just move viewport if required"*, and it is two complaints, not
235
+ * one:
236
+ *
237
+ * * THE SCALE IS THE USER'S. They set it by scrolling; nothing that is
238
+ * merely navigation may overwrite it. Zooming OUT "to help" fit a wide
239
+ * pair breaks the same rule as zooming in.
240
+ * * ALREADY VISIBLE MEANS NOTHING HAPPENS. `fit` centres unconditionally,
241
+ * so asking for a node you are already looking at slid the whole drawing
242
+ * under the cursor. A pan that was not required is not a smaller version
243
+ * of the right behaviour; it is the wrong one. Hence the first branch of
244
+ * `_panShift`, and hence `_apply()` below is not called at all when both
245
+ * deltas are zero — no transform is rewritten, no `onViewportChange`
246
+ * fires, and so no layout save is scheduled.
247
+ *
248
+ * Otherwise the move is the MINIMUM that brings the box inside: the near
249
+ * edge lands on the padding and the far edge is left wherever it falls.
250
+ * Centring would travel further for no gain.
251
+ *
252
+ * ── WHEN THE BOX IS BIGGER THAN THE WINDOW AT THIS SCALE ──
253
+ *
254
+ * It cannot be brought in, and zooming out is exactly what the rule
255
+ * forbids, so `fallback` names a smaller box worth preferring — for a line,
256
+ * the end nearest what is already on screen. Per axis and independently,
257
+ * because two nodes side by side are usually too wide and perfectly fine
258
+ * vertically. With no fallback (or one that is itself oversized) the rule is
259
+ * "fill the window with as much of it as possible": if the window already
260
+ * lies entirely within the box, nothing can be gained by moving and nothing
261
+ * moves; otherwise the box is pulled across until the dead space on one
262
+ * side is gone.
263
+ *
264
+ * @param {{x, y, width, height}} box — canvas coordinates.
265
+ * @param {{padding?: number, fallback?: object}} [options]
266
+ * @returns {{moved: boolean, contained: boolean}} `contained` reports
267
+ * whether ALL of `box` ended up on screen, so a caller can say why
268
+ * half of what it named is still not visible rather than leaving
269
+ * the user to conclude the click failed.
270
+ */
271
+ bringIntoView(box, { padding = 40, fallback = null } = {}) {
272
+ if (!box || box.width < 0 || box.height < 0) {
273
+ return { moved: false, contained: false };
274
+ }
275
+ const view = this.root.getBoundingClientRect();
276
+ const dx = ignoreSubPixel(
277
+ this._panShift(box.x, box.width, view.width, this.tx, padding,
278
+ fallback && { start: fallback.x, size: fallback.width }));
279
+ const dy = ignoreSubPixel(
280
+ this._panShift(box.y, box.height, view.height, this.ty, padding,
281
+ fallback && { start: fallback.y, size: fallback.height }));
282
+ const moved = dx !== 0 || dy !== 0;
283
+ if (moved) {
284
+ this.tx += dx;
285
+ this.ty += dy;
286
+ this._apply();
287
+ }
288
+ return { moved, contained: this.contains(box) };
289
+ }
290
+
291
+ /**
292
+ * One axis of `bringIntoView`, in SCREEN pixels: how far the translate has
293
+ * to move. Positive is right/down. Returns 0 for "already in view", which is
294
+ * the branch the whole method exists for.
295
+ */
296
+ _panShift(start, size, viewportSize, translate, padding, fallback) {
297
+ // A padding wider than half the window would put the two limits the
298
+ // wrong way round and turn every case below into nonsense. A pane can be
299
+ // that narrow while it is being dragged.
300
+ const pad = Math.min(padding, Math.max(0, (viewportSize - 1) / 2));
301
+ const low = pad;
302
+ const high = viewportSize - pad;
303
+ const near = start * this.scale + translate;
304
+ const far = (start + size) * this.scale + translate;
305
+ if (near >= low && far <= high) return 0; // nothing to do
306
+ if (far - near <= high - low) { // it fits: minimum move
307
+ return near < low ? low - near : high - far;
308
+ }
309
+ if (fallback) {
310
+ return this._panShift(fallback.start, fallback.size, viewportSize,
311
+ translate, padding, null);
312
+ }
313
+ if (near <= low && far >= high) return 0; // the window is full of it
314
+ return near > low ? low - near : high - far;
315
+ }
316
+
317
+ _apply() {
318
+ this.surface.style.transform =
319
+ `translate(${this.tx}px, ${this.ty}px) scale(${this.scale})`;
320
+ // SEVEN background layers, in the order `.twm-canvas` declares them:
321
+ // grain, lighting, coarse grid ×2, fine grid ×2, ground. Only the four
322
+ // GRID layers follow the pan and the zoom — the grain is a material and
323
+ // the lighting belongs to the window, and zooming either makes the
324
+ // surface swim under the content instead of sitting behind it.
325
+ //
326
+ // The list length MUST match the stylesheet. A short list makes the
327
+ // browser cycle it, so layer 5 silently takes layer 1's size. An
328
+ // embedder that restyles the ground keeps seven layers, in this order.
329
+ const fine = this.gridSize * this.scale;
330
+ const coarse = fine * COARSE_GRID_EVERY;
331
+ this.root.style.backgroundSize = [
332
+ '160px 160px', // grain — its own fixed scale
333
+ 'auto', // lighting — fills the element
334
+ `${coarse}px ${coarse}px`,
335
+ `${coarse}px ${coarse}px`,
336
+ `${fine}px ${fine}px`,
337
+ `${fine}px ${fine}px`,
338
+ 'auto', // the ground
339
+ ].join(', ');
340
+ const offset = `${this.tx}px ${this.ty}px`;
341
+ this.root.style.backgroundPosition =
342
+ ['0 0', '0 0', offset, offset, offset, offset, '0 0'].join(', ');
343
+ this.onViewportChange?.(this.viewport());
344
+ }
345
+
346
+ /**
347
+ * Is `target` inside one of the embedder's nodes? A node is whatever the
348
+ * embedder put in the node layer — or, with `nodeSelector`, what that
349
+ * selector names. A press there belongs to the embedder: it starts no pan
350
+ * and no marquee, and its right-click keeps whatever menu the embedder (or,
351
+ * failing that, the browser) gives it.
352
+ */
353
+ _onNode(target) {
354
+ if (!target || typeof target.closest !== 'function') return false;
355
+ if (this.nodeSelector) return Boolean(target.closest(this.nodeSelector));
356
+ return target !== this.nodeLayer && this.nodeLayer.contains(target);
357
+ }
358
+
359
+ // ── input ───────────────────────────────────────────────────────────
360
+ _bind() {
361
+ this.root.addEventListener('wheel', (event) => {
362
+ event.preventDefault();
363
+ if (event.ctrlKey || event.metaKey) {
364
+ this.zoomAt(event.clientX, event.clientY, event.deltaY < 0 ? 1.12 : 1 / 1.12);
365
+ } else {
366
+ this.tx -= event.deltaX;
367
+ this.ty -= event.deltaY;
368
+ this._apply();
369
+ }
370
+ }, { passive: false });
371
+
372
+ // RIGHT-DRAG PANS, and the browser menu gets out of the way for it.
373
+ //
374
+ // Middle-drag is the canonical pan and most mice have the button; a
375
+ // laptop trackpad does not, and Shift+drag is a chord you have to be
376
+ // told about. Right-drag is what every map and every node editor does,
377
+ // and it costs one `contextmenu` handler.
378
+ //
379
+ // ── AND THE BACKGROUND EATS THE MENU WHETHER OR NOT IT MOVED ──
380
+ //
381
+ // "Right-click on the canvas if nothing is behind should be blocked."
382
+ // It was not: nothing on the empty surface calls `preventDefault`, so a
383
+ // right-click there produced the BROWSER's menu — Back, Reload, Save as,
384
+ // Inspect — over the drawing. Suppressing only after a drag made that
385
+ // worse rather than better, because whether you got a browser menu at
386
+ // the end of a pan then depended on having moved more than three pixels.
387
+ //
388
+ // THE RULE IS "SUPPRESS EXACTLY WHERE THIS CLASS CLAIMS THE RIGHT
389
+ // BUTTON", and that is the same test `pointerdown` below already makes:
390
+ // it bails out inside a node and pans everywhere else. So the background
391
+ // is ours — right-press there is a gesture, and a gesture does not hand
392
+ // the browser a menu — while a node is the embedder's, and one that has
393
+ // no menu of its own KEEPS THE BROWSER'S. Taking a working menu away to
394
+ // put nothing in its place is a loss, unless the press was already
395
+ // spoken for.
396
+ //
397
+ // `rightDragged` still has to be consulted first. A pan that ends over a
398
+ // node dispatches `contextmenu` with that node as the target on the
399
+ // platforms that fire it on release, and the menu must not come back
400
+ // just because the pointer finished somewhere else.
401
+ //
402
+ // Nothing is shown in the menu's place. An empty menu is worse than
403
+ // none: it reads as a feature that has broken rather than as a surface
404
+ // that has no commands.
405
+ //
406
+ // ── AND THE GUARD HAS TO BE REACHABLE FROM AN ELEMENT HANDLER ──
407
+ //
408
+ // A `contextmenu` listener bound to a NODE or an EDGE is on the target,
409
+ // and target-phase listeners run BEFORE the ones bubbling to the root.
410
+ // So an embedder's menu is already on screen by the time root gets to
411
+ // consult the flag — and if that handler calls `stopPropagation()` root
412
+ // never runs AT ALL, which leaves the flag stuck `true` and poisons the
413
+ // NEXT right-click as well. Hence instance state with a CONSUMING reader
414
+ // (`consumeRightDrag`): whichever handler reaches it first gets the
415
+ // answer, and no second handler can see a stale `true`.
416
+ this._rightDragged = false;
417
+ this.root.addEventListener('contextmenu', (event) => {
418
+ const afterDrag = this.consumeRightDrag();
419
+ if (!afterDrag && this._onNode(event.target)) return;
420
+ event.preventDefault();
421
+ event.stopPropagation();
422
+ // ONLY A CLICK, NEVER THE TAIL OF A PAN — the same test the two
423
+ // lines above make, read once.
424
+ if (!afterDrag) this.onBackgroundMenu?.(event);
425
+ });
426
+
427
+ this.root.addEventListener('pointerdown', (event) => {
428
+ // ── THE RESET IS ABOVE THE NODE BAIL, AND THAT ORDER IS THE FIX ──
429
+ //
430
+ // A NEW PRESS MEANS THE LAST GESTURE IS OVER, wherever it landed.
431
+ // Below the bail, a press inside a node would return before reaching
432
+ // it — and once the flag is readable from a target handler, that
433
+ // turns a dead bit into a swallowed menu. Two gestures reach it:
434
+ //
435
+ // THE MENU IS NOT ALWAYS OURS TO CONSUME. An embedder's toolbar
436
+ // and side panels are siblings of the canvas, not descendants of
437
+ // `root`. Right-press the background, drag, release over one:
438
+ // `contextmenu` fires there, no listener of ours runs, and the
439
+ // flag stays armed.
440
+ //
441
+ // AND ON WINDOWS/X11 `contextmenu` FIRES AT THE PRESS, not the
442
+ // release. So the only menu event of a right-drag pan has already
443
+ // been and gone — with the flag still false — by the time the drag
444
+ // sets it. Every pan on those platforms ends armed.
445
+ //
446
+ // Either way the next right-click on a node was eaten: its handler
447
+ // consumed a `true` left over from a gesture that ended somewhere
448
+ // else, suppressed the browser's menu and showed nothing.
449
+ //
450
+ // Clearing in `onUp` instead would be wrong, and quietly: on the
451
+ // release-order platforms `contextmenu` arrives AFTER `pointerup`,
452
+ // so the guard would be gone before the event it exists for.
453
+ this._rightDragged = false;
454
+ if (this._onNode(event.target)) return;
455
+ // Middle button, right button or Shift-drag pans; left button
456
+ // rubber-bands.
457
+ const panning = event.button === 1 || event.button === 2 || event.shiftKey;
458
+ const start = { x: event.clientX, y: event.clientY, tx: this.tx, ty: this.ty };
459
+ const origin = this.clientToCanvas(event.clientX, event.clientY);
460
+
461
+ if (!panning) {
462
+ this.marquee.hidden = false;
463
+ this.selection.clear();
464
+ this.onSelectionChange?.(this.selection);
465
+ }
466
+
467
+ const onMove = (move) => {
468
+ if (panning) {
469
+ // A few pixels of slop, so a right-click with a shaky hand
470
+ // is still a click and still gets its menu.
471
+ if (event.button === 2
472
+ && (Math.abs(move.clientX - start.x) > 3
473
+ || Math.abs(move.clientY - start.y) > 3)) {
474
+ this._rightDragged = true;
475
+ }
476
+ this.tx = start.tx + (move.clientX - start.x);
477
+ this.ty = start.ty + (move.clientY - start.y);
478
+ this._apply();
479
+ return;
480
+ }
481
+ const current = this.clientToCanvas(move.clientX, move.clientY);
482
+ const box = {
483
+ x: Math.min(origin.x, current.x),
484
+ y: Math.min(origin.y, current.y),
485
+ width: Math.abs(current.x - origin.x),
486
+ height: Math.abs(current.y - origin.y),
487
+ };
488
+ const topLeft = this.canvasToClient(box.x, box.y);
489
+ const root = this.root.getBoundingClientRect();
490
+ this.marquee.style.left = `${topLeft.x - root.left}px`;
491
+ this.marquee.style.top = `${topLeft.y - root.top}px`;
492
+ this.marquee.style.width = `${box.width * this.scale}px`;
493
+ this.marquee.style.height = `${box.height * this.scale}px`;
494
+ this.onMarquee?.(box);
495
+ };
496
+ const onUp = () => {
497
+ window.removeEventListener('pointermove', onMove);
498
+ window.removeEventListener('pointerup', onUp);
499
+ this.marquee.hidden = true;
500
+ };
501
+ window.addEventListener('pointermove', onMove);
502
+ window.addEventListener('pointerup', onUp);
503
+ });
504
+ }
505
+
506
+ clear() {
507
+ this.nodeLayer.replaceChildren();
508
+ this.edgeLayer.replaceChildren();
509
+ }
510
+
511
+ destroy() {
512
+ this.root.remove();
513
+ }
514
+ }
515
+
516
+ function clamp(value, low, high) {
517
+ return Math.max(low, Math.min(value, high));
518
+ }
519
+
520
+ /** A pan nobody could see is not a pan. See `SUB_PIXEL`. */
521
+ function ignoreSubPixel(shift) {
522
+ return Math.abs(shift) < SUB_PIXEL ? 0 : shift;
523
+ }