@syware/jlib 0.1.3 → 0.3.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 +3 -17
- package/dist/all.js +5 -4
- package/dist/atoms/jl-icon.css_raw.js +1 -1
- package/dist/atoms/jl-icon.d.ts +7 -1
- package/dist/atoms/jl-icon.js +4 -1
- package/dist/atoms/jl-input.d.ts +1 -0
- package/dist/atoms/jl-input.js +2 -0
- package/dist/atoms/jl-progress-ring.js +1 -6
- package/dist/atoms/jl-select.d.ts +6 -0
- package/dist/atoms/jl-select.js +18 -1
- package/dist/atoms/jl-switch.d.ts +12 -1
- package/dist/atoms/jl-switch.js +11 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -3
- package/dist/lib/calendar.d.ts +15 -1
- package/dist/lib/calendar.js +72 -1
- package/dist/lib/dom.d.ts +2 -0
- package/dist/lib/dom.js +8 -1
- package/dist/lib/flow.d.ts +70 -0
- package/dist/lib/flow.js +353 -0
- package/dist/lib/options.d.ts +5 -0
- package/dist/lib/options.js +13 -1
- package/dist/molecules/jl-accordion-item.css_raw.js +1 -1
- package/dist/molecules/jl-accordion-item.d.ts +6 -1
- package/dist/molecules/jl-accordion-item.js +21 -6
- package/dist/molecules/jl-alert.d.ts +1 -0
- package/dist/molecules/jl-alert.js +3 -1
- package/dist/molecules/jl-date-picker.d.ts +10 -4
- package/dist/molecules/jl-date-picker.js +21 -8
- package/dist/molecules/jl-search.d.ts +3 -1
- package/dist/molecules/jl-search.js +8 -6
- package/dist/molecules/jl-segmented.css_raw.js +1 -1
- package/dist/molecules/jl-segmented.js +14 -7
- package/dist/molecules/jl-stat.css_raw.js +1 -1
- package/dist/molecules/jl-stat.d.ts +6 -0
- package/dist/molecules/jl-stat.js +6 -0
- package/dist/molecules/jl-steps.css_raw.js +1 -1
- package/dist/molecules/jl-timeline.css_raw.js +1 -1
- package/dist/molecules/jl-timeline.d.ts +2 -1
- package/dist/molecules/jl-timeline.js +18 -16
- package/dist/molecules/jl-toast.css_raw.js +1 -1
- package/dist/molecules/jl-toast.d.ts +1 -0
- package/dist/molecules/jl-toast.js +3 -1
- package/dist/organisms/jl-flow.css_raw.js +3 -0
- package/dist/organisms/jl-flow.d.ts +109 -0
- package/dist/organisms/jl-flow.js +755 -0
- package/dist/types/calendar.types.d.ts +2 -0
- package/dist/types/jl-flow.types.d.ts +94 -0
- package/dist/types/jl-segmented.types.d.ts +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
import { BASE_STYLES } from "../styles/shared.js";
|
|
2
|
+
import { JlElement } from "../base/jl-element.js";
|
|
3
|
+
import { jsonOf } from "../lib/attributes.js";
|
|
4
|
+
import { registerIcons } from "../lib/icons.js";
|
|
5
|
+
import { setOrRemove, svgElement } from "../lib/dom.js";
|
|
6
|
+
import { JlIcon } from "../atoms/jl-icon.js";
|
|
7
|
+
import { FLOW_NODE, FLOW_ZOOM, centeredAt, connect, edgeEnds, edgeMidpoint, edgePath, fitView, isEdgeTaken, isPortFree, layoutFlow, moveNode, nodeAt, nodeHeight, outputAnchor, portX, portsOf, positionAfter, removeEdge, removeNode, revealIn, snap, toWorld, zoomAt } from "../lib/flow.js";
|
|
8
|
+
import jl_flow_default from "./jl-flow.css_raw.js";
|
|
9
|
+
import { css } from "@jadis/core";
|
|
10
|
+
import { Check, CircleAlert, Hourglass, LoaderCircle, Maximize, Minus, Plus, WandSparkles, X } from "lucide";
|
|
11
|
+
import { Fragment, jsx, jsxs } from "@jadis/core/jsx-runtime";
|
|
12
|
+
// src/organisms/jl-flow.tsx
|
|
13
|
+
registerIcons({
|
|
14
|
+
check: Check,
|
|
15
|
+
"circle-alert": CircleAlert,
|
|
16
|
+
hourglass: Hourglass,
|
|
17
|
+
"loader-circle": LoaderCircle,
|
|
18
|
+
maximize: Maximize,
|
|
19
|
+
minus: Minus,
|
|
20
|
+
plus: Plus,
|
|
21
|
+
"wand-sparkles": WandSparkles,
|
|
22
|
+
x: X
|
|
23
|
+
});
|
|
24
|
+
var EMPTY = {
|
|
25
|
+
edges: [],
|
|
26
|
+
nodes: []
|
|
27
|
+
};
|
|
28
|
+
var IDLE = { kind: "idle" };
|
|
29
|
+
var DEFAULT_LABEL = "Flow";
|
|
30
|
+
var DOT_SPACING = 20;
|
|
31
|
+
var WHEEL_ZOOM_SPEED = .0018;
|
|
32
|
+
var CLICK_TOLERANCE = 4;
|
|
33
|
+
var NUDGE = 8;
|
|
34
|
+
var LONG_NUDGE = 40;
|
|
35
|
+
var PERCENT = 100;
|
|
36
|
+
var ICON_SIZE = 16;
|
|
37
|
+
var ADD_ICON_SIZE = 12;
|
|
38
|
+
var CONTROL_ICON_SIZE = 14;
|
|
39
|
+
var STATUS_ICON_SIZE = 12;
|
|
40
|
+
var PORT_GAP = 6;
|
|
41
|
+
var ID_LENGTH = 8;
|
|
42
|
+
var DELETE_KEYS = /* @__PURE__ */ new Set(["Delete", "Backspace"]);
|
|
43
|
+
var ARROWS = /* @__PURE__ */ new Map([
|
|
44
|
+
["ArrowDown", {
|
|
45
|
+
x: 0,
|
|
46
|
+
y: 1
|
|
47
|
+
}],
|
|
48
|
+
["ArrowLeft", {
|
|
49
|
+
x: -1,
|
|
50
|
+
y: 0
|
|
51
|
+
}],
|
|
52
|
+
["ArrowRight", {
|
|
53
|
+
x: 1,
|
|
54
|
+
y: 0
|
|
55
|
+
}],
|
|
56
|
+
["ArrowUp", {
|
|
57
|
+
x: 0,
|
|
58
|
+
y: -1
|
|
59
|
+
}]
|
|
60
|
+
]);
|
|
61
|
+
var STATUS_ICONS = {
|
|
62
|
+
done: "check",
|
|
63
|
+
failed: "x",
|
|
64
|
+
running: "loader-circle",
|
|
65
|
+
waiting: "hourglass"
|
|
66
|
+
};
|
|
67
|
+
var STATUS_LABELS = {
|
|
68
|
+
done: "Done",
|
|
69
|
+
failed: "Failed",
|
|
70
|
+
running: "Running",
|
|
71
|
+
waiting: "Waiting"
|
|
72
|
+
};
|
|
73
|
+
/** What screen readers hear after a node's label: how it did in the run, and what to fix. */
|
|
74
|
+
var noteOf = (node) => [node.status ? STATUS_LABELS[node.status] : "", node.problem ?? ""].filter((part) => part !== "").join(". ");
|
|
75
|
+
var markOf = (element, name) => element?.closest(`[${name}]`)?.getAttribute(name) ?? void 0;
|
|
76
|
+
var isControl = (event) => event.composedPath().some((element) => element instanceof Element && element.hasAttribute("data-control"));
|
|
77
|
+
function targetOf(event) {
|
|
78
|
+
const element = event.target instanceof Element ? event.target : null;
|
|
79
|
+
return {
|
|
80
|
+
add: markOf(element, "data-add"),
|
|
81
|
+
control: isControl(event),
|
|
82
|
+
edge: markOf(element, "data-edge"),
|
|
83
|
+
node: markOf(element, "data-node"),
|
|
84
|
+
port: markOf(element, "data-port")
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
var distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
88
|
+
var newId = (prefix) => `${prefix}_${crypto.randomUUID().replaceAll("-", "").slice(0, ID_LENGTH)}`;
|
|
89
|
+
var addLabel = (node, port) => port.label ? `Add after ${node.label}, ${port.label}` : `Add after ${node.label}`;
|
|
90
|
+
/**
|
|
91
|
+
* A flow of nodes joined by edges, to edit: people move the nodes, draw an edge from a node's exit to another node,
|
|
92
|
+
* press + on a free exit to ask for the next node, and move and zoom the view. Every edit goes out as a new graph
|
|
93
|
+
* (`change`); the page adds nodes itself, when `add` or `drop` asks. Nodes can show how a run went (`status`,
|
|
94
|
+
* `taken`) and what to fix (`problem`).
|
|
95
|
+
*/
|
|
96
|
+
var JlFlow = class extends JlElement {
|
|
97
|
+
static selector = "jl-flow";
|
|
98
|
+
events = this.useEvents();
|
|
99
|
+
graph = this.useChange(EMPTY, (graph) => this.show(graph), {
|
|
100
|
+
attribute: "graph",
|
|
101
|
+
parse: jsonOf(EMPTY)
|
|
102
|
+
});
|
|
103
|
+
/** The id of the selected node; the view moves to show it. */
|
|
104
|
+
selected = this.useChange("", (id) => {
|
|
105
|
+
this.renderSelection();
|
|
106
|
+
this.reveal(id);
|
|
107
|
+
}, { attribute: "selected" });
|
|
108
|
+
/** View only: nodes can be selected, not moved, joined or removed. */
|
|
109
|
+
readonly = this.useChange(false, (value) => {
|
|
110
|
+
this.reflect("readonly", value);
|
|
111
|
+
this.render();
|
|
112
|
+
}, { attribute: "readonly" });
|
|
113
|
+
/**
|
|
114
|
+
* The drag-and-drop type the canvas takes (`application/x-step`): something dragged in carrying it asks for a node
|
|
115
|
+
* where it is dropped, with `drop`.
|
|
116
|
+
*/
|
|
117
|
+
dropType = this.useChange("", () => void 0, { attribute: "drop-type" });
|
|
118
|
+
/** The canvas's name for screen readers. */
|
|
119
|
+
label = this.useChange("", (value) => this._refs.viewport.setAttribute("aria-label", value || DEFAULT_LABEL), { attribute: "label" });
|
|
120
|
+
_state = {
|
|
121
|
+
edge: "",
|
|
122
|
+
fitted: false,
|
|
123
|
+
gesture: IDLE,
|
|
124
|
+
graph: EMPTY,
|
|
125
|
+
viewport: {
|
|
126
|
+
x: 0,
|
|
127
|
+
y: 0,
|
|
128
|
+
zoom: 1
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
_nodes = /* @__PURE__ */ new Map();
|
|
132
|
+
_svg = {
|
|
133
|
+
draft: svgElement("path", { class: "edge draft" }),
|
|
134
|
+
root: svgElement("svg", {
|
|
135
|
+
class: "edges",
|
|
136
|
+
height: "1",
|
|
137
|
+
width: "1"
|
|
138
|
+
})
|
|
139
|
+
};
|
|
140
|
+
_refs = this.useRefs((ref) => ({
|
|
141
|
+
empty: ref(".empty"),
|
|
142
|
+
fit: ref(".fit"),
|
|
143
|
+
nodes: ref(".nodes"),
|
|
144
|
+
removeEdge: ref(".remove-edge"),
|
|
145
|
+
tidy: ref(".tidy"),
|
|
146
|
+
viewport: ref(".viewport"),
|
|
147
|
+
world: ref(".world"),
|
|
148
|
+
zoomIn: ref(".zoom-in"),
|
|
149
|
+
zoomLabel: ref(".zoom-label"),
|
|
150
|
+
zoomOut: ref(".zoom-out")
|
|
151
|
+
}));
|
|
152
|
+
/** Moves and zooms the view to show every node, never larger than life. */
|
|
153
|
+
fit() {
|
|
154
|
+
this.whenRendered(() => {
|
|
155
|
+
const { width, height } = this._refs.viewport.getBoundingClientRect();
|
|
156
|
+
if (width === 0) return;
|
|
157
|
+
this._state.fitted = true;
|
|
158
|
+
this.setViewport(fitView(this._state.graph.nodes, width, height));
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/** Moves the view, when needed, so that a node is in sight: one just added, or moved with the keys. */
|
|
162
|
+
reveal(id) {
|
|
163
|
+
this.whenRendered(() => {
|
|
164
|
+
const node = this.nodeOf(id);
|
|
165
|
+
const { width, height } = this._refs.viewport.getBoundingClientRect();
|
|
166
|
+
if (!node || width === 0) return;
|
|
167
|
+
this.setViewport(revealIn(this._state.viewport, node, width, height));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** Where a node added after an exit goes, as + puts it: a row below, under that exit. */
|
|
171
|
+
positionAfter(from, port) {
|
|
172
|
+
return positionAfter(this._state.graph, from, port);
|
|
173
|
+
}
|
|
174
|
+
/** Where a node added without a place goes: in the middle of the view (its top left corner). */
|
|
175
|
+
centerPosition() {
|
|
176
|
+
const { width, height } = this._refs.viewport.getBoundingClientRect();
|
|
177
|
+
return centeredAt(toWorld(this._state.viewport, {
|
|
178
|
+
x: width / 2,
|
|
179
|
+
y: height / 2
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
/** Lays the nodes out again, in rows from the starts down (`change` gives the new positions), and shows them all. */
|
|
183
|
+
layout() {
|
|
184
|
+
this.whenRendered(() => {
|
|
185
|
+
this.commit(layoutFlow(this._state.graph));
|
|
186
|
+
this.fit();
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
templateHtml() {
|
|
190
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
191
|
+
class: "viewport",
|
|
192
|
+
role: "application",
|
|
193
|
+
tabIndex: 0,
|
|
194
|
+
attrs: { part: "base" },
|
|
195
|
+
children: [
|
|
196
|
+
/* @__PURE__ */ jsxs("div", {
|
|
197
|
+
class: "world",
|
|
198
|
+
children: [
|
|
199
|
+
this._svg.root,
|
|
200
|
+
/* @__PURE__ */ jsx("div", { class: "nodes" }),
|
|
201
|
+
/* @__PURE__ */ jsx("button", {
|
|
202
|
+
type: "button",
|
|
203
|
+
class: "remove-edge",
|
|
204
|
+
"data-control": "",
|
|
205
|
+
hidden: true,
|
|
206
|
+
"aria-label": "Remove this edge",
|
|
207
|
+
children: /* @__PURE__ */ jsx(JlIcon, {
|
|
208
|
+
name: "x",
|
|
209
|
+
size: ADD_ICON_SIZE
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
]
|
|
213
|
+
}),
|
|
214
|
+
/* @__PURE__ */ jsx("div", {
|
|
215
|
+
class: "empty",
|
|
216
|
+
"data-control": "",
|
|
217
|
+
hidden: true,
|
|
218
|
+
children: /* @__PURE__ */ jsx("slot", {
|
|
219
|
+
name: "empty",
|
|
220
|
+
children: "Nothing here yet."
|
|
221
|
+
})
|
|
222
|
+
}),
|
|
223
|
+
/* @__PURE__ */ jsxs("div", {
|
|
224
|
+
class: "controls",
|
|
225
|
+
"data-control": "",
|
|
226
|
+
children: [
|
|
227
|
+
/* @__PURE__ */ jsx("button", {
|
|
228
|
+
type: "button",
|
|
229
|
+
class: "control zoom-out",
|
|
230
|
+
title: "Zoom out",
|
|
231
|
+
"aria-label": "Zoom out",
|
|
232
|
+
children: /* @__PURE__ */ jsx(JlIcon, {
|
|
233
|
+
name: "minus",
|
|
234
|
+
size: CONTROL_ICON_SIZE
|
|
235
|
+
})
|
|
236
|
+
}),
|
|
237
|
+
/* @__PURE__ */ jsx("span", { class: "zoom-label" }),
|
|
238
|
+
/* @__PURE__ */ jsx("button", {
|
|
239
|
+
type: "button",
|
|
240
|
+
class: "control zoom-in",
|
|
241
|
+
title: "Zoom in",
|
|
242
|
+
"aria-label": "Zoom in",
|
|
243
|
+
children: /* @__PURE__ */ jsx(JlIcon, {
|
|
244
|
+
name: "plus",
|
|
245
|
+
size: CONTROL_ICON_SIZE
|
|
246
|
+
})
|
|
247
|
+
}),
|
|
248
|
+
/* @__PURE__ */ jsx("button", {
|
|
249
|
+
type: "button",
|
|
250
|
+
class: "control fit",
|
|
251
|
+
title: "Show every node",
|
|
252
|
+
"aria-label": "Show every node",
|
|
253
|
+
children: /* @__PURE__ */ jsx(JlIcon, {
|
|
254
|
+
name: "maximize",
|
|
255
|
+
size: CONTROL_ICON_SIZE
|
|
256
|
+
})
|
|
257
|
+
}),
|
|
258
|
+
/* @__PURE__ */ jsx("button", {
|
|
259
|
+
type: "button",
|
|
260
|
+
class: "control tidy",
|
|
261
|
+
title: "Tidy up the layout",
|
|
262
|
+
"aria-label": "Tidy up the layout",
|
|
263
|
+
children: /* @__PURE__ */ jsx(JlIcon, {
|
|
264
|
+
name: "wand-sparkles",
|
|
265
|
+
size: CONTROL_ICON_SIZE
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
]
|
|
269
|
+
})
|
|
270
|
+
]
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
templateCss() {
|
|
274
|
+
return css`${BASE_STYLES}${jl_flow_default}`;
|
|
275
|
+
}
|
|
276
|
+
onConnect() {
|
|
277
|
+
const viewport = this._refs.viewport;
|
|
278
|
+
this.on(viewport, "pointerdown", (event) => this.pointerDown(event));
|
|
279
|
+
this.on(viewport, "pointermove", (event) => this.pointerMove(event));
|
|
280
|
+
this.on(viewport, "pointerup", (event) => this.pointerUp(event));
|
|
281
|
+
this.on(viewport, "pointercancel", (event) => this.pointerUp(event));
|
|
282
|
+
this.on(viewport, "wheel", (event) => this.wheel(event));
|
|
283
|
+
this.on(viewport, "keydown", (event) => this.key(event));
|
|
284
|
+
this.on(viewport, "click", (event) => this.pressAdd(event));
|
|
285
|
+
this.on(viewport, "dragover", (event) => this.dragOver(event));
|
|
286
|
+
this.on(viewport, "drop", (event) => this.dropOn(event));
|
|
287
|
+
this.on(viewport, "focusin", (event) => {
|
|
288
|
+
const node = targetOf(event).node;
|
|
289
|
+
if (node !== void 0) this.select(node);
|
|
290
|
+
});
|
|
291
|
+
this.on(this._refs.removeEdge, "click", () => this.removeSelection());
|
|
292
|
+
this.on(this._refs.zoomIn, "click", () => this.zoomBy(FLOW_ZOOM.step));
|
|
293
|
+
this.on(this._refs.zoomOut, "click", () => this.zoomBy(1 / FLOW_ZOOM.step));
|
|
294
|
+
this.on(this._refs.fit, "click", () => this.fit());
|
|
295
|
+
this.on(this._refs.tidy, "click", () => this.layout());
|
|
296
|
+
this.applyViewport();
|
|
297
|
+
}
|
|
298
|
+
show(graph) {
|
|
299
|
+
this._state.graph = graph;
|
|
300
|
+
this.render();
|
|
301
|
+
if (!this._state.fitted && graph.nodes.length > 0) requestAnimationFrame(() => this.fit());
|
|
302
|
+
}
|
|
303
|
+
/** An edit: drawn, kept, and sent to the page. */
|
|
304
|
+
commit(graph) {
|
|
305
|
+
this.graph.set(graph);
|
|
306
|
+
this.events.emit("change", graph);
|
|
307
|
+
}
|
|
308
|
+
select(id) {
|
|
309
|
+
if (this._state.edge !== "") {
|
|
310
|
+
this._state.edge = "";
|
|
311
|
+
this.renderEdges();
|
|
312
|
+
}
|
|
313
|
+
if (id === this.selected.get()) return;
|
|
314
|
+
this.selected.set(id);
|
|
315
|
+
this.events.emit("select", id);
|
|
316
|
+
}
|
|
317
|
+
selectEdge(id) {
|
|
318
|
+
this.select("");
|
|
319
|
+
this._state.edge = id;
|
|
320
|
+
this.renderEdges();
|
|
321
|
+
}
|
|
322
|
+
removeSelection() {
|
|
323
|
+
const { edge, graph } = this._state;
|
|
324
|
+
const node = this.selected.get();
|
|
325
|
+
if (edge !== "") {
|
|
326
|
+
this._state.edge = "";
|
|
327
|
+
this.commit(removeEdge(graph, edge));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (node === "") return;
|
|
331
|
+
this.select("");
|
|
332
|
+
this.commit(removeNode(graph, node));
|
|
333
|
+
}
|
|
334
|
+
nodeOf(id) {
|
|
335
|
+
return this._state.graph.nodes.find((node) => node.id === id);
|
|
336
|
+
}
|
|
337
|
+
pointerDown(event) {
|
|
338
|
+
const target = targetOf(event);
|
|
339
|
+
if (event.button !== 0 || target.control || target.add !== void 0) return;
|
|
340
|
+
if (target.node !== void 0) this.select(target.node);
|
|
341
|
+
this._state.gesture = this.gestureFor(target, event);
|
|
342
|
+
this._refs.viewport.setPointerCapture(event.pointerId);
|
|
343
|
+
}
|
|
344
|
+
gestureFor(target, event) {
|
|
345
|
+
const start = {
|
|
346
|
+
x: event.clientX,
|
|
347
|
+
y: event.clientY
|
|
348
|
+
};
|
|
349
|
+
const node = target.node === void 0 ? void 0 : this.nodeOf(target.node);
|
|
350
|
+
if (node && target.port !== void 0 && !this.readonly.get()) return {
|
|
351
|
+
kind: "connect",
|
|
352
|
+
node: node.id,
|
|
353
|
+
port: target.port,
|
|
354
|
+
start
|
|
355
|
+
};
|
|
356
|
+
if (node) {
|
|
357
|
+
const world = this.worldPoint(event);
|
|
358
|
+
const offset = {
|
|
359
|
+
x: world.x - node.position.x,
|
|
360
|
+
y: world.y - node.position.y
|
|
361
|
+
};
|
|
362
|
+
return {
|
|
363
|
+
kind: "drag",
|
|
364
|
+
moved: false,
|
|
365
|
+
node: node.id,
|
|
366
|
+
offset,
|
|
367
|
+
start
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
edge: target.edge ?? "",
|
|
372
|
+
kind: "pan",
|
|
373
|
+
moved: false,
|
|
374
|
+
origin: this._state.viewport,
|
|
375
|
+
start
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
pointerMove(event) {
|
|
379
|
+
const gesture = this._state.gesture;
|
|
380
|
+
const screen = {
|
|
381
|
+
x: event.clientX,
|
|
382
|
+
y: event.clientY
|
|
383
|
+
};
|
|
384
|
+
if (gesture.kind === "pan") {
|
|
385
|
+
this._state.gesture = {
|
|
386
|
+
...gesture,
|
|
387
|
+
moved: gesture.moved || distance(screen, gesture.start) > CLICK_TOLERANCE
|
|
388
|
+
};
|
|
389
|
+
this.setViewport({
|
|
390
|
+
...gesture.origin,
|
|
391
|
+
x: gesture.origin.x + screen.x - gesture.start.x,
|
|
392
|
+
y: gesture.origin.y + screen.y - gesture.start.y
|
|
393
|
+
});
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (gesture.kind === "drag") {
|
|
397
|
+
this.drag(gesture, screen, event);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (gesture.kind === "connect") this.drawDraft(gesture, event);
|
|
401
|
+
}
|
|
402
|
+
drag(gesture, screen, event) {
|
|
403
|
+
const moved = gesture.moved || distance(screen, gesture.start) > CLICK_TOLERANCE;
|
|
404
|
+
if (!moved || this.readonly.get()) return;
|
|
405
|
+
this._state.gesture = {
|
|
406
|
+
...gesture,
|
|
407
|
+
moved
|
|
408
|
+
};
|
|
409
|
+
const world = this.worldPoint(event);
|
|
410
|
+
const position = snap({
|
|
411
|
+
x: world.x - gesture.offset.x,
|
|
412
|
+
y: world.y - gesture.offset.y
|
|
413
|
+
});
|
|
414
|
+
this._state.graph = moveNode(this._state.graph, gesture.node, position);
|
|
415
|
+
const element = this._nodes.get(gesture.node);
|
|
416
|
+
if (element) this.place(element, position);
|
|
417
|
+
this.renderEdges();
|
|
418
|
+
}
|
|
419
|
+
drawDraft(gesture, event) {
|
|
420
|
+
const node = this.nodeOf(gesture.node);
|
|
421
|
+
const from = node ? outputAnchor(node, gesture.port) : void 0;
|
|
422
|
+
if (from) this._svg.draft.setAttribute("d", edgePath(from, this.worldPoint(event)));
|
|
423
|
+
}
|
|
424
|
+
pointerUp(event) {
|
|
425
|
+
const gesture = this._state.gesture;
|
|
426
|
+
this._state.gesture = IDLE;
|
|
427
|
+
if (gesture.kind === "connect") {
|
|
428
|
+
this._svg.draft.removeAttribute("d");
|
|
429
|
+
this.connectAt(gesture, event);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (gesture.kind === "drag" && gesture.moved) {
|
|
433
|
+
this.commit(this._state.graph);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (gesture.kind === "pan" && !gesture.moved) this.selectEdge(gesture.edge);
|
|
437
|
+
}
|
|
438
|
+
/** An edge dropped on a node joins it; dropped on empty space, it asks for a node there. */
|
|
439
|
+
connectAt(gesture, event) {
|
|
440
|
+
if (distance({
|
|
441
|
+
x: event.clientX,
|
|
442
|
+
y: event.clientY
|
|
443
|
+
}, gesture.start) <= CLICK_TOLERANCE) return;
|
|
444
|
+
const at = this.worldPoint(event);
|
|
445
|
+
const target = nodeAt(this._state.graph, at, gesture.node);
|
|
446
|
+
if (!target) {
|
|
447
|
+
const position = snap({
|
|
448
|
+
x: at.x - FLOW_NODE.width / 2,
|
|
449
|
+
y: at.y
|
|
450
|
+
});
|
|
451
|
+
this.events.emit("add", {
|
|
452
|
+
from: gesture.node,
|
|
453
|
+
port: gesture.port,
|
|
454
|
+
position
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const joined = connect(this._state.graph, {
|
|
459
|
+
from: gesture.node,
|
|
460
|
+
id: newId("e"),
|
|
461
|
+
port: gesture.port,
|
|
462
|
+
to: target.id
|
|
463
|
+
});
|
|
464
|
+
if (joined) this.commit(joined);
|
|
465
|
+
}
|
|
466
|
+
pressAdd(event) {
|
|
467
|
+
const { add, node } = targetOf(event);
|
|
468
|
+
if (add === void 0 || node === void 0 || this.readonly.get()) return;
|
|
469
|
+
this.events.emit("add", {
|
|
470
|
+
from: node,
|
|
471
|
+
port: add,
|
|
472
|
+
position: positionAfter(this._state.graph, node, add)
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
/** Whether a drag carries what the canvas takes: then it may be dropped here. */
|
|
476
|
+
carriesDrop(event) {
|
|
477
|
+
const type = this.dropType.get();
|
|
478
|
+
return type !== "" && !this.readonly.get() && (event.dataTransfer?.types.includes(type) ?? false);
|
|
479
|
+
}
|
|
480
|
+
dragOver(event) {
|
|
481
|
+
if (this.carriesDrop(event)) event.preventDefault();
|
|
482
|
+
}
|
|
483
|
+
dropOn(event) {
|
|
484
|
+
if (!this.carriesDrop(event)) return;
|
|
485
|
+
event.preventDefault();
|
|
486
|
+
event.stopPropagation();
|
|
487
|
+
const data = event.dataTransfer?.getData(this.dropType.get()) ?? "";
|
|
488
|
+
this.events.emit("drop", {
|
|
489
|
+
data,
|
|
490
|
+
position: centeredAt(this.worldPoint(event))
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
key(event) {
|
|
494
|
+
const target = targetOf(event);
|
|
495
|
+
if (target.control || target.add !== void 0) return;
|
|
496
|
+
if (event.key === "Escape") {
|
|
497
|
+
this.select("");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (this.readonly.get()) return;
|
|
501
|
+
if (DELETE_KEYS.has(event.key)) {
|
|
502
|
+
event.preventDefault();
|
|
503
|
+
this.removeSelection();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
this.nudge(event);
|
|
507
|
+
}
|
|
508
|
+
/** The arrows move the selected node; with Shift, further. */
|
|
509
|
+
nudge(event) {
|
|
510
|
+
const arrow = ARROWS.get(event.key);
|
|
511
|
+
const node = this.nodeOf(this.selected.get());
|
|
512
|
+
if (!arrow || !node) return;
|
|
513
|
+
event.preventDefault();
|
|
514
|
+
const step = event.shiftKey ? LONG_NUDGE : NUDGE;
|
|
515
|
+
const position = {
|
|
516
|
+
x: node.position.x + arrow.x * step,
|
|
517
|
+
y: node.position.y + arrow.y * step
|
|
518
|
+
};
|
|
519
|
+
this.commit(moveNode(this._state.graph, node.id, position));
|
|
520
|
+
this.reveal(node.id);
|
|
521
|
+
}
|
|
522
|
+
/** Two fingers or the wheel move the view; with Ctrl or ⌘ (a pinch on a trackpad), they zoom. */
|
|
523
|
+
wheel(event) {
|
|
524
|
+
event.preventDefault();
|
|
525
|
+
const viewport = this._state.viewport;
|
|
526
|
+
if (event.ctrlKey || event.metaKey) {
|
|
527
|
+
this.setViewport(zoomAt(viewport, this.screenPoint(event), Math.exp(-event.deltaY * WHEEL_ZOOM_SPEED)));
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
this.setViewport({
|
|
531
|
+
...viewport,
|
|
532
|
+
x: viewport.x - event.deltaX,
|
|
533
|
+
y: viewport.y - event.deltaY
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
zoomBy(factor) {
|
|
537
|
+
const { width, height } = this._refs.viewport.getBoundingClientRect();
|
|
538
|
+
this.setViewport(zoomAt(this._state.viewport, {
|
|
539
|
+
x: width / 2,
|
|
540
|
+
y: height / 2
|
|
541
|
+
}, factor));
|
|
542
|
+
}
|
|
543
|
+
setViewport(viewport) {
|
|
544
|
+
this._state.viewport = viewport;
|
|
545
|
+
this.applyViewport();
|
|
546
|
+
}
|
|
547
|
+
applyViewport() {
|
|
548
|
+
const { x, y, zoom } = this._state.viewport;
|
|
549
|
+
const viewport = this._refs.viewport;
|
|
550
|
+
this._refs.world.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`;
|
|
551
|
+
viewport.style.backgroundSize = `${DOT_SPACING * zoom}px ${DOT_SPACING * zoom}px`;
|
|
552
|
+
viewport.style.backgroundPosition = `${x}px ${y}px`;
|
|
553
|
+
this._refs.zoomLabel.textContent = `${Math.round(zoom * PERCENT)}%`;
|
|
554
|
+
}
|
|
555
|
+
screenPoint(event) {
|
|
556
|
+
const rect = this._refs.viewport.getBoundingClientRect();
|
|
557
|
+
return {
|
|
558
|
+
x: event.clientX - rect.left,
|
|
559
|
+
y: event.clientY - rect.top
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
worldPoint(event) {
|
|
563
|
+
return toWorld(this._state.viewport, this.screenPoint(event));
|
|
564
|
+
}
|
|
565
|
+
render() {
|
|
566
|
+
this._refs.empty.hidden = this._state.graph.nodes.length > 0;
|
|
567
|
+
this.renderNodes();
|
|
568
|
+
this.renderEdges();
|
|
569
|
+
}
|
|
570
|
+
/** The nodes' elements stay from one drawing to the next: the focused one keeps the focus. */
|
|
571
|
+
renderNodes() {
|
|
572
|
+
const nodes = this._state.graph.nodes;
|
|
573
|
+
const ids = new Set(nodes.map((node) => node.id));
|
|
574
|
+
for (const [id, element] of this._nodes) if (!ids.has(id)) {
|
|
575
|
+
element.remove();
|
|
576
|
+
this._nodes.delete(id);
|
|
577
|
+
}
|
|
578
|
+
for (const node of nodes) this.drawNode(this._nodes.get(node.id) ?? this.createNode(node.id), node);
|
|
579
|
+
this.renderSelection();
|
|
580
|
+
}
|
|
581
|
+
createNode(id) {
|
|
582
|
+
const element = /* @__PURE__ */ jsx("div", {
|
|
583
|
+
class: "node",
|
|
584
|
+
"data-node": id,
|
|
585
|
+
tabIndex: 0,
|
|
586
|
+
role: "group",
|
|
587
|
+
"aria-roledescription": "node",
|
|
588
|
+
attrs: { part: "node" }
|
|
589
|
+
});
|
|
590
|
+
this._nodes.set(id, element);
|
|
591
|
+
this._refs.nodes.append(element);
|
|
592
|
+
return element;
|
|
593
|
+
}
|
|
594
|
+
drawNode(element, node) {
|
|
595
|
+
const ports = portsOf(node);
|
|
596
|
+
element.setAttribute("aria-label", node.label);
|
|
597
|
+
setOrRemove(element, "aria-description", noteOf(node));
|
|
598
|
+
element.part.toggle("start", node.start === true);
|
|
599
|
+
setOrRemove(element, "data-tone", node.tone ?? "");
|
|
600
|
+
setOrRemove(element, "data-status", node.status ?? "");
|
|
601
|
+
element.style.height = `${nodeHeight(node)}px`;
|
|
602
|
+
this.paintIcon(element, node.color ?? "");
|
|
603
|
+
element.replaceChildren(/* @__PURE__ */ jsxs(Fragment, { children: [
|
|
604
|
+
node.start === true ? null : /* @__PURE__ */ jsx("span", { class: "input" }),
|
|
605
|
+
/* @__PURE__ */ jsxs("span", {
|
|
606
|
+
class: "head",
|
|
607
|
+
children: [
|
|
608
|
+
node.icon ? /* @__PURE__ */ jsx("span", {
|
|
609
|
+
class: "icon",
|
|
610
|
+
attrs: { part: "icon" },
|
|
611
|
+
children: /* @__PURE__ */ jsx(JlIcon, {
|
|
612
|
+
name: node.icon,
|
|
613
|
+
size: ICON_SIZE
|
|
614
|
+
})
|
|
615
|
+
}) : null,
|
|
616
|
+
/* @__PURE__ */ jsxs("span", {
|
|
617
|
+
class: "text",
|
|
618
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
619
|
+
class: "label",
|
|
620
|
+
attrs: { part: "label" },
|
|
621
|
+
children: node.label
|
|
622
|
+
}), node.description ? /* @__PURE__ */ jsx("span", {
|
|
623
|
+
class: "description",
|
|
624
|
+
attrs: { part: "description" },
|
|
625
|
+
children: node.description
|
|
626
|
+
}) : null]
|
|
627
|
+
}),
|
|
628
|
+
node.problem ? /* @__PURE__ */ jsx("span", {
|
|
629
|
+
class: "problem",
|
|
630
|
+
title: node.problem,
|
|
631
|
+
attrs: { part: "problem" },
|
|
632
|
+
children: /* @__PURE__ */ jsx(JlIcon, {
|
|
633
|
+
name: "circle-alert",
|
|
634
|
+
size: ICON_SIZE
|
|
635
|
+
})
|
|
636
|
+
}) : null,
|
|
637
|
+
node.status ? /* @__PURE__ */ jsx("span", {
|
|
638
|
+
class: "status",
|
|
639
|
+
title: STATUS_LABELS[node.status],
|
|
640
|
+
attrs: { part: "status" },
|
|
641
|
+
children: /* @__PURE__ */ jsx(JlIcon, {
|
|
642
|
+
name: STATUS_ICONS[node.status],
|
|
643
|
+
size: STATUS_ICON_SIZE
|
|
644
|
+
})
|
|
645
|
+
}) : null
|
|
646
|
+
]
|
|
647
|
+
}),
|
|
648
|
+
node.detail ? /* @__PURE__ */ jsx("span", {
|
|
649
|
+
class: "detail",
|
|
650
|
+
title: node.detail,
|
|
651
|
+
attrs: { part: "detail" },
|
|
652
|
+
children: node.detail
|
|
653
|
+
}) : null,
|
|
654
|
+
/* @__PURE__ */ jsx("span", {
|
|
655
|
+
class: "ports",
|
|
656
|
+
children: ports.map((port, index) => this.portElement(node, port, index, ports.length))
|
|
657
|
+
})
|
|
658
|
+
] }));
|
|
659
|
+
this.place(element, node.position);
|
|
660
|
+
}
|
|
661
|
+
/** A colour of the page's own for the icon wins over the tone's. */
|
|
662
|
+
paintIcon(element, color) {
|
|
663
|
+
if (color === "") {
|
|
664
|
+
element.style.removeProperty("--tone");
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
element.style.setProperty("--tone", color);
|
|
668
|
+
}
|
|
669
|
+
portElement(node, port, index, count) {
|
|
670
|
+
const free = !this.readonly.get() && isPortFree(this._state.graph, node, port.id);
|
|
671
|
+
const element = /* @__PURE__ */ jsxs("span", {
|
|
672
|
+
class: "port",
|
|
673
|
+
"data-port": port.id,
|
|
674
|
+
title: "Drag to another node to connect",
|
|
675
|
+
attrs: { part: "port" },
|
|
676
|
+
children: [
|
|
677
|
+
/* @__PURE__ */ jsx("span", { class: "dot" }),
|
|
678
|
+
port.label ? /* @__PURE__ */ jsx("span", {
|
|
679
|
+
class: "port-label",
|
|
680
|
+
children: port.label
|
|
681
|
+
}) : null,
|
|
682
|
+
free ? /* @__PURE__ */ jsx("button", {
|
|
683
|
+
type: "button",
|
|
684
|
+
class: "add",
|
|
685
|
+
"data-add": port.id,
|
|
686
|
+
"aria-label": addLabel(node, port),
|
|
687
|
+
children: /* @__PURE__ */ jsx(JlIcon, {
|
|
688
|
+
name: "plus",
|
|
689
|
+
size: ADD_ICON_SIZE
|
|
690
|
+
})
|
|
691
|
+
}) : null
|
|
692
|
+
]
|
|
693
|
+
});
|
|
694
|
+
element.style.left = `${portX(index, count)}px`;
|
|
695
|
+
element.style.maxWidth = `${FLOW_NODE.width / count - PORT_GAP}px`;
|
|
696
|
+
setOrRemove(element, "data-tone", port.tone ?? "");
|
|
697
|
+
element.toggleAttribute("data-taken", node.taken === port.id);
|
|
698
|
+
return element;
|
|
699
|
+
}
|
|
700
|
+
place(element, position) {
|
|
701
|
+
element.style.transform = `translate(${position.x}px, ${position.y}px)`;
|
|
702
|
+
}
|
|
703
|
+
renderSelection() {
|
|
704
|
+
const selected = this.selected.get();
|
|
705
|
+
for (const [id, element] of this._nodes) {
|
|
706
|
+
element.classList.toggle("selected", id === selected);
|
|
707
|
+
element.part.toggle("selected", id === selected);
|
|
708
|
+
setOrRemove(element, "aria-current", id === selected ? "true" : "");
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
renderEdges() {
|
|
712
|
+
const { edge: selected, graph } = this._state;
|
|
713
|
+
const groups = graph.edges.flatMap((edge) => {
|
|
714
|
+
const ends = edgeEnds(graph, edge);
|
|
715
|
+
return ends ? [this.edgeGroup(edge.id, ends, edge.id === selected, isEdgeTaken(graph, edge))] : [];
|
|
716
|
+
});
|
|
717
|
+
this._svg.root.replaceChildren(...groups, this._svg.draft);
|
|
718
|
+
this.renderEdgeTool();
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* An edge, with a wide invisible twin that catches the pointer: a thin line is still easy to press. One a run took
|
|
722
|
+
* stands out.
|
|
723
|
+
*/
|
|
724
|
+
edgeGroup(id, ends, selected, taken) {
|
|
725
|
+
const d = edgePath(ends.from, ends.to);
|
|
726
|
+
const states = [selected ? "selected" : "", taken ? "taken" : ""].filter((state) => state !== "");
|
|
727
|
+
const group = svgElement("g", {
|
|
728
|
+
class: ["edge-group", ...states].join(" "),
|
|
729
|
+
"data-edge": id
|
|
730
|
+
});
|
|
731
|
+
group.append(svgElement("path", {
|
|
732
|
+
class: "edge-hit",
|
|
733
|
+
d
|
|
734
|
+
}), svgElement("path", {
|
|
735
|
+
class: "edge",
|
|
736
|
+
d,
|
|
737
|
+
"data-tone": ends.tone,
|
|
738
|
+
part: ["edge", ...states].join(" ")
|
|
739
|
+
}));
|
|
740
|
+
return group;
|
|
741
|
+
}
|
|
742
|
+
/** The button that removes the selected edge, in its middle. */
|
|
743
|
+
renderEdgeTool() {
|
|
744
|
+
const { edge: selected, graph } = this._state;
|
|
745
|
+
const edge = graph.edges.find((candidate) => candidate.id === selected);
|
|
746
|
+
const ends = edge ? edgeEnds(graph, edge) : void 0;
|
|
747
|
+
const button = this._refs.removeEdge;
|
|
748
|
+
button.hidden = ends === void 0 || this.readonly.get();
|
|
749
|
+
if (!ends) return;
|
|
750
|
+
const middle = edgeMidpoint(ends.from, ends.to);
|
|
751
|
+
button.style.transform = `translate(${middle.x}px, ${middle.y}px) translate(-50%, -50%)`;
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
JlFlow.register();
|
|
755
|
+
export { JlFlow };
|