darabos commited on
Commit
7297755
·
unverified ·
2 Parent(s): d762aa6 ffe4038

Merge pull request #10 from lynxkite/darabos-grouping

Browse files
lynxkite-app/src/lynxkite_app/crdt.py CHANGED
@@ -117,6 +117,7 @@ last_ws_input = None
117
 
118
 
119
  def clean_input(ws_pyd):
 
120
  for node in ws_pyd.nodes:
121
  node.data.display = None
122
  node.data.input_metadata = None
@@ -125,6 +126,8 @@ def clean_input(ws_pyd):
125
  for p in list(node.data.params):
126
  if p.startswith("_"):
127
  del node.data.params[p]
 
 
128
  node.position.x = 0
129
  node.position.y = 0
130
  if node.model_extra:
 
117
 
118
 
119
  def clean_input(ws_pyd):
120
+ """Delete everything that we want to ignore for the purposes of change detection."""
121
  for node in ws_pyd.nodes:
122
  node.data.display = None
123
  node.data.input_metadata = None
 
126
  for p in list(node.data.params):
127
  if p.startswith("_"):
128
  del node.data.params[p]
129
+ if node.data.title == "Comment":
130
+ node.data.params = {}
131
  node.position.x = 0
132
  node.position.y = 0
133
  if node.model_extra:
