Spaces:
Running
Running
Merge pull request #10 from lynxkite/darabos-grouping
Browse files- lynxkite-app/src/lynxkite_app/crdt.py +3 -0
- lynxkite-app/web/src/Directory.tsx +1 -1
- lynxkite-app/web/src/Tooltip.tsx +2 -2
- lynxkite-app/web/src/common.ts +9 -0
- lynxkite-app/web/src/index.css +45 -0
- lynxkite-app/web/src/workspace/NodeSearch.tsx +1 -1
- lynxkite-app/web/src/workspace/Workspace.tsx +154 -26
- lynxkite-app/web/src/workspace/nodes/Group.tsx +65 -0
- lynxkite-app/web/src/workspace/nodes/LynxKiteNode.tsx +4 -11
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 |
-
<
|
11 |
{props.children}
|
12 |
-
</
|
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
|
|
|
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
|
66 |
const [message, setMessage] = useState(null as string | null);
|
67 |
useEffect(() => {
|
68 |
-
const state = syncedStore({ workspace: {} as
|
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
|
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 +287,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 +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
|
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 |
-
|
366 |
-
<
|
367 |
-
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
376 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 =
|
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,
|