From 844e778e2b5cddfde2b9bc152309612a3cf31d71 Mon Sep 17 00:00:00 2001 From: sander boer Date: Sun, 15 Dec 2024 20:57:55 +0100 Subject: [PATCH] fix: delete nodes (#41) * init debugging * found bug and overwrite `xyflow` behaviour until issue is resolved * update feedback * update docs * update docs * adding docs to navigation --- FEEDBACK.md | 2 +- apps/electron-app/package.json | 2 +- apps/electron-app/src/index.css | 4 +- .../components/react-flow/ReactFlowCanvas.tsx | 10 +- .../components/react-flow/nodes/Node.tsx | 8 +- .../react-flow/panels/SettingsPanel.tsx | 13 +++ .../src/render/providers/NewNodeProvider.tsx | 103 ++++++++++++------ .../src/render/stores/new-node.ts | 2 + .../src/render/stores/react-flow.ts | 54 +++++---- .../app/docs/contributing/nodes/page.md | 66 ++++++----- apps/nextjs-app/lib/navigation.ts | 5 +- yarn.lock | 20 ++-- 12 files changed, 170 insertions(+), 119 deletions(-) create mode 100644 apps/electron-app/src/render/components/react-flow/panels/SettingsPanel.tsx diff --git a/FEEDBACK.md b/FEEDBACK.md index 2f2a2c0..3b48a14 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -1,7 +1,7 @@ ## Some thoughts - [ ] it feels a bit unhandy that the inputs are on the left side of the node and the outputs are a the bottom. This kind of forces the program to grow in a starcase shape downwards towards the right, which forces scrolling in two directions instead of just up-down or left-right. In biggish sketches it would be more usable to have a dataflow that travel in one axis only. -- [ ] for some reason to delete a node I must select it and delete it twice +- [x] for some reason to delete a node I must select it and delete it twice - [x] difference between the `save` and the `save & close` button in the settings pannel not very clear, what is saved? you mean apply new settings to node? or save state in file? bit unclear? could the settings be applied just 'on change' in the dialog entry? - [x] when I was messing around with the codebase to add a new node type I saw the app fail silently... I thought that was a javascript feature that was not possible with typescript. diff --git a/apps/electron-app/package.json b/apps/electron-app/package.json index edc2ee4..c540043 100644 --- a/apps/electron-app/package.json +++ b/apps/electron-app/package.json @@ -63,7 +63,7 @@ "@microflow/ui": "workspaces:*", "@microflow/utils": "workspaces:*", "@tsparticles/react": "3.0.0", - "@xyflow/react": "12.3.1", + "@xyflow/react": "12.3.6", "abcjs": "6.4.3", "electron-log": "5.2.0", "firmata": "https://github.com/xiduzo/firmata.js.git", diff --git a/apps/electron-app/src/index.css b/apps/electron-app/src/index.css index 5c9a68c..2391869 100644 --- a/apps/electron-app/src/index.css +++ b/apps/electron-app/src/index.css @@ -11,7 +11,7 @@ body { /* Nodes */ .react-flow__minimap .react-flow__minimap-node { - @apply fill-muted transition-all; + @apply fill-muted transition-colors; } .react-flow__minimap .react-flow__minimap-node.selected { @apply fill-blue-500; @@ -19,7 +19,7 @@ body { /* Edges */ .react-flow__edges .react-flow__edge-path { - @apply stroke-[4] stroke-neutral-600 transition-all; + @apply stroke-[4] stroke-neutral-600 transition-colors; } .react-flow__edges .react-flow__edge.selected .react-flow__edge-path, diff --git a/apps/electron-app/src/render/components/react-flow/ReactFlowCanvas.tsx b/apps/electron-app/src/render/components/react-flow/ReactFlowCanvas.tsx index 88c7764..3ba2fa6 100644 --- a/apps/electron-app/src/render/components/react-flow/ReactFlowCanvas.tsx +++ b/apps/electron-app/src/render/components/react-flow/ReactFlowCanvas.tsx @@ -1,9 +1,9 @@ -import { useAutoAnimate } from '@ui/index'; import { Background, Controls, MiniMap, Panel, ReactFlow } from '@xyflow/react'; import { useShallow } from 'zustand/react/shallow'; import { NODE_TYPES } from '../../../common/nodes'; import { AppState, useReactFlowStore } from '../../stores/react-flow'; import { SerialConnectionStatusPanel } from './panels/SerialConnectionStatusPanel'; +import { SettingsPanel } from './panels/SettingsPanel'; const selector = (state: AppState) => ({ nodes: state.nodes, @@ -15,9 +15,6 @@ const selector = (state: AppState) => ({ export function ReactFlowCanvas() { const store = useReactFlowStore(useShallow(selector)); - const [animationRef] = useAutoAnimate({ - duration: 100, - }); return ( @@ -51,9 +47,7 @@ export function ReactFlowCanvas() { -
- {/* Filled by settings */} -
+
); diff --git a/apps/electron-app/src/render/components/react-flow/nodes/Node.tsx b/apps/electron-app/src/render/components/react-flow/nodes/Node.tsx index 3cb7468..4ddaa19 100644 --- a/apps/electron-app/src/render/components/react-flow/nodes/Node.tsx +++ b/apps/electron-app/src/render/components/react-flow/nodes/Node.tsx @@ -96,7 +96,7 @@ function NodeHeader(props: { error?: string; selected?: boolean }) { )} - {id} + {id} @@ -167,7 +167,6 @@ function NodeSettingsPane>( const updateNode = useUpdateNode(id); const ref = useRef(null); - const autoUpdate = useRef(null); const settings = useRef(data); const handlesToDelete = useRef([]); @@ -192,10 +191,7 @@ function NodeSettingsPane>( pane.on('change', event => { if (!event.last) return; - autoUpdate.current && clearTimeout(autoUpdate.current); - autoUpdate.current = setTimeout(() => { - saveSettings(); - }, 1_000); + saveSettings(); }); pane.registerPlugin(TweakpaneEssentialPlugin); diff --git a/apps/electron-app/src/render/components/react-flow/panels/SettingsPanel.tsx b/apps/electron-app/src/render/components/react-flow/panels/SettingsPanel.tsx new file mode 100644 index 0000000..a1cf40b --- /dev/null +++ b/apps/electron-app/src/render/components/react-flow/panels/SettingsPanel.tsx @@ -0,0 +1,13 @@ +import { useAutoAnimate } from '@ui/index'; + +export function SettingsPanel() { + const [animationRef] = useAutoAnimate({ + duration: 100, + }); + + return ( +
+ {/* Filled by settings */} +
+ ); +} diff --git a/apps/electron-app/src/render/providers/NewNodeProvider.tsx b/apps/electron-app/src/render/providers/NewNodeProvider.tsx index f855efa..2f2332a 100644 --- a/apps/electron-app/src/render/providers/NewNodeProvider.tsx +++ b/apps/electron-app/src/render/providers/NewNodeProvider.tsx @@ -13,16 +13,32 @@ import { DialogTitle, } from '@microflow/ui'; import { useReactFlow } from '@xyflow/react'; -import { memo, useEffect } from 'react'; +import { memo, useEffect, useMemo } from 'react'; import { DEFAULT_NODE_DATA, NodeType } from '../../common/nodes'; -import { useTempNode } from '../stores/react-flow'; +import { useDeleteSelectedNodes, useNodesChange } from '../stores/react-flow'; import { useNewNodeStore } from '../stores/new-node'; -import { useShallow } from 'zustand/react/shallow'; +import { useWindowSize } from 'usehooks-ts'; + +const NODE_SIZE = { + width: 208, + height: 176, +}; export const NewNodeCommandDialog = memo(function NewNodeCommandDialog() { useDraggableNewNode(); + useBackspaceOverwrite(); + const { open, setOpen, setNodeToAdd } = useNewNodeStore(); - const { addNode } = useTempNode(); + const { flowToScreenPosition } = useReactFlow(); + const changeNodes = useNodesChange(); + const windowSize = useWindowSize(); + + const position = useMemo(() => { + return flowToScreenPosition({ + x: windowSize.width / 2 - NODE_SIZE.width / 2, + y: windowSize.height / 2 - NODE_SIZE.height / 2, + }); + }, [flowToScreenPosition, windowSize]); function selectNode(type: NodeType, options?: { label?: string; subType?: string }) { return function () { @@ -36,13 +52,12 @@ export const NewNodeCommandDialog = memo(function NewNodeCommandDialog() { }, id, type, - position: { x: 0, y: 0 }, + position, selected: true, }; - addNode(newNode); + changeNodes([{ item: newNode, type: 'add' }]); setNodeToAdd(id); - setOpen(false); }; } @@ -218,54 +233,54 @@ export const NewNodeCommandDialog = memo(function NewNodeCommandDialog() { }); function useDraggableNewNode() { - const { nodeToAdd, setNodeToAdd } = useNewNodeStore( - useShallow(state => ({ nodeToAdd: state.nodeToAdd, setNodeToAdd: state.setNodeToAdd })), - ); - const { screenToFlowPosition, updateNode } = useReactFlow(); - const { addNode, deleteNode } = useTempNode(); + const { nodeToAdd, setNodeToAdd } = useNewNodeStore(); + const { screenToFlowPosition, getZoom } = useReactFlow(); + const changeNodes = useNodesChange(); useEffect(() => { if (!nodeToAdd) return; function handleKeyDown(event: KeyboardEvent) { if (!nodeToAdd) return; - if (event.key === 'Escape' || event.key === 'Backspace') { - setNodeToAdd(null); - deleteNode(nodeToAdd); - } - if (event.key === 'Enter') { - const element = event.target as HTMLElement; - if (element !== document.body) return; + switch (event.key) { + case 'Backspace': + case 'Escape': + changeNodes([{ id: nodeToAdd, type: 'remove' }]); + setNodeToAdd(null); + break; + case 'Enter': + const element = event.target as HTMLElement; + if (element !== document.body) return; - setNodeToAdd(null); - updateNode(nodeToAdd, { selected: false }); + changeNodes([{ id: nodeToAdd, type: 'select', selected: false }]); + setNodeToAdd(null); + break; } } function handleMouseDown(event: MouseEvent) { if (!nodeToAdd) return; - updateNode(nodeToAdd, { - position: screenToFlowPosition({ - x: event.clientX - 120, - y: event.clientY - 75, - }), - }); const element = event.target as HTMLElement; if (!element.closest('.react-flow__node')) return; + changeNodes([{ id: nodeToAdd, type: 'select', selected: false }]); setNodeToAdd(null); - updateNode(nodeToAdd, { selected: false }); } function handleMouseMove(event: MouseEvent) { if (!nodeToAdd) return; - updateNode(nodeToAdd, { - position: screenToFlowPosition({ - x: event.clientX - 120, - y: event.clientY - 75, - }), - }); + const zoom = getZoom(); + changeNodes([ + { + id: nodeToAdd, + type: 'position', + position: screenToFlowPosition({ + x: event.clientX - (NODE_SIZE.width / 2) * zoom, + y: event.clientY - (NODE_SIZE.height / 2) * zoom, + }), + }, + ]); } document.addEventListener('keydown', handleKeyDown); @@ -279,7 +294,25 @@ function useDraggableNewNode() { document.removeEventListener('mousedown', handleMouseDown); document.removeEventListener('click', handleMouseDown); }; - }, [nodeToAdd, addNode, deleteNode]); + }, [nodeToAdd, getZoom, changeNodes]); return null; } + +// https://github.com/xyflow/xyflow/issues/4761 +export function useBackspaceOverwrite() { + const deleteSelectedNodes = useDeleteSelectedNodes(); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.code === 'Backspace') { + deleteSelectedNodes(); + } + }; + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [deleteSelectedNodes]); +} diff --git a/apps/electron-app/src/render/stores/new-node.ts b/apps/electron-app/src/render/stores/new-node.ts index f40d9f0..9a748b8 100644 --- a/apps/electron-app/src/render/stores/new-node.ts +++ b/apps/electron-app/src/render/stores/new-node.ts @@ -15,5 +15,7 @@ export const useNewNodeStore = create((set, get) => ({ nodeToAdd: null as string | null, setNodeToAdd: (nodeId: string | null) => { set({ nodeToAdd: nodeId }); + + if (nodeId) set({ open: false }); }, })); diff --git a/apps/electron-app/src/render/stores/react-flow.ts b/apps/electron-app/src/render/stores/react-flow.ts index cddfd06..4e0081c 100644 --- a/apps/electron-app/src/render/stores/react-flow.ts +++ b/apps/electron-app/src/render/stores/react-flow.ts @@ -14,7 +14,7 @@ import { LinkedList } from '../../common/LinkedList'; import { INTRODUCTION_EDGES, INTRODUCTION_NODES } from './introduction'; import { useShallow } from 'zustand/react/shallow'; -const HISTORY_DEBOUNCE_TIME_IN_MS = 1000; +const HISTORY_DEBOUNCE_TIME_IN_MS = 100; export type AppState = {}> = { nodes: Node[]; @@ -25,8 +25,7 @@ export type AppState = {}> = { setNodes: (nodes: Node[]) => void; setEdges: (edges: Edge[]) => void; deleteEdges: (nodeId: string, handles?: string[]) => void; - addNode: (node: Node) => void; - deleteNode: (nodeId: string) => void; + deleteSelectedNodes: () => void; history: LinkedList<{ nodes: Node[]; edges: Edge[] }>; undo: () => void; redo: () => void; @@ -61,9 +60,7 @@ export const useReactFlowStore = create((set, get) => { const initialEdges = hasSeenIntroduction ? localEdges : INTRODUCTION_EDGES; let historyUpdateDebounce: NodeJS.Timeout | undefined; - function updateHistory(update: Partial>) { - set(update); - + function updateHistory() { historyUpdateDebounce && clearTimeout(historyUpdateDebounce); historyUpdateDebounce = setTimeout(() => { const { nodes, edges, history } = get(); @@ -76,19 +73,28 @@ export const useReactFlowStore = create((set, get) => { edges: initialEdges, history: new LinkedList({ nodes: initialNodes, edges: initialEdges }), onNodesChange: changes => { - updateHistory({ nodes: applyNodeChanges(changes, get().nodes) }); + set({ + nodes: applyNodeChanges(changes, get().nodes), + }); + + updateHistory(); }, onEdgesChange: changes => { - updateHistory({ edges: applyEdgeChanges(changes, get().edges) }); + set({ + edges: applyEdgeChanges(changes, get().edges), + }); + updateHistory(); }, onConnect: connection => { - updateHistory({ edges: addEdge(connection, get().edges) }); + set({ + edges: addEdge(connection, get().edges), + }); }, setNodes: nodes => { - updateHistory({ nodes }); + set({ nodes }); }, setEdges: edges => { - updateHistory({ edges }); + set({ edges }); }, deleteEdges: (nodeId, handles = []) => { if (!handles.length) return; @@ -104,19 +110,12 @@ export const useReactFlowStore = create((set, get) => { return false; }); - updateHistory({ edges }); - }, - addNode: node => { - if (!node.data) node.data = {}; - updateHistory({ nodes: [...get().nodes, node] }); + set({ edges }); }, - deleteNode: nodeId => { - const nodes = get().nodes.filter(node => node.id !== nodeId); + deleteSelectedNodes: () => { + const nodes = get().nodes.filter(node => !node.selected); set({ nodes }); - - const edges = get().edges.filter(edge => edge.source !== nodeId && edge.target !== nodeId); - updateHistory({ edges }); }, undo: () => { const history = get().history; @@ -146,13 +145,8 @@ export function useNodeAndEdgeCount() { ); } -export function useTempNode() { - return useReactFlowStore( - useShallow(state => ({ - addNode: state.addNode, - deleteNode: state.deleteNode, - })), - ); +export function useNodesChange() { + return useReactFlowStore(useShallow(state => state.onNodesChange)); } export function useDeleteEdges() { @@ -162,3 +156,7 @@ export function useDeleteEdges() { export function useEdges() { return useReactFlowStore(useShallow(state => state.edges)); } + +export function useDeleteSelectedNodes() { + return useReactFlowStore(useShallow(state => state.deleteSelectedNodes)); +} diff --git a/apps/nextjs-app/app/docs/contributing/nodes/page.md b/apps/nextjs-app/app/docs/contributing/nodes/page.md index 960226f..01000cb 100644 --- a/apps/nextjs-app/app/docs/contributing/nodes/page.md +++ b/apps/nextjs-app/app/docs/contributing/nodes/page.md @@ -4,12 +4,11 @@ title: How to add your own node Adding your own node requires a bit of boilerplate and manual work at the moment. - ## Step 1: creating your own component type Let's say you want to create your own node type called `MyNode`. First you need to create a file with it's own class in `packages/components/src/YourNode.ts` your type must extend the BaseComponent class. Here's an example declaration: -``` +```ts export type MyNodeValueType = number; export class MyNode extends BaseComponent {} @@ -17,36 +16,33 @@ export class MyNode extends BaseComponent {} Let's now say that your node type will have a configuration panel where you can change some attributes, for now let's say the attributes are a drop-down that let's you choose between `happy` and `sad`, and a numeric value we call `joy`. To be able to contain the values of these attributes you will need a data type associated to your node. -``` +```ts export type EmotionType = 'happy' | 'sad'; export type MyNodeData = { emotion: EmotionType; joy: number; }; - -type MyNodeOptions = BaseComponentOptions & MyNodeData; ``` Add a constructor to your type that takes the attributes and passes them to the superclass. Like this: -``` - constructor(private readonly options: MyNodeOptions) { - super(options, 0); - } +```ts +constructor(private readonly data: BaseComponentData & MyNodeData) { + super(data, 0); +} ``` ## Step 2: expose your new type in the components packages and refresh build - Include your newly created component in the `index.ts` file in `packages/components`. This will make your new components available in the `@microflow/components` package, so that they can be used later in the electron app. -- run `yarn build` in the `microflow/packages/components` directory, you need to do this before you run yarn at the `app` level directories ## Step 3: create a react wrapper in the electron app - Create a reactflow wrapper for your node type in `apps/electron-app/src/common/render/componenets/react-flow/nodes/YourNode.tsx` - Implement here your JSX -``` +```tsx export function MyNode(props: Props) { return ( @@ -59,9 +55,22 @@ export function MyNode(props: Props) { } ``` -- Your config panel (@TODO link to documentation) +- Show how much `joy` you are in right now + +```tsx +function Value() { + const value = useNodeValue(0) // Acces the nodes' internal value + const data = useNodeData() // Access the node data + return { +
{value} / {data.joy}
+ } +} ``` + +- Give the user freedom to configure the node + +```tsx function Settings() { const { pane, settings } = useNodeSettingsPane(); @@ -90,10 +99,10 @@ function Settings() { } ``` -- And your panel setting defaults. +- And your panel data defaults. -``` -type Props = BaseNode; +```ts +type Props = BaseNode; export const DEFAULT_MYNODE_DATA: Props['data'] = { label: 'MyNode', emotion: 'happy', @@ -103,31 +112,34 @@ export const DEFAULT_MYNODE_DATA: Props['data'] = { - Add a reference in `apps/electron-app/src/common/nodes.ts` -``` +```ts import { DEFAULT_MYNODE_DATA, MyNode } from '../render/components/react-flow/nodes/MyNode'; ``` Add the correct entry to the `NODE_TYPES` list: -``` + +```ts export const NODE_TYPES = { ... MyNode: MyNode, ... -};``` +}; +``` -And last but not least i that same file, add an entry that specifies the default attribute values for the node: +And last but not least in that same file, add an entry that specifies the default attribute values for the node: -``` +```ts DEFAULT_MYNODE_DATA.set('MyNode', DEFAULT_MYNODE_DATA); ``` - Add some JSX in `apps/electron-app/src/render/NewNodeProvider.tsx` so that it appears in the search menu. -``` - - MyNode - - Custom - - +```tsx + + MyNode + + custom + {/* ... */} + + ``` diff --git a/apps/nextjs-app/lib/navigation.ts b/apps/nextjs-app/lib/navigation.ts index 3fe6251..848f45a 100644 --- a/apps/nextjs-app/lib/navigation.ts +++ b/apps/nextjs-app/lib/navigation.ts @@ -101,6 +101,9 @@ export const navigation = [ }, { title: 'Community', - links: [{ title: 'How to contribute', href: '/docs/contributing/how-to' }], + links: [ + { title: 'How to contribute', href: '/docs/contributing/how-to' }, + { title: 'Add your own node', href: '/docs/contributing/nodes' }, + ], }, ]; diff --git a/yarn.lock b/yarn.lock index 9a64136..ac6b2f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5340,23 +5340,23 @@ __metadata: languageName: node linkType: hard -"@xyflow/react@npm:12.3.1": - version: 12.3.1 - resolution: "@xyflow/react@npm:12.3.1" +"@xyflow/react@npm:12.3.6": + version: 12.3.6 + resolution: "@xyflow/react@npm:12.3.6" dependencies: - "@xyflow/system": 0.0.43 + "@xyflow/system": 0.0.47 classcat: ^5.0.3 zustand: ^4.4.0 peerDependencies: react: ">=17" react-dom: ">=17" - checksum: 01f5369c64db0f2c11c27339b5a79d83a2246f0812b466a23b905a96297c1578a48e044a3d5a37cad506ba752eaf9d9eee93dcc6bbff93e9bbd0b282f133cea4 + checksum: c8f86c900502d4b4cb74cdb525a2b25730d54ca63792154127aaf7e075587a0f016121396e7c6185f4872af5585c0e6e515a2ce506e3b70822eb2a0b48c7b266 languageName: node linkType: hard -"@xyflow/system@npm:0.0.43": - version: 0.0.43 - resolution: "@xyflow/system@npm:0.0.43" +"@xyflow/system@npm:0.0.47": + version: 0.0.47 + resolution: "@xyflow/system@npm:0.0.47" dependencies: "@types/d3-drag": ^3.0.7 "@types/d3-selection": ^3.0.10 @@ -5365,7 +5365,7 @@ __metadata: d3-drag: ^3.0.0 d3-selection: ^3.0.0 d3-zoom: ^3.0.0 - checksum: 25effd99488ad9f22b7baef2306bc59ddb1906c51655edce3b1b4bb2fdc034f404009ecb022ef1eaa1f40191ba43da11416de93786d5ec8b83d2c34b945526f9 + checksum: 2f1788bce46610055de857f8743e794394dcc53ffd6e0943bf6e7718fb6334ca29dffd0e57e7c8de0b1eaaa3e63e082cbdebc3caefaf506a4e8f2971a40f6d88 languageName: node linkType: hard @@ -11057,7 +11057,7 @@ __metadata: "@types/npmcli__arborist": 5.6.11 "@types/react": 18.3.11 "@types/react-dom": 18.3.0 - "@xyflow/react": 12.3.1 + "@xyflow/react": 12.3.6 abcjs: 6.4.3 autoprefixer: 10.4.20 dotenv: 16.4.5