Spaces:
Running
Running
Add basic grouping.
Browse files
lynxkite-app/web/src/workspace/Workspace.tsx
CHANGED
@@ -25,9 +25,14 @@ import Atom from "~icons/tabler/atom.jsx";
|
|
25 |
// @ts-ignore
|
26 |
import Backspace from "~icons/tabler/backspace.jsx";
|
27 |
// @ts-ignore
|
|
|
|
|
|
|
|
|
28 |
import Restart from "~icons/tabler/rotate-clockwise.jsx";
|
29 |
// @ts-ignore
|
30 |
import Close from "~icons/tabler/x.jsx";
|
|
|
31 |
import type { WorkspaceNode, Workspace as WorkspaceType } from "../apiTypes.ts";
|
32 |
import favicon from "../assets/favicon.ico";
|
33 |
import { usePath } from "../common.ts";
|
@@ -78,7 +83,7 @@ function LynxKiteFlow() {
|
|
78 |
if (!state.workspace.nodes) return;
|
79 |
if (!state.workspace.edges) return;
|
80 |
for (const n of state.workspace.nodes) {
|
81 |
-
if (n.dragHandle !== ".drag-handle") {
|
82 |
n.dragHandle = ".drag-handle";
|
83 |
}
|
84 |
}
|
@@ -247,16 +252,18 @@ function LynxKiteFlow() {
|
|
247 |
},
|
248 |
[catalog, state, nodeSearchSettings, suppressSearchUntil, closeNodeSearch],
|
249 |
);
|
250 |
-
function
|
251 |
-
const title = node.data?.title;
|
252 |
let i = 1;
|
253 |
-
|
254 |
-
const
|
255 |
-
while (
|
256 |
i += 1;
|
257 |
-
|
258 |
}
|
259 |
-
|
|
|
|
|
|
|
260 |
setNodes([...nodes, node as WorkspaceNode]);
|
261 |
}
|
262 |
function nodeFromMeta(meta: OpsOp): Partial<WorkspaceNode> {
|
@@ -278,7 +285,8 @@ function LynxKiteFlow() {
|
|
278 |
x: nss.pos.x,
|
279 |
y: nss.pos.y,
|
280 |
});
|
281 |
-
|
|
|
282 |
closeNodeSearch();
|
283 |
},
|
284 |
[nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch],
|
@@ -321,6 +329,7 @@ function LynxKiteFlow() {
|
|
321 |
setMessage(null);
|
322 |
const cat = catalog.data![state.workspace.env!];
|
323 |
const node = nodeFromMeta(cat["Import file"]);
|
|
|
324 |
node.position = reactFlow.screenToFlowPosition({
|
325 |
x: e.clientX,
|
326 |
y: e.clientY,
|
@@ -335,7 +344,7 @@ function LynxKiteFlow() {
|
|
335 |
} else if (file.name.includes(".xls")) {
|
336 |
node.data!.params.file_format = "excel";
|
337 |
}
|
338 |
-
addNode(node
|
339 |
} catch (error) {
|
340 |
setMessage("File upload failed.");
|
341 |
}
|
@@ -351,6 +360,92 @@ function LynxKiteFlow() {
|
|
351 |
const selectedEdges = edges.filter((e) => e.selected);
|
352 |
reactFlow.deleteElements({ nodes: selectedNodes, edges: selectedEdges });
|
353 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
354 |
return (
|
355 |
<div className="workspace">
|
356 |
<div className="top-bar bg-neutral">
|
@@ -367,18 +462,35 @@ function LynxKiteFlow() {
|
|
367 |
}}
|
368 |
/>
|
369 |
<div className="tools text-secondary">
|
370 |
-
|
371 |
-
<
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
376 |
-
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
-
|
381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
382 |
</div>
|
383 |
</div>
|
384 |
<div style={{ height: "100%", width: "100vw" }} onDragOver={onDragOver} onDrop={onDrop}>
|
|
|
25 |
// @ts-ignore
|
26 |
import Backspace from "~icons/tabler/backspace.jsx";
|
27 |
// @ts-ignore
|
28 |
+
import UngroupIcon from "~icons/tabler/library-minus.jsx";
|
29 |
+
// @ts-ignore
|
30 |
+
import GroupIcon from "~icons/tabler/library-plus.jsx";
|
31 |
+
// @ts-ignore
|
32 |
import Restart from "~icons/tabler/rotate-clockwise.jsx";
|
33 |
// @ts-ignore
|
34 |
import Close from "~icons/tabler/x.jsx";
|
35 |
+
import Tooltip from "../Tooltip.tsx";
|
36 |
import type { WorkspaceNode, Workspace as WorkspaceType } from "../apiTypes.ts";
|
37 |
import favicon from "../assets/favicon.ico";
|
38 |
import { usePath } from "../common.ts";
|
|
|
83 |
if (!state.workspace.nodes) return;
|
84 |
if (!state.workspace.edges) return;
|
85 |
for (const n of state.workspace.nodes) {
|
86 |
+
if (n.type !== "group" && n.dragHandle !== ".drag-handle") {
|
87 |
n.dragHandle = ".drag-handle";
|
88 |
}
|
89 |
}
|
|
|
252 |
},
|
253 |
[catalog, state, nodeSearchSettings, suppressSearchUntil, closeNodeSearch],
|
254 |
);
|
255 |
+
function findFreeId(prefix: string) {
|
|
|
256 |
let i = 1;
|
257 |
+
let id = `${prefix} ${i}`;
|
258 |
+
const used = new Set(state.workspace.nodes!.map((n) => n.id));
|
259 |
+
while (used.has(id)) {
|
260 |
i += 1;
|
261 |
+
id = `${prefix} ${i}`;
|
262 |
}
|
263 |
+
return id;
|
264 |
+
}
|
265 |
+
function addNode(node: Partial<WorkspaceNode>) {
|
266 |
+
state.workspace.nodes!.push(node as WorkspaceNode);
|
267 |
setNodes([...nodes, node as WorkspaceNode]);
|
268 |
}
|
269 |
function nodeFromMeta(meta: OpsOp): Partial<WorkspaceNode> {
|
|
|
285 |
x: nss.pos.x,
|
286 |
y: nss.pos.y,
|
287 |
});
|
288 |
+
node.id = findFreeId(node.data!.title);
|
289 |
+
addNode(node);
|
290 |
closeNodeSearch();
|
291 |
},
|
292 |
[nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch],
|
|
|
329 |
setMessage(null);
|
330 |
const cat = catalog.data![state.workspace.env!];
|
331 |
const node = nodeFromMeta(cat["Import file"]);
|
332 |
+
node.id = findFreeId(node.data!.title);
|
333 |
node.position = reactFlow.screenToFlowPosition({
|
334 |
x: e.clientX,
|
335 |
y: e.clientY,
|
|
|
344 |
} else if (file.name.includes(".xls")) {
|
345 |
node.data!.params.file_format = "excel";
|
346 |
}
|
347 |
+
addNode(node);
|
348 |
} catch (error) {
|
349 |
setMessage("File upload failed.");
|
350 |
}
|
|
|
360 |
const selectedEdges = edges.filter((e) => e.selected);
|
361 |
reactFlow.deleteElements({ nodes: selectedNodes, edges: selectedEdges });
|
362 |
}
|
363 |
+
function groupSelection() {
|
364 |
+
const selectedNodes = nodes.filter((n) => n.selected);
|
365 |
+
const groupNode = {
|
366 |
+
id: findFreeId("Group"),
|
367 |
+
type: "group",
|
368 |
+
position: { x: 0, y: 0 },
|
369 |
+
width: 0,
|
370 |
+
height: 0,
|
371 |
+
data: { title: "Group", params: {} },
|
372 |
+
};
|
373 |
+
let top = Number.POSITIVE_INFINITY;
|
374 |
+
let left = Number.POSITIVE_INFINITY;
|
375 |
+
let bottom = Number.NEGATIVE_INFINITY;
|
376 |
+
let right = Number.NEGATIVE_INFINITY;
|
377 |
+
for (const node of selectedNodes) {
|
378 |
+
if (node.position.y < top) top = node.position.y;
|
379 |
+
if (node.position.x < left) left = node.position.x;
|
380 |
+
if (node.position.y + node.height! > bottom) bottom = node.position.y + node.height!;
|
381 |
+
if (node.position.x + node.width! > right) right = node.position.x + node.width!;
|
382 |
+
}
|
383 |
+
groupNode.position = {
|
384 |
+
x: left,
|
385 |
+
y: top,
|
386 |
+
};
|
387 |
+
groupNode.width = right - left;
|
388 |
+
groupNode.height = bottom - top;
|
389 |
+
setNodes([
|
390 |
+
groupNode as WorkspaceNode,
|
391 |
+
...nodes.map((n) =>
|
392 |
+
n.selected
|
393 |
+
? {
|
394 |
+
...n,
|
395 |
+
position: { x: n.position.x - left, y: n.position.y - top },
|
396 |
+
parentId: groupNode.id,
|
397 |
+
selected: false,
|
398 |
+
}
|
399 |
+
: n,
|
400 |
+
),
|
401 |
+
]);
|
402 |
+
getYjsDoc(state).transact(() => {
|
403 |
+
state.workspace.nodes!.unshift(groupNode as WorkspaceNode);
|
404 |
+
const selectedNodeIds = new Set(selectedNodes.map((n) => n.id));
|
405 |
+
for (const node of state.workspace.nodes!) {
|
406 |
+
if (selectedNodeIds.has(node.id)) {
|
407 |
+
node.position.x -= left;
|
408 |
+
node.position.y -= top;
|
409 |
+
node.parentId = groupNode.id;
|
410 |
+
node.selected = false;
|
411 |
+
}
|
412 |
+
}
|
413 |
+
});
|
414 |
+
}
|
415 |
+
function ungroupSelection() {
|
416 |
+
const groups = Object.fromEntries(
|
417 |
+
nodes.filter((n) => n.selected && n.type === "group").map((n) => [n.id, n]),
|
418 |
+
);
|
419 |
+
setNodes(
|
420 |
+
nodes
|
421 |
+
.filter((n) => !groups[n.id])
|
422 |
+
.map((n) => {
|
423 |
+
const g = groups[n.parentId!];
|
424 |
+
if (!g) return n;
|
425 |
+
return {
|
426 |
+
...n,
|
427 |
+
position: { x: n.position.x + g.position.x, y: n.position.y + g.position.y },
|
428 |
+
parentId: undefined,
|
429 |
+
};
|
430 |
+
}),
|
431 |
+
);
|
432 |
+
getYjsDoc(state).transact(() => {
|
433 |
+
const wnodes = state.workspace.nodes!;
|
434 |
+
for (const groupId in groups) {
|
435 |
+
const groupIdx = wnodes.findIndex((n) => n.id === groupId);
|
436 |
+
wnodes.splice(groupIdx, 1);
|
437 |
+
}
|
438 |
+
for (const node of state.workspace.nodes!) {
|
439 |
+
const g = groups[node.parentId as string];
|
440 |
+
if (!g) continue;
|
441 |
+
node.position.x += g.position.x;
|
442 |
+
node.position.y += g.position.y;
|
443 |
+
node.parentId = undefined;
|
444 |
+
}
|
445 |
+
});
|
446 |
+
}
|
447 |
+
const areMultipleNodesSelected = nodes.filter((n) => n.selected).length > 1;
|
448 |
+
const isAnyGroupSelected = nodes.some((n) => n.selected && n.type === "group");
|
449 |
return (
|
450 |
<div className="workspace">
|
451 |
<div className="top-bar bg-neutral">
|
|
|
462 |
}}
|
463 |
/>
|
464 |
<div className="tools text-secondary">
|
465 |
+
{areMultipleNodesSelected && (
|
466 |
+
<Tooltip doc="Group selected nodes">
|
467 |
+
<button className="btn btn-link" onClick={groupSelection}>
|
468 |
+
<GroupIcon />
|
469 |
+
</button>
|
470 |
+
</Tooltip>
|
471 |
+
)}
|
472 |
+
{isAnyGroupSelected && (
|
473 |
+
<Tooltip doc="Ungroup selected nodes">
|
474 |
+
<button className="btn btn-link" onClick={ungroupSelection}>
|
475 |
+
<UngroupIcon />
|
476 |
+
</button>
|
477 |
+
</Tooltip>
|
478 |
+
)}
|
479 |
+
<Tooltip doc="Delete selected nodes and edges">
|
480 |
+
<button className="btn btn-link" onClick={deleteSelection}>
|
481 |
+
<Backspace />
|
482 |
+
</button>
|
483 |
+
</Tooltip>
|
484 |
+
<Tooltip doc="Re-run the workspace">
|
485 |
+
<button className="btn btn-link" onClick={executeWorkspace}>
|
486 |
+
<Restart />
|
487 |
+
</button>
|
488 |
+
</Tooltip>
|
489 |
+
<Tooltip doc="Close workspace">
|
490 |
+
<Link className="btn btn-link" to={`/dir/${parentDir}`} aria-label="close">
|
491 |
+
<Close />
|
492 |
+
</Link>
|
493 |
+
</Tooltip>
|
494 |
</div>
|
495 |
</div>
|
496 |
<div style={{ height: "100%", width: "100vw" }} onDragOver={onDragOver} onDrop={onDrop}>
|