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