darabos commited on
Commit
a66000a
·
1 Parent(s): 15bcc0e

Add and delete nodes.

Browse files
web/src/index.css CHANGED
@@ -109,17 +109,22 @@ body {
109
  visibility: hidden;
110
  }
111
 
112
- .left .handle-name {
113
  right: 20px;
114
  }
115
 
116
- .right .handle-name {
117
  left: 20px;
118
  }
119
 
120
- .top .handle-name,
121
- .bottom .handle-name {
122
- top: -5px;
 
 
 
 
 
123
  left: 5px;
124
  backdrop-filter: none;
125
  }
 
109
  visibility: hidden;
110
  }
111
 
112
+ .react-flow__handle-left .handle-name {
113
  right: 20px;
114
  }
115
 
116
+ .react-flow__handle-right .handle-name {
117
  left: 20px;
118
  }
119
 
120
+ .react-flow__handle-top .handle-name {
121
+ top: -20px;
122
+ left: 5px;
123
+ backdrop-filter: none;
124
+ }
125
+
126
+ .react-flow__handle-bottom .handle-name {
127
+ top: 0px;
128
  left: 5px;
129
  backdrop-filter: none;
130
  }
web/src/workspace/NodeSearch.tsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Fuse from 'fuse.js'
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+
4
+ export type OpsOp = {
5
+ name: string
6
+ type: string
7
+ position: { x: number, y: number }
8
+ params: { name: string, default: any }[]
9
+ }
10
+ export type Catalog = { [op: string]: OpsOp };
11
+ export type Catalogs = { [env: string]: Catalog };
12
+
13
+ export default function (props: { boxes: Catalog, onCancel: any, onAdd: any, pos: { x: number, y: number } }) {
14
+ const searchBox = useRef(null as unknown as HTMLInputElement);
15
+ const [searchText, setSearchText] = useState('');
16
+ const fuse = useMemo(() => new Fuse(Object.values(props.boxes), {
17
+ keys: ['name']
18
+ }), [props.boxes]);
19
+ const hits: { item: OpsOp }[] = searchText ? fuse.search<OpsOp>(searchText) : Object.values(props.boxes).map(box => ({ item: box }));
20
+ const [selectedIndex, setSelectedIndex] = useState(0);
21
+ useEffect(() => searchBox.current.focus());
22
+ function typed(text: string) {
23
+ setSearchText(text);
24
+ setSelectedIndex(Math.max(0, Math.min(selectedIndex, hits.length - 1)));
25
+ }
26
+ function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
27
+ if (e.key === 'ArrowDown') {
28
+ e.preventDefault();
29
+ setSelectedIndex(Math.min(selectedIndex + 1, hits.length - 1));
30
+ } else if (e.key === 'ArrowUp') {
31
+ e.preventDefault();
32
+ setSelectedIndex(Math.max(selectedIndex - 1, 0));
33
+ } else if (e.key === 'Enter') {
34
+ addSelected();
35
+ } else if (e.key === 'Escape') {
36
+ props.onCancel();
37
+ }
38
+ }
39
+ function addSelected() {
40
+ const node = { ...hits[selectedIndex].item };
41
+ node.position = props.pos;
42
+ props.onAdd(node);
43
+ }
44
+ async function lostFocus(e: any) {
45
+ // If it's a click on a result, let the click handler handle it.
46
+ if (e.relatedTarget && e.relatedTarget.closest('.node-search')) return;
47
+ props.onCancel();
48
+ }
49
+
50
+
51
+ return (
52
+ <div className="node-search" style={{ top: props.pos.y, left: props.pos.x }}>
53
+ <input
54
+ ref={searchBox}
55
+ value={searchText}
56
+ onChange={event => typed(event.target.value)}
57
+ onKeyDown={onKeyDown}
58
+ onBlur={lostFocus}
59
+ placeholder="Search for box" />
60
+ <div className="matches">
61
+ {hits.map((box, index) =>
62
+ <div
63
+ key={box.item.name}
64
+ tabIndex={0}
65
+ onFocus={() => setSelectedIndex(index)}
66
+ onMouseEnter={() => setSelectedIndex(index)}
67
+ onClick={addSelected}
68
+ className={`search-result ${index === selectedIndex ? 'selected' : ''}`}>
69
+ {box.item.name}
70
+ </div>
71
+ )}
72
+ </div>
73
+ </div >
74
+ );
75
+ }
web/src/workspace/Workspace.tsx CHANGED
@@ -1,7 +1,7 @@
1
  // The LynxKite workspace editor.
2
  import { useParams } from "react-router";
3
- import useSWR from 'swr';
4
- import { useEffect, useMemo, useCallback, useState } from "react";
5
  import favicon from '../assets/favicon.ico';
