darabos commited on
Commit
3f3cb7b
·
1 Parent(s): da1f2d0

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 addNode(node: Partial<WorkspaceNode>, state: { workspace: Workspace }, nodes: Node[]) {
251
- const title = node.data?.title;
252
  let i = 1;
253
- node.id = `${title} ${i}`;
254
- const wnodes = state.workspace.nodes!;
255
- while (wnodes.find((x) => x.id === node.id)) {
256
  i += 1;
257
- node.id = `${title} ${i}`;
258
  }
259
- wnodes.push(node as WorkspaceNode);
 
 
 
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
- addNode(node, state, nodes);
 
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, state, nodes);
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
- <button className="btn btn-link">
371
- <Atom />
372
- </button>
373
- <button className="btn btn-link" onClick={deleteSelection}>
374
- <Backspace />
375
- </button>
376
- <button className="btn btn-link" onClick={executeWorkspace}>
377
- <Restart />
378
- </button>
379
- <Link className="btn btn-link" to={`/dir/${parentDir}`} aria-label="close">
380
- <Close />
381
- </Link>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}>