flexdesk 0.5.0 → 0.6.1
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 +654 -1
- package/css/base.css +2868 -0
- package/css/flexdesk.css +2868 -0
- package/css/tokens.css +72 -0
- package/dist/canvas.js +390 -0
- package/dist/canvas.js.map +7 -0
- package/dist/charts.js +2 -2
- package/dist/{chunk-24MS7W5Y.js → chunk-6NG7YVBG.js} +2 -470
- package/dist/chunk-6NG7YVBG.js.map +7 -0
- package/dist/chunk-H4LT43RS.js +475 -0
- package/dist/chunk-H4LT43RS.js.map +7 -0
- package/dist/{chunk-KH2GJTKR.js → chunk-IYV5JVYB.js} +29 -1
- package/dist/chunk-IYV5JVYB.js.map +7 -0
- package/dist/chunk-SN4LSOAR.js +532 -0
- package/dist/chunk-SN4LSOAR.js.map +7 -0
- package/dist/{chunk-CNXP3SIN.js → chunk-ZMCRDOCF.js} +2 -2
- package/dist/flexdesk.css +2868 -0
- package/dist/flow.js +10030 -0
- package/dist/flow.js.map +7 -0
- package/dist/tiles.js +9 -9
- package/dist/tokens.css +72 -0
- package/dist/widgets.js +87 -5
- package/dist/widgets.js.map +3 -3
- package/dist/wm.js +4 -2
- package/dist/wm.js.map +1 -1
- package/package.json +9 -1
- package/src/canvas/canvas_adapter.js +523 -0
- package/src/canvas/edge_router.js +392 -0
- package/src/canvas/graph/connector_router.js +189 -0
- package/src/canvas/graph/history_service.js +170 -0
- package/src/canvas/graph/node_platform.js +116 -0
- package/src/canvas/index.js +16 -0
- package/src/flow/canvas_editor/editor.js +1081 -0
- package/src/flow/canvas_editor/index.js +16 -0
- package/src/flow/canvas_editor/layout.js +118 -0
- package/src/flow/canvas_editor/strings.js +37 -0
- package/src/flow/kit/always_before.js +325 -0
- package/src/flow/kit/catalogue.js +170 -0
- package/src/flow/kit/chip_input.js +461 -0
- package/src/flow/kit/dom.js +205 -0
- package/src/flow/kit/findings.js +129 -0
- package/src/flow/kit/history.js +173 -0
- package/src/flow/kit/index.js +38 -0
- package/src/flow/kit/keys.js +127 -0
- package/src/flow/kit/logic_graph.js +210 -0
- package/src/flow/kit/references.js +199 -0
- package/src/flow/kit/settings_panel.js +357 -0
- package/src/flow/kit/settings_schema.js +175 -0
- package/src/flow/kit/step_picker.js +209 -0
- package/src/flow/kit/strings.js +102 -0
- package/src/flow/kit/value_picker.js +238 -0
- package/src/flow/kit/widgets.js +763 -0
- package/src/flow/lanes/editor.js +1469 -0
- package/src/flow/lanes/index.js +26 -0
- package/src/flow/lanes/layout.js +525 -0
- package/src/flow/lanes/pipeline.js +327 -0
- package/src/flow/lanes/preview.js +109 -0
- package/src/flow/lanes/strings.js +98 -0
- package/src/flow/outline/editor.js +1511 -0
- package/src/flow/outline/index.js +20 -0
- package/src/flow/outline/mapping.js +286 -0
- package/src/flow/outline/menu.js +154 -0
- package/src/flow/outline/operations.js +738 -0
- package/src/flow/outline/recognise.js +755 -0
- package/src/flow/outline/strings.js +135 -0
- package/src/ui/components/data_table.js +40 -0
- package/src/ui/utils/scroll_lock.js +111 -0
- package/dist/chunk-24MS7W5Y.js.map +0 -7
- package/dist/chunk-KH2GJTKR.js.map +0 -7
- /package/dist/{chunk-CNXP3SIN.js.map → chunk-ZMCRDOCF.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flexdesk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -53,6 +53,14 @@
|
|
|
53
53
|
"./editor": {
|
|
54
54
|
"import": "./dist/editor.js",
|
|
55
55
|
"development": "./editor.js"
|
|
56
|
+
},
|
|
57
|
+
"./canvas": {
|
|
58
|
+
"import": "./dist/canvas.js",
|
|
59
|
+
"development": "./canvas.js"
|
|
60
|
+
},
|
|
61
|
+
"./flow": {
|
|
62
|
+
"import": "./dist/flow.js",
|
|
63
|
+
"development": "./flow.js"
|
|
56
64
|
}
|
|
57
65
|
},
|
|
58
66
|
"scripts": {
|
|
@@ -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
|
+
}
|