6
  import {
7
  ReactFlow,
@@ -16,6 +16,8 @@ import {
16
  type Edge,
17
  type Connection,
18
  type NodeTypes,
 
 
19
  } from '@xyflow/react';
20
  // @ts-ignore
21
  import ArrowBack from '~icons/tabler/arrow-back.jsx';
@@ -35,7 +37,8 @@ import NodeWithParams from './nodes/NodeWithParams';
35
  import EnvironmentSelector from './EnvironmentSelector';
36
  import { LynxKiteState } from './LynxKiteState';
37
  import '@xyflow/react/dist/style.css';
38
- import { Workspace } from "../apiTypes.ts";
 
39
 
40
  export default function (props: any) {
41
  return (
@@ -48,6 +51,7 @@ export default function (props: any) {
48
 
49
  function LynxKiteFlow() {
50
  const updateNodeInternals = useUpdateNodeInternals()
 
51
  const [nodes, setNodes] = useState([] as Node[]);
52
  const [edges, setEdges] = useState([] as Edge[]);
53
  const { path } = useParams();
@@ -57,13 +61,14 @@ function LynxKiteFlow() {
57
  setState(state);
58
  const doc = getYjsDoc(state);
59
  const wsProvider = new WebsocketProvider("ws://localhost:8000/ws/crdt", path!, doc);
60
- const onChange = (update: any, origin: any, doc: any, tr: any) => {
61
  if (origin === wsProvider) {
62
  // An update from the CRDT. Apply it to the local state.
63
  // This is only necessary because ReactFlow keeps secret internal copies of our stuff.
64
  if (!state.workspace) return;
65
  if (!state.workspace.nodes) return;
66
  if (!state.workspace.edges) return;
 
67
  setNodes([...state.workspace.nodes] as Node[]);
68
  setEdges([...state.workspace.edges] as Edge[]);
69
  for (const node of state.workspace.nodes) {
@@ -98,6 +103,8 @@ function LynxKiteFlow() {
98
  } else if (ch.type === 'select') {
99
  } else if (ch.type === 'dimensions') {
100
  getYjsDoc(state).transact(() => Object.assign(node, ch.dimensions));
 
 
101
  } else if (ch.type === 'replace') {
102
  // Ideally we would only update the parameter that changed. But ReactFlow does not give us that detail.
103
  const u = {
@@ -116,13 +123,79 @@ function LynxKiteFlow() {
116
  setEdges((eds) => applyEdgeChanges(changes, eds));
117
  };
118
 
119
- const fetcher = (resource: string, init?: RequestInit) => fetch(resource, init).then(res => res.json());
120
  const catalog = useSWR('/api/catalog', fetcher);
121
 
122
  const nodeTypes = useMemo(() => ({
123
  basic: NodeWithParams,
124
  table_view: NodeWithParams,
125
  }), []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  const parentDir = path!.split('/').slice(0, -1).join('/');
127
  return (
128
  <div className="workspace">
@@ -150,15 +223,19 @@ function LynxKiteFlow() {
150
  nodeTypes={nodeTypes} fitView
151
  onNodesChange={onNodesChange}
152
  onEdgesChange={onEdgesChange}
 
 
 
153
  proOptions={{ hideAttribution: true }}
154
  maxZoom={3}
155
  minZoom={0.3}
156
  defaultEdgeOptions={{ markerEnd: { type: MarkerType.Arrow } }}
157
  >
158
  <Controls />
159
- {/* {#if nodeSearchSettings}
160
- <NodeSearch pos={nodeSearchSettings.pos} boxes={nodeSearchSettings.boxes} on:cancel={closeNodeSearch} on:add={addNode} />
161
- {/if} */}
 
162
  </ReactFlow>
163
  </LynxKiteState.Provider>
164
  </div>
 
1
  // The LynxKite workspace editor.
2
  import { useParams } from "react-router";
3
+ import useSWR, { Fetcher } from 'swr';
4
+ import { useEffect, useMemo, useCallback, useState, MouseEvent } from "react";
5
  import favicon from '../assets/favicon.ico';
6
  import {
7
  ReactFlow,
 
16
  type Edge,
17
  type Connection,
18
  type NodeTypes,
19
+ useReactFlow,
20
+ MiniMap,
21
  } from '@xyflow/react';
22
  // @ts-ignore
23
  import ArrowBack from '~icons/tabler/arrow-back.jsx';
 
37
  import EnvironmentSelector from './EnvironmentSelector';
38
  import { LynxKiteState } from './LynxKiteState';
39
  import '@xyflow/react/dist/style.css';
40
+ import { Workspace, WorkspaceNode } from "../apiTypes.ts";
41
+ import NodeSearch, { OpsOp, Catalog, Catalogs } from "./NodeSearch.tsx";
42
 
43
  export default function (props: any) {
44
  return (
 
51
 
52
  function LynxKiteFlow() {
53
  const updateNodeInternals = useUpdateNodeInternals()
54
+ const reactFlow = useReactFlow();
55
  const [nodes, setNodes] = useState([] as Node[]);
56
  const [edges, setEdges] = useState([] as Edge[]);
57
  const { path } = useParams();
 
61
  setState(state);
62
  const doc = getYjsDoc(state);
63
  const wsProvider = new WebsocketProvider("ws://localhost:8000/ws/crdt", path!, doc);
64
+ const onChange = (_update: any, origin: any, _doc: any, _tr: any) => {
65
  if (origin === wsProvider) {
66
  // An update from the CRDT. Apply it to the local state.
67
  // This is only necessary because ReactFlow keeps secret internal copies of our stuff.
68
  if (!state.workspace) return;
69
  if (!state.workspace.nodes) return;
70
  if (!state.workspace.edges) return;
71
+ console.log('update', JSON.parse(JSON.stringify(state.workspace)));
72
  setNodes([...state.workspace.nodes] as Node[]);
73
  setEdges([...state.workspace.edges] as Edge[]);
74
  for (const node of state.workspace.nodes) {
 
103
  } else if (ch.type === 'select') {
104
  } else if (ch.type === 'dimensions') {
105
  getYjsDoc(state).transact(() => Object.assign(node, ch.dimensions));
106
+ } else if (ch.type === 'remove') {
107
+ wnodes.splice(nodeIndex, 1);
108
  } else if (ch.type === 'replace') {
109
  // Ideally we would only update the parameter that changed. But ReactFlow does not give us that detail.
110
  const u = {
 
123
  setEdges((eds) => applyEdgeChanges(changes, eds));
124
  };
125
 
126
+ const fetcher: Fetcher<Catalogs> = (resource: string, init?: RequestInit) => fetch(resource, init).then(res => res.json());
127
  const catalog = useSWR('/api/catalog', fetcher);
128
 
129
  const nodeTypes = useMemo(() => ({
130
  basic: NodeWithParams,
131
  table_view: NodeWithParams,
132
  }), []);
133
+ function closeNodeSearch() {
134
+ setNodeSearchSettings(undefined);
135
+ }
136
+ function toggleNodeSearch(event: MouseEvent) {
137
+ if (nodeSearchSettings) {
138
+ closeNodeSearch();
139
+ return;
140
+ }
141
+ event.preventDefault();
142
+ setNodeSearchSettings({
143
+ pos: { x: event.clientX, y: event.clientY },
144
+ boxes: catalog.data![state.workspace.env!],
145
+ });
146
+ }
147
+ function addNode(meta: OpsOp) {
148
+ const node: Partial<WorkspaceNode> = {
149
+ type: meta.type,
150
+ data: {
151
+ meta: meta,
152
+ title: meta.name,
153
+ params: Object.fromEntries(
154
+ Object.values(meta.params).map((p) => [p.name, p.default])),
155
+ },
156
+ };
157
+ const nss = nodeSearchSettings!;
158
+ node.position = reactFlow.screenToFlowPosition({ x: nss.pos.x, y: nss.pos.y });
159
+ const title = meta.name;
160
+ let i = 1;
161
+ node.id = `${title} ${i}`;
162
+ const wnodes = state.workspace.nodes!;
163
+ while (wnodes.find((x) => x.id === node.id)) {
164
+ i += 1;
165
+ node.id = `${title} ${i}`;
166
+ }
167
+ wnodes.push(node as WorkspaceNode);
168
+ setNodes([...nodes, node as WorkspaceNode]);
169
+ closeNodeSearch();
170
+ }
171
+ const [nodeSearchSettings, setNodeSearchSettings] = useState(undefined as {
172
+ pos: XYPosition,
173
+ boxes: Catalog,
174
+ } | undefined);
175
+
176
+ function nodeClick(e: any) {
177
+ const node = e.detail.node;
178
+ const meta = node.data.meta;
179
+ if (!meta) return;
180
+ const sub_nodes = meta.sub_nodes;
181
+ if (!sub_nodes) return;
182
+ const event = e.detail.event;
183
+ if (event.target.classList.contains('title')) return;
184
+ setNodeSearchSettings({
185
+ pos: { x: event.clientX, y: event.clientY },
186
+ boxes: sub_nodes,
187
+ });
188
+ }
189
+ function onConnect(params: Connection) {
190
+ // const edge = {
191
+ // id: `${params.source} ${params.target}`,
192
+ // source: params.source,
193
+ // sourceHandle: params.sourceHandle,
194
+ // target: params.target,
195
+ // targetHandle: params.targetHandle,
196
+ // };
197
+ // state.workspace.edges!.push(edge);
198
+ }
199
  const parentDir = path!.split('/').slice(0, -1).join('/');
200
  return (
201
  <div className="workspace">
 
223
  nodeTypes={nodeTypes} fitView
224
  onNodesChange={onNodesChange}
225
  onEdgesChange={onEdgesChange}
226
+ onPaneClick={toggleNodeSearch}
227
+ onNodeClick={nodeClick}
228
+ onConnect={onConnect}
229
  proOptions={{ hideAttribution: true }}
230
  maxZoom={3}
231
  minZoom={0.3}
232
  defaultEdgeOptions={{ markerEnd: { type: MarkerType.Arrow } }}
233
  >
234
  <Controls />
235
+ <MiniMap />
236
+ {nodeSearchSettings &&
237
+ <NodeSearch pos={nodeSearchSettings.pos} boxes={nodeSearchSettings.boxes} onCancel={closeNodeSearch} onAdd={addNode} />
238
+ }
239
  </ReactFlow>
240
  </LynxKiteState.Provider>
241
  </div>