lynxkite-app/web/src/Directory.tsx CHANGED
@@ -58,7 +58,7 @@ function EntryCreator(props: {
58
 
59
  const fetcher = (url: string) => fetch(url).then((res) => res.json());
60
 
61
- export default function () {
62
  const path = usePath().replace(/^[/]$|^[/]dir$|^[/]dir[/]/, "");
63
  const encodedPath = encodeURIComponent(path || "");
64
  const list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher, {
 
58
 
59
  const fetcher = (url: string) => fetch(url).then((res) => res.json());
60
 
61
+ export default function Directory() {
62
  const path = usePath().replace(/^[/]$|^[/]dir$|^[/]dir[/]/, "");
63
  const encodedPath = encodeURIComponent(path || "");
64
  const list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher, {
lynxkite-app/web/src/Tooltip.tsx CHANGED
@@ -7,9 +7,9 @@ export default function Tooltip(props: any) {
7
  if (!props.doc) return null;
8
  return (
9
  <>
10
- <a data-tooltip-id={id} tabIndex={0}>
11
  {props.children}
12
- </a>
13
  <ReactTooltip id={id} className="tooltip prose" place="top-end">
14
  {props.doc.map?.(
15
  (section: any, i: number) =>
 
7
  if (!props.doc) return null;
8
  return (
9
  <>
10
+ <span data-tooltip-id={id} tabIndex={0}>
11
  {props.children}
12
+ </span>
13
  <ReactTooltip id={id} className="tooltip prose" place="top-end">
14
  {props.doc.map?.(
15
  (section: any, i: number) =>
lynxkite-app/web/src/common.ts CHANGED
@@ -5,3 +5,12 @@ export function usePath() {
5
  const path = decodeURIComponent(useLocation().pathname).replace(/[/]$/, "");
6
  return path;
7
  }
 
 
 
 
 
 
 
 
 
 
5
  const path = decodeURIComponent(useLocation().pathname).replace(/[/]$/, "");
6
  return path;
7
  }
8
+
9
+ export const COLORS: { [key: string]: string } = {
10
+ gray: "oklch(95% 0 0)",
11
+ pink: "oklch(75% 0.2 0)",
12
+ orange: "oklch(75% 0.2 55)",
13
+ green: "oklch(75% 0.2 150)",
14
+ blue: "oklch(75% 0.2 230)",
15
+ purple: "oklch(75% 0.2 290)",
16
+ };
lynxkite-app/web/src/index.css CHANGED
@@ -88,6 +88,42 @@ body {
88
  }
89
  }
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  .tooltip {
92
  padding: 8px;
93
  border-radius: 4px;
@@ -102,6 +138,10 @@ body {
102
  max-width: 300px;
103
  }
104
 
 
 
 
 
105
  .expanded .lynxkite-node {
106
  height: 100%;
107
  }
@@ -526,12 +566,17 @@ body {
526
  z-index: -10 !important;
527
  }
528
 
 
529
  .selected .comment-view,
530
  .selected .lynxkite-node {
531
  outline: var(--xy-selection-border, var(--xy-selection-border-default));
532
  outline-offset: 7.5px;
533
  }
534
 
 
 
 
 
535
  .graph-creation-view {
536
  display: flex;
537
  width: 100%;
 
88
  }
89
  }
90
 
91
+ .in-group .lynxkite-node {
92
+ box-shadow: 0px 1px 5px 0px rgba(0, 0, 0, 0.3);
93
+ opacity: 0.3;
94
+ transition: opacity 0.3s;
95
+ }
96
+
97
+ .in-group .lynxkite-node:hover {
98
+ opacity: 1;
99
+ }
100
+
101
+ .node-group {
102
+ box-shadow: 0px 3px 30px 0px rgba(0, 0, 0, 0.3);
103
+ border-radius: 20px;
104
+ border: none;
105
+ background-color: white;
106
+ opacity: 0.9;
107
+ display: flex;
108
+ flex-direction: column;
109
+ align-items: end;
110
+ padding: 10px 20px;
111
+ }
112
+
113
+ .node-group-color-picker-icon {
114
+ font-size: 30px;
115
+ opacity: 0.1;
116
+ transition: opacity 0.3s;
117
+ }
118
+
119
+ .node-group:hover .node-group-color-picker-icon {
120
+ opacity: 1;
121
+ }
122
+
123
+ .color-picker-button {
124
+ font-size: 30px;
125
+ }
126
+
127
  .tooltip {
128
  padding: 8px;
129
  border-radius: 4px;
 
138
  max-width: 300px;
139
  }
140
 
141
+ .prose p {
142
+ margin-bottom: 0;
143
+ }
144
+
145
  .expanded .lynxkite-node {
146
  height: 100%;
147
  }
 
566
  z-index: -10 !important;
567
  }
568
 
569
+ .selected .node-group,
570
  .selected .comment-view,
571
  .selected .lynxkite-node {
572
  outline: var(--xy-selection-border, var(--xy-selection-border-default));
573
  outline-offset: 7.5px;
574
  }
575
 
576
+ .selected .node-group {
577
+ outline-offset: 20px;
578
+ }
579
+
580
  .graph-creation-view {
581
  display: flex;
582
  width: 100%;
lynxkite-app/web/src/workspace/NodeSearch.tsx CHANGED
@@ -10,7 +10,7 @@ export type OpsOp = {
10
  export type Catalog = { [op: string]: OpsOp };
11
  export type Catalogs = { [env: string]: Catalog };
12
 
13
- export default function (props: {
14
  boxes: Catalog;
15
  onCancel: any;
16
  onAdd: any;
 
10
  export type Catalog = { [op: string]: OpsOp };
11
  export type Catalogs = { [env: string]: Catalog };
12
 
13
+ export default function NodeSearch(props: {
14
  boxes: Catalog;
15
  onCancel: any;
16
  onAdd: any;
lynxkite-app/web/src/workspace/Workspace.tsx CHANGED
@@ -25,10 +25,15 @@ 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 { Workspace, WorkspaceNode } from "../apiTypes.ts";
 
32
  import favicon from "../assets/favicon.ico";
33
  import { usePath } from "../common.ts";
34
  // import NodeWithTableView from './NodeWithTableView';
@@ -37,6 +42,7 @@ import LynxKiteEdge from "./LynxKiteEdge.tsx";
37
  import { LynxKiteState } from "./LynxKiteState";
38
  import NodeSearch, { type OpsOp, type Catalog, type Catalogs } from "./NodeSearch.tsx";
39
  import NodeWithGraphCreationView from "./nodes/GraphCreationNode.tsx";
 
40
  import NodeWithComment from "./nodes/NodeWithComment.tsx";
41
  import NodeWithImage from "./nodes/NodeWithImage.tsx";
42
  import NodeWithMolecule from "./nodes/NodeWithMolecule.tsx";
@@ -44,7 +50,7 @@ import NodeWithParams from "./nodes/NodeWithParams";
44
  import NodeWithTableView from "./nodes/NodeWithTableView.tsx";
45
  import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx";
46
 
47
- export default function (props: any) {
48
  return (
49
  <ReactFlowProvider>
50
  <LynxKiteFlow {...props} />
@@ -62,10 +68,10 @@ function LynxKiteFlow() {
62
  .split("/")
63
  .pop()!
64
  .replace(/[.]lynxkite[.]json$/, "");
65
- const [state, setState] = useState({ workspace: {} as Workspace });
66
  const [message, setMessage] = useState(null as string | null);
67
  useEffect(() => {
68
- const state = syncedStore({ workspace: {} as Workspace });
69
  setState(state);
70
  const doc = getYjsDoc(state);
71
  const proto = location.protocol === "https:" ? "wss:" : "ws:";
@@ -78,7 +84,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
  }
@@ -185,6 +191,7 @@ function LynxKiteFlow() {
185
  graph_creation_view: NodeWithGraphCreationView,
186
  molecule: NodeWithMolecule,
187
  comment: NodeWithComment,
 
188
  }),
189
  [],
190
  );
@@ -247,16 +254,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 +287,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 +331,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 +346,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
  }
@@ -346,6 +357,106 @@ function LynxKiteFlow() {
346
  setMessage("Workspace execution failed.");
347
  }
348
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  return (
350
  <div className="workspace">
351
  <div className="top-bar bg-neutral">
@@ -362,18 +473,35 @@ function LynxKiteFlow() {
362
  }}
363
  />
364
  <div className="tools text-secondary">
365
- <button className="btn btn-link">
366
- <Atom />
367
- </button>
368
- <button className="btn btn-link">
369
- <Backspace />
370
- </button>
371
- <button className="btn btn-link" onClick={executeWorkspace}>
372
- <Restart />
373
- </button>
374
- <Link className="btn btn-link" to={`/dir/${parentDir}`} aria-label="close">
375
- <Close />
376
- </Link>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  </div>
378
  </div>
379
  <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";
39
  // import NodeWithTableView from './NodeWithTableView';
 
42
  import { LynxKiteState } from "./LynxKiteState";
43
  import NodeSearch, { type OpsOp, type Catalog, type Catalogs } from "./NodeSearch.tsx";
44
  import NodeWithGraphCreationView from "./nodes/GraphCreationNode.tsx";
45
+ import Group from "./nodes/Group.tsx";
46
  import NodeWithComment from "./nodes/NodeWithComment.tsx";
47
  import NodeWithImage from "./nodes/NodeWithImage.tsx";
48
  import NodeWithMolecule from "./nodes/NodeWithMolecule.tsx";
 
50
  import NodeWithTableView from "./nodes/NodeWithTableView.tsx";
51
  import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx";
52
 
53
+ export default function Workspace(props: any) {
54
  return (
55
  <ReactFlowProvider>
56
  <LynxKiteFlow {...props} />
 
68
  .split("/")
69
  .pop()!
70
  .replace(/[.]lynxkite[.]json$/, "");
71
+ const [state, setState] = useState({ workspace: {} as WorkspaceType });
72
  const [message, setMessage] = useState(null as string | null);
73
  useEffect(() => {
74
+ const state = syncedStore({ workspace: {} as WorkspaceType });
75
  setState(state);
76
  const doc = getYjsDoc(state);
77
  const proto = location.protocol === "https:" ? "wss:" : "ws:";
 
84
  if (!state.workspace.nodes) return;
85
  if (!state.workspace.edges) return;
86
  for (const n of state.workspace.nodes) {
87
+ if (n.type !== "node_group" && n.dragHandle !== ".drag-handle") {
88
  n.dragHandle = ".drag-handle";
89
  }
90
  }
 
191
  graph_creation_view: NodeWithGraphCreationView,
192
  molecule: NodeWithMolecule,
193
  comment: NodeWithComment,
194
+ node_group: Group,
195
  }),
196
  [],
197
  );
 
254
  },
255
  [catalog, state, nodeSearchSettings, suppressSearchUntil, closeNodeSearch],
256
  );
257
+ function findFreeId(prefix: string) {
 
258
  let i = 1;
259
+ let id = `${prefix} ${i}`;
260
+ const used = new Set(state.workspace.nodes!.map((n) => n.id));
261
+ while (used.has(id)) {
262
  i += 1;
263
+ id = `${prefix} ${i}`;
264
  }
265
+ return id;
266
+ }
267
+ function addNode(node: Partial<WorkspaceNode>) {
268
+ state.workspace.nodes!.push(node as WorkspaceNode);
269
  setNodes([...nodes, node as WorkspaceNode]);
270
  }
271
  function nodeFromMeta(meta: OpsOp): Partial<WorkspaceNode> {
 
287
  x: nss.pos.x,
288
  y: nss.pos.y,
289
  });
290
+ node.id = findFreeId(node.data!.title);
291
+ addNode(node);
292
  closeNodeSearch();
293
  },
294
  [nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch],
 
331
  setMessage(null);
332
  const cat = catalog.data![state.workspace.env!];
333
  const node = nodeFromMeta(cat["Import file"]);
334
+ node.id = findFreeId(node.data!.title);
335
  node.position = reactFlow.screenToFlowPosition({
336
  x: e.clientX,
337
  y: e.clientY,
 
346
  } else if (file.name.includes(".xls")) {
347
  node.data!.params.file_format = "excel";
348
  }
349
+ addNode(node);
350
  } catch (error) {
351
  setMessage("File upload failed.");
352
  }
 
357
  setMessage("Workspace execution failed.");
358
  }
359
  }
360
+ function deleteSelection() {
361
+ const selectedNodes = nodes.filter((n) => n.selected);
362
+ const selectedEdges = edges.filter((e) => e.selected);
363
+ reactFlow.deleteElements({ nodes: selectedNodes, edges: selectedEdges });
364
+ }
365
+ function groupSelection() {
366
+ const selectedNodes = nodes.filter((n) => n.selected && !n.parentId);
367
+ const groupNode = {
368
+ id: findFreeId("Group"),
369
+ type: "node_group",
370
+ position: { x: 0, y: 0 },
371
+ width: 0,
372
+ height: 0,
373
+ data: { title: "Group", params: {} },
374
+ };
375
+ let top = Number.POSITIVE_INFINITY;
376
+ let left = Number.POSITIVE_INFINITY;
377
+ let bottom = Number.NEGATIVE_INFINITY;
378
+ let right = Number.NEGATIVE_INFINITY;
379
+ const PAD = 10;
380
+ for (const node of selectedNodes) {
381
+ if (node.position.y - PAD < top) top = node.position.y - PAD;
382
+ if (node.position.x - PAD < left) left = node.position.x - PAD;
383
+ if (node.position.y + PAD + node.height! > bottom)
384
+ bottom = node.position.y + PAD + node.height!;
385
+ if (node.position.x + PAD + node.width! > right) right = node.position.x + PAD + node.width!;
386
+ }
387
+ groupNode.position = {
388
+ x: left,
389
+ y: top,
390
+ };
391
+ groupNode.width = right - left;
392
+ groupNode.height = bottom - top;
393
+ setNodes([
394
+ { ...(groupNode as WorkspaceNode), selected: true },
395
+ ...nodes.map((n) =>
396
+ n.selected
397
+ ? {
398
+ ...n,
399
+ position: { x: n.position.x - left, y: n.position.y - top },
400
+ parentId: groupNode.id,
401
+ extent: "parent" as const,
402
+ selected: false,
403
+ }
404
+ : n,
405
+ ),
406
+ ]);
407
+ getYjsDoc(state).transact(() => {
408
+ state.workspace.nodes!.unshift(groupNode as WorkspaceNode);
409
+ const selectedNodeIds = new Set(selectedNodes.map((n) => n.id));
410
+ for (const node of state.workspace.nodes!) {
411
+ if (selectedNodeIds.has(node.id)) {
412
+ node.position.x -= left;
413
+ node.position.y -= top;
414
+ node.parentId = groupNode.id;
415
+ node.extent = "parent";
416
+ node.selected = false;
417
+ }
418
+ }
419
+ });
420
+ }
421
+ function ungroupSelection() {
422
+ const groups = Object.fromEntries(
423
+ nodes
424
+ .filter((n) => n.selected && n.type === "node_group" && !n.parentId)
425
+ .map((n) => [n.id, n]),
426
+ );
427
+ setNodes(
428
+ nodes
429
+ .filter((n) => !groups[n.id])
430
+ .map((n) => {
431
+ const g = groups[n.parentId!];
432
+ if (!g) return n;
433
+ return {
434
+ ...n,
435
+ position: { x: n.position.x + g.position.x, y: n.position.y + g.position.y },
436
+ parentId: undefined,
437
+ extent: undefined,
438
+ selected: true,
439
+ };
440
+ }),
441
+ );
442
+ getYjsDoc(state).transact(() => {
443
+ const wnodes = state.workspace.nodes!;
444
+ for (const node of state.workspace.nodes!) {
445
+ const g = groups[node.parentId as string];
446
+ if (!g) continue;
447
+ node.position.x += g.position.x;
448
+ node.position.y += g.position.y;
449
+ node.parentId = undefined;
450
+ node.extent = undefined;
451
+ }
452
+ for (const groupId in groups) {
453
+ const groupIdx = wnodes.findIndex((n) => n.id === groupId);
454
+ wnodes.splice(groupIdx, 1);
455
+ }
456
+ });
457
+ }
458
+ const areMultipleNodesSelected = nodes.filter((n) => n.selected).length > 1;
459
+ const isAnyGroupSelected = nodes.some((n) => n.selected && n.type === "node_group");
460
  return (
461
  <div className="workspace">
462
  <div className="top-bar bg-neutral">
 
473
  }}
474
  />
475
  <div className="tools text-secondary">
476
+ {areMultipleNodesSelected && (
477
+ <Tooltip doc="Group selected nodes">
478
+ <button className="btn btn-link" onClick={groupSelection}>
479
+ <GroupIcon />
480
+ </button>
481
+ </Tooltip>
482
+ )}
483
+ {isAnyGroupSelected && (
484
+ <Tooltip doc="Ungroup selected nodes">
485
+ <button className="btn btn-link" onClick={ungroupSelection}>
486
+ <UngroupIcon />
487
+ </button>
488
+ </Tooltip>
489
+ )}
490
+ <Tooltip doc="Delete selected nodes and edges">
491
+ <button className="btn btn-link" onClick={deleteSelection}>
492
+ <Backspace />
493
+ </button>
494
+ </Tooltip>
495
+ <Tooltip doc="Re-run the workspace">
496
+ <button className="btn btn-link" onClick={executeWorkspace}>
497
+ <Restart />
498
+ </button>
499
+ </Tooltip>
500
+ <Tooltip doc="Close workspace">
501
+ <Link className="btn btn-link" to={`/dir/${parentDir}`} aria-label="close">
502
+ <Close />
503
+ </Link>
504
+ </Tooltip>
505
  </div>
506
  </div>
507
  <div style={{ height: "100%", width: "100vw" }} onDragOver={onDragOver} onDrop={onDrop}>
lynxkite-app/web/src/workspace/nodes/Group.tsx ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useReactFlow } from "@xyflow/react";
2
+ import { useState } from "react";
3
+ // @ts-ignore
4
+ import Palette from "~icons/tabler/palette-filled.jsx";
5
+ // @ts-ignore
6
+ import Square from "~icons/tabler/square-filled.jsx";
7
+ import Tooltip from "../../Tooltip.tsx";
8
+ import { COLORS } from "../../common.ts";
9
+
10
+ export default function Group(props: any) {
11
+ const reactFlow = useReactFlow();
12
+ const [displayingColorPicker, setDisplayingColorPicker] = useState(false);
13
+ function setColor(newValue: string) {
14
+ reactFlow.updateNodeData(props.id, (prevData: any) => ({
15
+ ...prevData,
16
+ params: { color: newValue },
17
+ }));
18
+ setDisplayingColorPicker(false);
19
+ }
20
+ function toggleColorPicker(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
21
+ e.stopPropagation();
22
+ setDisplayingColorPicker(!displayingColorPicker);
23
+ }
24
+ const currentColor = props.data?.params?.color || "gray";
25
+ return (
26
+ <div
27
+ className="node-group"
28
+ style={{
29
+ width: props.width,
30
+ height: props.height,
31
+ backgroundColor: COLORS[currentColor],
32
+ opacity: 0.9,
33
+ }}
34
+ >
35
+ <button
36
+ className="node-group-color-picker-icon"
37
+ onClick={toggleColorPicker}
38
+ aria-label="Change group color"
39
+ >
40
+ <Tooltip doc="Change color">
41
+ <Palette />
42
+ </Tooltip>
43
+ </button>
44
+ {displayingColorPicker && <ColorPicker currentColor={currentColor} onPick={setColor} />}
45
+ </div>
46
+ );
47
+ }
48
+
49
+ function ColorPicker(props: { currentColor: string; onPick: (color: string) => void }) {
50
+ const colors = Object.keys(COLORS).filter((color) => color !== props.currentColor);
51
+ return (
52
+ <div className="color-picker">
53
+ {colors.map((color) => (
54
+ <button
55
+ key={color}
56
+ className="color-picker-button"
57
+ style={{ color: COLORS[color] }}
58
+ onClick={() => props.onPick(color)}
59
+ >
60
+ <Square />
61
+ </button>
62
+ ))}
63
+ </div>
64
+ );
65
+ }
lynxkite-app/web/src/workspace/nodes/LynxKiteNode.tsx CHANGED
@@ -12,6 +12,7 @@ import Help from "~icons/tabler/question-mark.jsx";
12
  // @ts-ignore
13
  import Skull from "~icons/tabler/skull.jsx";
14
  import Tooltip from "../../Tooltip";
 
15
 
16
  interface LynxKiteNodeProps {
17
  id: string;
@@ -20,6 +21,7 @@ interface LynxKiteNodeProps {
20
  nodeStyle: any;
21
  data: any;
22
  children: any;
 
23
  }
24
 
25
  function getHandles(inputs: any[], outputs: any[]) {
@@ -53,15 +55,6 @@ function getHandles(inputs: any[], outputs: any[]) {
53
  return handles;
54
  }
55
 
56
- const OP_COLORS: { [key: string]: string } = {
57
- gray: "oklch(95% 0 0)",
58
- pink: "oklch(75% 0.2 0)",
59
- orange: "oklch(75% 0.2 55)",
60
- green: "oklch(75% 0.2 150)",
61
- blue: "oklch(75% 0.2 230)",
62
- purple: "oklch(75% 0.2 290)",
63
- };
64
-
65
  function LynxKiteNodeComponent(props: LynxKiteNodeProps) {
66
  const reactFlow = useReactFlow();
67
  const data = props.data;
@@ -78,11 +71,11 @@ function LynxKiteNodeComponent(props: LynxKiteNodeProps) {
78
  };
79
  const titleStyle: { backgroundColor?: string } = {};
80
  if (data.meta?.value?.color) {
81
- titleStyle.backgroundColor = OP_COLORS[data.meta.value.color] || data.meta.value.color;
82
  }
83
  return (
84
  <div
85
- className={`node-container ${expanded ? "expanded" : "collapsed"} `}
86
  style={{
87
  width: props.width || 200,
88
  height: expanded ? props.height || 200 : undefined,
 
12
  // @ts-ignore
13
  import Skull from "~icons/tabler/skull.jsx";
14
  import Tooltip from "../../Tooltip";
15
+ import { COLORS } from "../../common.ts";
16
 
17
  interface LynxKiteNodeProps {
18
  id: string;
 
21
  nodeStyle: any;
22
  data: any;
23
  children: any;
24
+ parentId?: string;
25
  }
26
 
27
  function getHandles(inputs: any[], outputs: any[]) {
 
55
  return handles;
56
  }
57
 
 
 
 
 
 
 
 
 
 
58
  function LynxKiteNodeComponent(props: LynxKiteNodeProps) {
59
  const reactFlow = useReactFlow();
60
  const data = props.data;
 
71
  };
72
  const titleStyle: { backgroundColor?: string } = {};
73
  if (data.meta?.value?.color) {
74
+ titleStyle.backgroundColor = COLORS[data.meta.value.color] || data.meta.value.color;
75
  }
76
  return (
77
  <div
78
+ className={`node-container ${expanded ? "expanded" : "collapsed"} ${props.parentId ? "in-group" : ""}`}
79
  style={{
80
  width: props.width || 200,
81
  height: expanded ? props.height || 200 : undefined,