From 7f0a0cda3e1dbeed823221fafcdc3a449b4be8e1 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Thu, 12 Dec 2024 12:24:18 +0000 Subject: [PATCH 1/9] graph: move reactflow instance to global state --- src/actions/editor.ts | 35 +++++++++++++++++++++++++++++++++++ src/pages/index.tsx | 15 +++++++++++++-- src/reducers/editor.ts | 28 ++++++++++++++++++++++++++++ src/reducers/index.ts | 2 ++ 4 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 src/actions/editor.ts create mode 100644 src/reducers/editor.ts diff --git a/src/actions/editor.ts b/src/actions/editor.ts new file mode 100644 index 00000000..544476c0 --- /dev/null +++ b/src/actions/editor.ts @@ -0,0 +1,35 @@ +import { ReactFlowInstance } from "reactflow"; +import { ActionBase } from "../lib/store"; + +export enum EditorActionType { + INIT = "EDITOR_INIT", + UNMOUNT = "EDITOR_UNMOUNT" +} + +export interface IInitEditor extends ActionBase { + type: EditorActionType.INIT; + payload: { + instance: ReactFlowInstance; + }; +} + +export interface IUnmountEditor extends ActionBase { + type: EditorActionType.UNMOUNT; + payload: {}; +} + +export type EditorAction = IInitEditor | IUnmountEditor; + +export const initEditor = (instance: ReactFlowInstance): IInitEditor => { + return { + type: EditorActionType.INIT, + payload: { instance } + }; +}; + +export const unmountEditor = (): IUnmountEditor => { + return { + type: EditorActionType.UNMOUNT, + payload: {} + }; +}; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 62fb2a70..bbbff5d9 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,12 +1,12 @@ import { Button, Group, Stack, Text, Tooltip } from "@mantine/core"; -import { FunctionComponent, useCallback } from "react"; +import { FunctionComponent, useCallback, useEffect } from "react"; import { useAppDispatch, useAppSelector } from "../hooks/useAppDispatch"; import { RootStateType } from "../lib/store"; import { getPatchersSortedByName } from "../selectors/patchers"; import { getConnections, getNodes } from "../selectors/graph"; import GraphEditor from "../components/editor"; import PresetDrawer from "../components/presets"; -import { Connection, Edge, EdgeChange, Node, NodeChange } from "reactflow"; +import { Connection, Edge, EdgeChange, Node, NodeChange, ReactFlowInstance } from "reactflow"; import { applyEditorEdgeChanges, applyEditorNodeChanges, createEditorConnection, removeEditorConnectionsById, removeEditorNodesById, @@ -26,6 +26,7 @@ import { modals } from "@mantine/modals"; import { IconElement } from "../components/elements/icon"; import { mdiCamera, mdiFileExport, mdiGroup } from "@mdi/js"; import { ResponsiveButton } from "../components/elements/responsiveButton"; +import { initEditor, unmountEditor } from "../actions/editor"; const Index: FunctionComponent> = () => { @@ -54,6 +55,11 @@ const Index: FunctionComponent> = () => { closePatcherDrawer(); }, [dispatch, closePatcherDrawer]); + // Editor + const onEditorInit = useCallback((instance: ReactFlowInstance) => { + dispatch(initEditor(instance)); + }, [dispatch]); + // Nodes const onConnectNodes = useCallback((connection: Connection) => { dispatch(createEditorConnection(connection)); @@ -145,6 +151,10 @@ const Index: FunctionComponent> = () => { dispatch(renamePatcherOnRemote(p, name)); }, [dispatch]); + useEffect(() => { + return () => dispatch(unmountEditor()); + }, [dispatch]); + return ( <> @@ -177,6 +187,7 @@ const Index: FunctionComponent> = () => { onNodesDelete={ onNodesDelete } onEdgesChange={ onEdgesChange } onEdgesDelete={ onEdgesDelete } + onInit={ onEditorInit } /> { + switch (action.type) { + + case EditorActionType.INIT: { + return { + ...state, + instance: action.payload.instance + }; + } + + case EditorActionType.UNMOUNT: { + return { + ...state, + instance: undefined + }; + } + + default: + return state; + } +}; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index c24773ab..4328d1c3 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -2,6 +2,7 @@ import { combineReducers } from "redux"; import { appStatus } from "./appStatus"; import { datafiles } from "./datafiles"; +import { editor } from "./editor"; import { instances } from "./instances"; import { graph } from "./graph"; import { nofitications } from "./notifications"; @@ -13,6 +14,7 @@ import { transport } from "./transport"; export const rootReducer = combineReducers({ appStatus, datafiles, + editor, instances, graph, nofitications, From c5592260b2fa68865f9dafa344d7954c20d65294 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Thu, 12 Dec 2024 12:46:02 +0000 Subject: [PATCH 2/9] graph: state determined node height and width --- src/actions/graph.ts | 14 +++-- src/components/editor/controlNode.tsx | 6 +-- src/components/editor/index.tsx | 11 ++-- src/components/editor/patcherNode.tsx | 6 +-- src/components/editor/systemNode.tsx | 9 +--- src/components/editor/util.ts | 3 -- src/lib/constants.ts | 5 ++ src/models/graph.ts | 75 +++++++++++++-------------- 8 files changed, 64 insertions(+), 65 deletions(-) diff --git a/src/actions/graph.ts b/src/actions/graph.ts index 30255838..34dbf8b5 100644 --- a/src/actions/graph.ts +++ b/src/actions/graph.ts @@ -15,6 +15,7 @@ import { Connection, EdgeChange, NodeChange } from "reactflow"; import { isValidConnection } from "../lib/editorUtils"; import throttle from "lodash.throttle"; import { getPatchers } from "../selectors/patchers"; +import { nodeDefaultWidth, nodeHeaderHeight } from "../lib/constants"; const defaultNodeSpacing = 150; const getPatcherOrControlNodeCoordinates = (node: GraphPatcherNodeRecord | GraphControlNodeRecord, nodes: GraphNodeRecord[]): { x: number, y: number } => { @@ -580,10 +581,12 @@ export const updateSystemOrControlPortInfo = (type: ConnectionType, direction: P direction, id: `${jackName}${direction === PortDirection.Source ? GraphSystemNodeRecord.inputSuffix : GraphSystemNodeRecord.outputSuffix}`, ports, - contentHeight, selected: false, x: 0, - y: 0 + y: 0, + width: nodeDefaultWidth, + height: contentHeight + nodeHeaderHeight + }); if (direction === PortDirection.Source) { @@ -605,10 +608,11 @@ export const updateSystemOrControlPortInfo = (type: ConnectionType, direction: P node = new GraphControlNodeRecord({ jackName, ports, - contentHeight, selected: false, x: 0, - y: 0 + y: 0, + width: nodeDefaultWidth, + height: contentHeight + nodeHeaderHeight }); const { x, y } = getPatcherOrControlNodeCoordinates(node, [...patcherNodes, ...controlNodes]); node = node.updatePosition(x, y); @@ -902,7 +906,7 @@ export const updateSourcePortConnections = (source: string, sinks: string[]): Ap }; export const addPatcherNode = (desc: OSCQueryRNBOInstance, metaString: string): AppThunk => - (dispatch) => { + (dispatch, getState) => { // Create Node let node = GraphPatcherNodeRecord.fromDescription(desc); const setMeta: OSCQuerySetMeta = deserializeSetMeta(metaString); diff --git a/src/components/editor/controlNode.tsx b/src/components/editor/controlNode.tsx index 793627c5..f3aa28bd 100644 --- a/src/components/editor/controlNode.tsx +++ b/src/components/editor/controlNode.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent, memo } from "react"; -import { EditorNodeProps, calcPortOffset, defaultNodeWidth } from "./util"; +import { EditorNodeProps, calcPortOffset } from "./util"; import { GraphPortRecord, PortDirection } from "../../models/graph"; import EditorPort from "./port"; import classes from "./editor.module.css"; @@ -16,14 +16,14 @@ const EditorControlNode: FunctionComponent = memo(function Wrap return result; }, { sinks: [], sources: [] } as { sinks: GraphPortRecord[]; sources: GraphPortRecord[]; }); - const portSizeLimit = sinks.length && sources.length ? Math.round(defaultNodeWidth / 2) : defaultNodeWidth; + const portSizeLimit = sinks.length && sources.length ? Math.round(node.width / 2) : node.width; return (
{ node.id }
-
+
{ sinks.map((port, i) => ( void; onConnect: (connection: Connection) => any; onNodesDelete: (nodes: Pick[]) => void; onNodesChange: (changes: NodeChange[]) => void; @@ -37,6 +38,7 @@ const edgeTypes: Record const GraphEditor: FunctionComponent = memo(function WrappedFlowGraph({ connections, nodes, + onInit, onConnect, onNodesChange, onNodesDelete, @@ -74,12 +76,10 @@ const GraphEditor: FunctionComponent = memo(function WrappedFl deletable: node.type === NodeType.Patcher, selected: node.selected, type: node?.type, - data: { - node - } + data: { node }, + style: { height: node.height, width: node.width } })); - const flowEdges: Edge[] = connections.valueSeq().toArray().map(connection => ({ id: connection.id, source: connection.sourceNodeId, @@ -109,6 +109,7 @@ const GraphEditor: FunctionComponent = memo(function WrappedFl edgeTypes={ edgeTypes } edgesUpdatable={ false } fitView + onInit={ onInit } minZoom={ 0.1 } maxZoom={ 5 } > diff --git a/src/components/editor/patcherNode.tsx b/src/components/editor/patcherNode.tsx index 54d29461..bc911c69 100644 --- a/src/components/editor/patcherNode.tsx +++ b/src/components/editor/patcherNode.tsx @@ -1,5 +1,5 @@ import React, { FunctionComponent, memo } from "react"; -import { EditorNodeProps, calcPortOffset, defaultNodeWidth } from "./util"; +import { EditorNodeProps, calcPortOffset } from "./util"; import { GraphPatcherNodeRecord, GraphPortRecord, PortDirection } from "../../models/graph"; import EditorPort from "./port"; import classes from "./editor.module.css"; @@ -21,7 +21,7 @@ const EditorPatcherNode: FunctionComponent = memo(function Wrap return result; }, { sinks: [], sources: [] } as { sinks: GraphPortRecord[]; sources: GraphPortRecord[]; }); - const portSizeLimit = sinks.length && sources.length ? Math.round(defaultNodeWidth / 2) : defaultNodeWidth; + const portSizeLimit = sinks.length && sources.length ? Math.round(node.width / 2) : node.width; return ( @@ -42,7 +42,7 @@ const EditorPatcherNode: FunctionComponent = memo(function Wrap
-
+
{ sinks.map((port, i) => ( = memo(function Wrapp }, { sinks: [], sources: [] } as { sinks: GraphPortRecord[]; sources: GraphPortRecord[]; }); const aliases = useAppSelector((state: RootStateType) => getPortAliasesForNode(state, node)); - const longestAliasCharCount = aliases.valueSeq().reduce((result, alias) => { - return alias.length > result ? alias.length : result; - }, 0); - - const width = 300 + Math.max(0, (longestAliasCharCount - 10) * 5); - const portSizeLimit = sinks.length && sources.length ? Math.round(width / 2) : width; + const portSizeLimit = sinks.length && sources.length ? Math.round(node.width / 2) : node.width; return (
{ node.jackName }
-
+
{ sinks.map((port, i) => ( ; export type EditorEdgeProps = EdgeProps; - export const calcPortOffset = (total: number, index: number): number => { return (index + 1) * (1 / (total + 1)) * 100; }; - -export const defaultNodeWidth = 300; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a941167e..9f9f088b 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -24,6 +24,11 @@ export enum InstanceTab { export const bodyFontSize = 16; +export const nodeDefaultWidth = 435; +export const nodeHeaderHeight = 50; +export const nodePortSpacing = 30; +export const nodePortHeight = 20; + export enum Breakpoints { xs = 36 * 16, sm = 48 * 16, diff --git a/src/models/graph.ts b/src/models/graph.ts index 493d2f57..60121926 100644 --- a/src/models/graph.ts +++ b/src/models/graph.ts @@ -1,6 +1,7 @@ import { Record as ImmuRecord, Map as ImmuMap, Set as ImmuSet } from "immutable"; import { OSCQueryRNBOInstance, OSCQueryRNBOJackPortInfo } from "../lib/types"; +import { nodeDefaultWidth, nodeHeaderHeight, nodePortHeight, nodePortSpacing } from "../lib/constants"; export enum ConnectionType { Audio = "audio", @@ -34,11 +35,6 @@ export class GraphPortRecord extends ImmuRecord ({ }) {} -const headerHeight = 50; -const portHeight = 20; -const portSpacing = 30; -const nodeWidth = 435; - export const calculateNodeContentHeight = (ports: ImmuMap): number => { const { sinkCount, sourceCount } = ports.valueSeq().reduce((result, port) => { if (port.direction === PortDirection.Sink) { @@ -49,16 +45,17 @@ export const calculateNodeContentHeight = (ports: ImmuMap sourceCount ? sinkCount : sourceCount) * (portHeight + portSpacing); + return (sinkCount > sourceCount ? sinkCount : sourceCount) * (nodePortHeight + nodePortSpacing); }; export type CommonGraphNodeProps = { jackName: string; ports: ImmuMap; - contentHeight: number; selected: boolean; x: number; y: number; + width: number; + height: number; } export type GraphSystemNodeProps = CommonGraphNodeProps & { @@ -76,6 +73,7 @@ export type GraphControlNodeProps = CommonGraphNodeProps; export interface GraphNode extends CommonGraphNodeProps { id: string; + contentHeight: number; getPort: (name: GraphPortRecord["id"]) => GraphPortRecord | undefined; type: NodeType; } @@ -101,10 +99,11 @@ export class GraphPatcherNodeRecord extends ImmuRecord({ ports: ImmuMap(), // Editor props - contentHeight: 0, selected: false, + x: 0, y: 0, - x: 0 + height: 0, + width: nodeDefaultWidth }) implements GraphPatcherNode { @@ -121,12 +120,12 @@ export class GraphPatcherNodeRecord extends ImmuRecord({ return NodeType.Patcher; } - public get height(): number { - return this.contentHeight + headerHeight; + public get contentHeight(): number { + return this.height - nodeHeaderHeight; } - public get width(): number { - return nodeWidth; + public updateDimensions(width: number, height: number): GraphPatcherNodeRecord { + return this.withMutations(record => record.set("width", width).set("height", height)); } public updatePosition(x: number, y: number): GraphPatcherNodeRecord { @@ -217,10 +216,11 @@ export class GraphPatcherNodeRecord extends ImmuRecord({ patcher: desc.CONTENTS.name.VALUE, path: desc.FULL_PATH, ports, - contentHeight: calculateNodeContentHeight(ports), selected: false, x: 0, - y: 0 + y: 0, + height: calculateNodeContentHeight(ports) + nodeHeaderHeight, + width: nodeDefaultWidth }); } } @@ -250,10 +250,11 @@ export class GraphSystemNodeRecord extends ImmuRecord({ ports: ImmuMap(), // Editor props - contentHeight: 0, selected: false, + x: 0, y: 0, - x: 0 + height: 0, + width: nodeDefaultWidth }) implements GraphSystemNode { @@ -265,12 +266,8 @@ export class GraphSystemNodeRecord extends ImmuRecord({ return NodeType.System; } - public get height(): number { - return this.contentHeight + headerHeight; - } - - public get width(): number { - return nodeWidth; + public get contentHeight(): number { + return this.height - nodeHeaderHeight; } public updatePosition(x: number, y: number): GraphSystemNodeRecord { @@ -290,7 +287,7 @@ export class GraphSystemNodeRecord extends ImmuRecord({ return this .set("ports", portList) - .set("contentHeight", calculateNodeContentHeight(portList)); + .set("height", calculateNodeContentHeight(portList) + nodeHeaderHeight); } public select(): GraphSystemNodeRecord { @@ -360,10 +357,11 @@ export class GraphSystemNodeRecord extends ImmuRecord({ direction: PortDirection.Source, id: `${jackName}${this.inputSuffix}`, ports, - contentHeight: calculateNodeContentHeight(ports), selected: false, x: 0, - y: 0 + y: 0, + height: calculateNodeContentHeight(ports) + nodeHeaderHeight, + width: nodeDefaultWidth }) ); } @@ -376,10 +374,11 @@ export class GraphSystemNodeRecord extends ImmuRecord({ direction: PortDirection.Sink, id: `${jackName}${this.outputSuffix}`, ports, - contentHeight: calculateNodeContentHeight(ports), selected: false, x: 0, - y: 0 + y: 0, + height: calculateNodeContentHeight(ports) + nodeHeaderHeight, + width: nodeDefaultWidth }) ); } @@ -395,10 +394,11 @@ export class GraphControlNodeRecord extends ImmuRecord({ ports: ImmuMap(), // Editor props - contentHeight: 0, selected: false, y: 0, - x: 0 + x: 0, + width: nodeDefaultWidth, + height: 0 }) implements GraphControlNode { @@ -415,12 +415,8 @@ export class GraphControlNodeRecord extends ImmuRecord({ return NodeType.Control; } - public get height(): number { - return this.contentHeight + headerHeight; - } - - public get width(): number { - return nodeWidth; + public get contentHeight(): number { + return this.height - nodeHeaderHeight; } public setPortsByType(type: ConnectionType, direction: PortDirection, newPorts: GraphPortRecord[]): GraphControlNodeRecord { @@ -436,7 +432,7 @@ export class GraphControlNodeRecord extends ImmuRecord({ return this .set("ports", portList) - .set("contentHeight", calculateNodeContentHeight(portList)); + .set("height", calculateNodeContentHeight(portList) + nodeHeaderHeight); } public updatePosition(x: number, y: number): GraphControlNodeRecord { @@ -467,10 +463,11 @@ export class GraphControlNodeRecord extends ImmuRecord({ return new GraphControlNodeRecord({ jackName, ports, - contentHeight: calculateNodeContentHeight(ports), selected: false, x: 0, - y: 0 + y: 0, + height: calculateNodeContentHeight(ports) + nodeHeaderHeight, + width: nodeDefaultWidth }); } } From c1483be77f714f5c4012142424fe9540c13f7365 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Thu, 12 Dec 2024 12:46:53 +0000 Subject: [PATCH 3/9] graph: place new nodes more sensible in top left corner of active view --- src/actions/graph.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/actions/graph.ts b/src/actions/graph.ts index 34dbf8b5..82ef2912 100644 --- a/src/actions/graph.ts +++ b/src/actions/graph.ts @@ -912,9 +912,13 @@ export const addPatcherNode = (desc: OSCQueryRNBOInstance, metaString: string): const setMeta: OSCQuerySetMeta = deserializeSetMeta(metaString); const nodeMeta: OSCQuerySetNodeMeta | undefined = setMeta?.nodes?.[node.id]; - const { x, y } = nodeMeta?.position || getPatcherOrControlNodeCoordinates(node, []); - node = node.updatePosition(x, y); + const state = getState(); + const { x, y } = nodeMeta?.position || state.editor.instance?.project({ + y: 10, + x: 10 + }) || getPatcherOrControlNodeCoordinates(node, []); + node = node.updatePosition(x, y); dispatch(setNode(node)); // Create Instance State From 0ad3fc0734218d12569a42454c231e186baee6cb Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Thu, 12 Dec 2024 14:36:29 +0000 Subject: [PATCH 4/9] grapheditor: moved more state into global state in preparation for fully controlled graph editor with viewport control --- src/actions/editor.ts | 302 +++++++++++++++++++++++- src/actions/graph.ts | 246 +------------------ src/components/editor/editor.module.css | 16 +- src/components/editor/index.tsx | 68 +++++- src/lib/constants.ts | 3 + src/pages/index.tsx | 39 ++- src/reducers/editor.ts | 12 +- src/selectors/editor.ts | 5 + 8 files changed, 417 insertions(+), 274 deletions(-) create mode 100644 src/selectors/editor.ts diff --git a/src/actions/editor.ts b/src/actions/editor.ts index 544476c0..ddbeeccc 100644 --- a/src/actions/editor.ts +++ b/src/actions/editor.ts @@ -1,9 +1,22 @@ -import { ReactFlowInstance } from "reactflow"; -import { ActionBase } from "../lib/store"; +import { Connection, EdgeChange, NodeChange, ReactFlowInstance } from "reactflow"; +import { Map as ImmuMap } from "immutable"; +import { ActionBase, AppThunk } from "../lib/store"; +import { getConnection, getConnectionByNodesAndPorts, getNode, getNodes } from "../selectors/graph"; +import { GraphConnectionRecord, GraphNode, GraphNodeRecord, GraphPatcherNode, NodeType } from "../models/graph"; +import { showNotification } from "./notifications"; +import { NotificationLevel } from "../models/notification"; +import { writePacket } from "osc"; +import { oscQueryBridge } from "../controller/oscqueryBridgeController"; +import { isValidConnection } from "../lib/editorUtils"; +import throttle from "lodash.throttle"; +import { OSCQuerySetMeta } from "../lib/types"; +import { setConnection, setNode, unloadPatcherNodeByIndexOnRemote } from "./graph"; +import { getGraphEditorInstance, getGraphEditorLockedState } from "../selectors/editor"; export enum EditorActionType { INIT = "EDITOR_INIT", - UNMOUNT = "EDITOR_UNMOUNT" + UNMOUNT = "EDITOR_UNMOUNT", + SET_LOCKED = "EDITOR_SET_LOCKED" } export interface IInitEditor extends ActionBase { @@ -15,10 +28,17 @@ export interface IInitEditor extends ActionBase { export interface IUnmountEditor extends ActionBase { type: EditorActionType.UNMOUNT; - payload: {}; + payload: Record; } -export type EditorAction = IInitEditor | IUnmountEditor; +export interface ISetEditorLocked extends ActionBase { + type: EditorActionType.SET_LOCKED; + payload: { + locked: boolean; + }; +} + +export type EditorAction = IInitEditor | IUnmountEditor | ISetEditorLocked; export const initEditor = (instance: ReactFlowInstance): IInitEditor => { return { @@ -33,3 +53,275 @@ export const unmountEditor = (): IUnmountEditor => { payload: {} }; }; + + +const serializeSetMeta = (nodes: GraphNodeRecord[]): string => { + const result: OSCQuerySetMeta = { nodes: {} }; + for (const node of nodes) { + result.nodes[node.id] = { position: { x: node.x, y: node.y } }; + } + return JSON.stringify(result); +}; + +const doUpdateNodesMeta = throttle((nodes: ImmuMap) => { + try { + const value = serializeSetMeta(nodes.valueSeq().toArray()); + + const message = { + address: "/rnbo/inst/control/sets/meta", + args: [ + { type: "s", value } + ] + }; + oscQueryBridge.sendPacket(writePacket(message)); + } catch (err) { + console.warn(`Failed to update Set Meta on remote: ${err.message}`); + } + +}, 150, { leading: true, trailing: true }); + +const updateSetMetaOnRemote = (): AppThunk => + (dispatch, getState) => doUpdateNodesMeta(getNodes(getState())); + + +export const createEditorConnection = (connection: Connection): AppThunk => + (dispatch, getState) => { + try { + const state = getState(); + + if (!connection.source || !connection.target || !connection.sourceHandle || !connection.targetHandle) { + throw new Error(`Invalid Connection Description (${connection.source}:${connection.sourceHandle} => ${connection.target}:${connection.targetHandle})`); + } + + // Valid Connection? + const { sourceNode, sourcePort, sinkNode, sinkPort } = isValidConnection(connection, state.graph.nodes); + + // Does it already exist? + const existingConnection = getConnectionByNodesAndPorts( + state, + { + sourceNodeId: sourceNode.id, + sinkNodeId: sinkNode.id, + sourcePortId: sourcePort.id, + sinkPortId: sinkPort.id + } + ); + + if (existingConnection) { + return void dispatch(showNotification({ + title: "Skipped creating connection", + level: NotificationLevel.warn, + message: `A connection between ${connection.source}:${connection.sourceHandle} and ${connection.target}:${connection.targetHandle} already exists` + })); + } + + const message = { + address: "/rnbo/jack/connections/connect", + args: [ + { type: "s", value: `${sourceNode.jackName}:${sourcePort.id}` }, + { type: "s", value: `${sinkNode.jackName}:${sinkPort.id}` } + ] + }; + + oscQueryBridge.sendPacket(writePacket(message)); + } catch (err) { + dispatch(showNotification({ + title: "Failed to create connection", + level: NotificationLevel.error, + message: err.message + })); + console.error(err); + } + }; + +export const removeEditorConnectionById = (id: GraphConnectionRecord["id"]): AppThunk => + (dispatch, getState) => { + + try { + const state = getState(); + const connection = getConnection(state, id); + if (!connection) throw new Error(`Connection with id ${id} does not exist.`); + + const sourceNode = getNode(state, connection.sourceNodeId); + if (!sourceNode) throw new Error(`Node with id ${connection.sourceNodeId} does not exist.`); + + const sourcePort = sourceNode.getPort(connection.sourcePortId); + if (!sourcePort) throw new Error(`Port with id ${connection.sourcePortId} does not exist on node ${sourceNode.id}.`); + + const sinkNode = getNode(state, connection.sinkNodeId); + if (!sinkNode) throw new Error(`Node with id ${connection.sinkNodeId} does not exist.`); + + const sinkPort = sinkNode.getPort(connection.sinkPortId); + if (!sinkPort) throw new Error(`Port with id ${connection.sinkPortId} does not exist on node ${sinkNode.id}.`); + + const message = { + address: "/rnbo/jack/connections/disconnect", + args: [ + { type: "s", value: `${sourceNode.jackName}:${sourcePort.id}` }, + { type: "s", value: `${sinkNode.jackName}:${sinkPort.id}` } + ] + }; + + oscQueryBridge.sendPacket(writePacket(message)); + + } catch (err) { + dispatch(showNotification({ + title: "Failed to delete connection", + level: NotificationLevel.error, + message: err.message + })); + console.error(err); + } + }; + + +export const removeEditorNodeById = (id: GraphNode["id"], updateSetMeta = true): AppThunk => + (dispatch, getState) => { + try { + const state = getState(); + const node = getNode(state, id); + + if (!node) { + throw new Error(`Node with id ${id} does not exist.`); + } + + if (node.type === NodeType.System || node.type === NodeType.Control) { + throw new Error(`System nodes cannot be removed (id: ${id}).`); + } + + dispatch(unloadPatcherNodeByIndexOnRemote(node.index)); + if (updateSetMeta) doUpdateNodesMeta(getNodes(state).delete(node.id)); + + } catch (err) { + dispatch(showNotification({ + title: "Failed to node", + level: NotificationLevel.error, + message: err.message + })); + console.error(err); + } + }; + +export const removeEditorNodesById = (ids: GraphPatcherNode["id"][]): AppThunk => + (dispatch, getState) => { + for (const id of ids) { + dispatch(removeEditorNodeById(id, false)); + } + // Only at the end update the meta to ensure all coord data has been removed + doUpdateNodesMeta(getNodes(getState()).deleteAll(ids)); + }; + +export const removeEditorConnectionsById = (ids: GraphConnectionRecord["id"][]): AppThunk => + (dispatch) => { + for (const id of ids) { + dispatch(removeEditorConnectionById(id)); + } + }; + +export const changeNodePosition = (id: GraphNode["id"], x: number, y: number): AppThunk => + (dispatch, getState) => { + const state = getState(); + const node = getNode(state, id); + if (!node) return; + dispatch(setNode(node.updatePosition(x, y))); + dispatch(updateSetMetaOnRemote()); + }; + +export const changeNodeSelection = (id: GraphNode["id"], selected: boolean): AppThunk => + (dispatch, getState) => { + const state = getState(); + const node = getNode(state, id); + if (!node) return; + dispatch(setNode(selected ? node.select() : node.unselect())); + }; + +export const changeEdgeSelection = (id: GraphConnectionRecord["id"], selected: boolean): AppThunk => + (dispatch, getState) => { + const state = getState(); + const connection = getConnection(state, id); + if (!connection) return; + dispatch(setConnection(selected ? connection.select() : connection.unselect())); + }; + +export const applyEditorNodeChanges = (changes: NodeChange[]): AppThunk => + (dispatch) => { + for (const change of changes) { + switch (change.type) { + case "position": { + if (change.position) { + dispatch(changeNodePosition( + change.id, + change.position.x, + change.position.y + )); + } + break; + } + + case "select": { + dispatch(changeNodeSelection(change.id, change.selected)); + break; + } + + case "remove": // handled separetely via dedicated action + case "add": + case "reset": + case "dimensions": + default: + // no-op + } + } + }; + +export const applyEditorEdgeChanges = (changes: EdgeChange[]): AppThunk => + (dispatch) => { + for (const change of changes) { + switch (change.type) { + case "select": { + dispatch(changeEdgeSelection(change.id, change.selected)); + break; + } + + case "remove": // handled separetely via dedicated action + case "add": + case "reset": + default: + // no-op + } + } + }; + +export const setEditorLockedState = (state: boolean): ISetEditorLocked => { + return { + type: EditorActionType.SET_LOCKED, + payload: { + locked: state + } + }; +}; + +export const toggleEditorLockedState = (): AppThunk => + (dispatch, getState) => { + const state = getState(); + dispatch(setEditorLockedState(!getGraphEditorLockedState(state))); + }; + +export const triggerEditorFitView = (): AppThunk => + (_, getState) => { + const state = getState(); + getGraphEditorInstance(state)?.fitView(); + }; + +export const editorZoomIn = (): AppThunk => + (_, getState) => { + const state = getState(); + getGraphEditorInstance(state)?.zoomIn(); + }; + +export const editorZoomOut = (): AppThunk => + (_, getState) => { + const state = getState(); + getGraphEditorInstance(state)?.zoomOut(); + }; + + diff --git a/src/actions/graph.ts b/src/actions/graph.ts index 82ef2912..a7a479ca 100644 --- a/src/actions/graph.ts +++ b/src/actions/graph.ts @@ -3,19 +3,17 @@ import { writePacket } from "osc"; import { oscQueryBridge } from "../controller/oscqueryBridgeController"; import { ActionBase, AppThunk, RootStateType } from "../lib/store"; import { OSCQueryRNBOInstance, OSCQueryRNBOInstancesState, OSCQueryRNBOJackConnections, OSCQueryRNBOJackPortInfo, OSCQuerySetMeta, OSCQuerySetNodeMeta } from "../lib/types"; -import { ConnectionType, GraphConnectionRecord, GraphControlNodeRecord, GraphNode, GraphNodeRecord, GraphPatcherNode, GraphPatcherNodeRecord, GraphPortRecord, GraphSystemNodeRecord, NodeType, PortDirection, calculateNodeContentHeight, createNodePorts } from "../models/graph"; -import { getConnection, getConnectionByNodesAndPorts, getConnectionsForSourceNodeAndPort, getNode, getPatcherNodeByIndex, getNodes, getSystemNodeByJackNameAndDirection, getConnections, getPatcherNodes, getSystemNodes, getControlNodes } from "../selectors/graph"; +import { ConnectionType, GraphConnectionRecord, GraphControlNodeRecord, GraphNodeRecord, GraphPatcherNodeRecord, GraphPortRecord, GraphSystemNodeRecord, NodeType, PortDirection, calculateNodeContentHeight, createNodePorts } from "../models/graph"; +import { getConnectionsForSourceNodeAndPort, getNode, getPatcherNodeByIndex, getNodes, getSystemNodeByJackNameAndDirection, getConnections, getPatcherNodes, getSystemNodes, getControlNodes } from "../selectors/graph"; import { showNotification } from "./notifications"; import { NotificationLevel } from "../models/notification"; import { InstanceStateRecord } from "../models/instance"; import { deleteInstance, setInstance, setInstances } from "./instances"; import { getInstance } from "../selectors/instances"; import { PatcherRecord } from "../models/patcher"; -import { Connection, EdgeChange, NodeChange } from "reactflow"; -import { isValidConnection } from "../lib/editorUtils"; -import throttle from "lodash.throttle"; import { getPatchers } from "../selectors/patchers"; import { nodeDefaultWidth, nodeHeaderHeight } from "../lib/constants"; +import { getGraphEditorInstance } from "../selectors/editor"; const defaultNodeSpacing = 150; const getPatcherOrControlNodeCoordinates = (node: GraphPatcherNodeRecord | GraphControlNodeRecord, nodes: GraphNodeRecord[]): { x: number, y: number } => { @@ -34,14 +32,6 @@ const getPatcherOrControlNodeCoordinates = (node: GraphPatcherNodeRecord | Graph return { x: 435 + defaultNodeSpacing, y }; }; -const serializeSetMeta = (nodes: GraphNodeRecord[]): string => { - const result: OSCQuerySetMeta = { nodes: {} }; - for (const node of nodes) { - result.nodes[node.id] = { position: { x: node.x, y: node.y } }; - } - return JSON.stringify(result); -}; - const deserializeSetMeta = (metaString: string): OSCQuerySetMeta => { // I don't know why we're getting strings of length 1 but, they can't be valid JSON anyway if (metaString && metaString.length > 1) { @@ -337,26 +327,6 @@ export const updateSetMetaFromRemote = (metaString: string): AppThunk => }; -const doUpdateNodesMeta = throttle((nodes: ImmuMap) => { - try { - const value = serializeSetMeta(nodes.valueSeq().toArray()); - - const message = { - address: "/rnbo/inst/control/sets/meta", - args: [ - { type: "s", value } - ] - }; - oscQueryBridge.sendPacket(writePacket(message)); - } catch (err) { - console.warn(`Failed to update Set Meta on remote: ${err.message}`); - } - -}, 150, { leading: true, trailing: true }); - -const updateSetMetaOnRemote = (): AppThunk => - (dispatch, getState) => doUpdateNodesMeta(getNodes(getState())); - const requestMetaUpdateFromRemote = (): AppThunk => async (dispatch) => { try { @@ -669,214 +639,6 @@ export const loadPatcherNodeOnRemote = (patcher: PatcherRecord): AppThunk => } }; -// Editor Actions -export const createEditorConnection = (connection: Connection): AppThunk => - (dispatch, getState) => { - try { - const state = getState(); - - if (!connection.source || !connection.target || !connection.sourceHandle || !connection.targetHandle) { - throw new Error(`Invalid Connection Description (${connection.source}:${connection.sourceHandle} => ${connection.target}:${connection.targetHandle})`); - } - - // Valid Connection? - const { sourceNode, sourcePort, sinkNode, sinkPort } = isValidConnection(connection, state.graph.nodes); - - // Does it already exist? - const existingConnection = getConnectionByNodesAndPorts( - state, - { - sourceNodeId: sourceNode.id, - sinkNodeId: sinkNode.id, - sourcePortId: sourcePort.id, - sinkPortId: sinkPort.id - } - ); - - if (existingConnection) { - return void dispatch(showNotification({ - title: "Skipped creating connection", - level: NotificationLevel.warn, - message: `A connection between ${connection.source}:${connection.sourceHandle} and ${connection.target}:${connection.targetHandle} already exists` - })); - } - - const message = { - address: "/rnbo/jack/connections/connect", - args: [ - { type: "s", value: `${sourceNode.jackName}:${sourcePort.id}` }, - { type: "s", value: `${sinkNode.jackName}:${sinkPort.id}` } - ] - }; - - oscQueryBridge.sendPacket(writePacket(message)); - } catch (err) { - dispatch(showNotification({ - title: "Failed to create connection", - level: NotificationLevel.error, - message: err.message - })); - console.error(err); - } - }; - -export const removeEditorConnectionById = (id: GraphConnectionRecord["id"]): AppThunk => - (dispatch, getState) => { - - try { - const state = getState(); - const connection = getConnection(state, id); - if (!connection) throw new Error(`Connection with id ${id} does not exist.`); - - const sourceNode = getNode(state, connection.sourceNodeId); - if (!sourceNode) throw new Error(`Node with id ${connection.sourceNodeId} does not exist.`); - - const sourcePort = sourceNode.getPort(connection.sourcePortId); - if (!sourcePort) throw new Error(`Port with id ${connection.sourcePortId} does not exist on node ${sourceNode.id}.`); - - const sinkNode = getNode(state, connection.sinkNodeId); - if (!sinkNode) throw new Error(`Node with id ${connection.sinkNodeId} does not exist.`); - - const sinkPort = sinkNode.getPort(connection.sinkPortId); - if (!sinkPort) throw new Error(`Port with id ${connection.sinkPortId} does not exist on node ${sinkNode.id}.`); - - const message = { - address: "/rnbo/jack/connections/disconnect", - args: [ - { type: "s", value: `${sourceNode.jackName}:${sourcePort.id}` }, - { type: "s", value: `${sinkNode.jackName}:${sinkPort.id}` } - ] - }; - - oscQueryBridge.sendPacket(writePacket(message)); - - } catch (err) { - dispatch(showNotification({ - title: "Failed to delete connection", - level: NotificationLevel.error, - message: err.message - })); - console.error(err); - } - }; - - -export const removeEditorNodeById = (id: GraphNode["id"], updateSetMeta = true): AppThunk => - (dispatch, getState) => { - try { - const state = getState(); - const node = getNode(state, id); - - if (!node) { - throw new Error(`Node with id ${id} does not exist.`); - } - - if (node.type === NodeType.System || node.type === NodeType.Control) { - throw new Error(`System nodes cannot be removed (id: ${id}).`); - } - - dispatch(unloadPatcherNodeByIndexOnRemote(node.index)); - if (updateSetMeta) doUpdateNodesMeta(getNodes(state).delete(node.id)); - - } catch (err) { - dispatch(showNotification({ - title: "Failed to node", - level: NotificationLevel.error, - message: err.message - })); - console.error(err); - } - }; - -export const removeEditorNodesById = (ids: GraphPatcherNode["id"][]): AppThunk => - (dispatch, getState) => { - for (const id of ids) { - dispatch(removeEditorNodeById(id, false)); - } - // Only at the end update the meta to ensure all coord data has been removed - doUpdateNodesMeta(getNodes(getState()).deleteAll(ids)); - }; - -export const removeEditorConnectionsById = (ids: GraphConnectionRecord["id"][]): AppThunk => - (dispatch) => { - for (const id of ids) { - dispatch(removeEditorConnectionById(id)); - } - }; - -export const changeNodePosition = (id: GraphNode["id"], x: number, y: number): AppThunk => - (dispatch, getState) => { - const state = getState(); - const node = getNode(state, id); - if (!node) return; - dispatch(setNode(node.updatePosition(x, y))); - dispatch(updateSetMetaOnRemote()); - }; - -export const changeNodeSelection = (id: GraphNode["id"], selected: boolean): AppThunk => - (dispatch, getState) => { - const state = getState(); - const node = getNode(state, id); - if (!node) return; - dispatch(setNode(selected ? node.select() : node.unselect())); - }; - -export const changeEdgeSelection = (id: GraphConnectionRecord["id"], selected: boolean): AppThunk => - (dispatch, getState) => { - const state = getState(); - const connection = getConnection(state, id); - if (!connection) return; - dispatch(setConnection(selected ? connection.select() : connection.unselect())); - }; - -export const applyEditorNodeChanges = (changes: NodeChange[]): AppThunk => - (dispatch) => { - for (const change of changes) { - switch (change.type) { - case "position": { - if (change.position) { - dispatch(changeNodePosition( - change.id, - change.position.x, - change.position.y - )); - } - break; - } - - case "select": { - dispatch(changeNodeSelection(change.id, change.selected)); - break; - } - - case "remove": // handled separetely via dedicated action - case "add": - case "reset": - case "dimensions": - default: - // no-op - } - } - }; - -export const applyEditorEdgeChanges = (changes: EdgeChange[]): AppThunk => - (dispatch) => { - for (const change of changes) { - switch (change.type) { - case "select": { - dispatch(changeEdgeSelection(change.id, change.selected)); - break; - } - - case "remove": // handled separetely via dedicated action - case "add": - case "reset": - default: - // no-op - } - } - }; - // Updates from OSCQuery Runner Remote export const updateSourcePortConnections = (source: string, sinks: string[]): AppThunk => (dispatch, getState) => { @@ -913,7 +675,7 @@ export const addPatcherNode = (desc: OSCQueryRNBOInstance, metaString: string): const nodeMeta: OSCQuerySetNodeMeta | undefined = setMeta?.nodes?.[node.id]; const state = getState(); - const { x, y } = nodeMeta?.position || state.editor.instance?.project({ + const { x, y } = nodeMeta?.position || getGraphEditorInstance(state)?.project({ y: 10, x: 10 }) || getPatcherOrControlNodeCoordinates(node, []); diff --git a/src/components/editor/editor.module.css b/src/components/editor/editor.module.css index 7cfde7ce..dfffdba7 100644 --- a/src/components/editor/editor.module.css +++ b/src/components/editor/editor.module.css @@ -30,6 +30,11 @@ pointer-events: all; } +.controls { + position: fixed; + bottom: var(--mantine-spacing-md); +} + .editor { flex: 1; @@ -68,17 +73,6 @@ } } - .react-flow__controls-button { - background: var(--mantine-color-default); - border-bottom-color: var(--mantine-color-default-border); - box-shadow: var(--mantine-shadow-sm); - fill: var(--mantine-color-default-color); - - &:hover { - background: var(--mantine-color-default-hover); - } - } - .react-flow__edge { .react-flow__edge-path { diff --git a/src/components/editor/index.tsx b/src/components/editor/index.tsx index 29d4b95f..8f318476 100644 --- a/src/components/editor/index.tsx +++ b/src/components/editor/index.tsx @@ -1,5 +1,5 @@ import React, { ComponentType, FunctionComponent, memo, useCallback } from "react"; -import ReactFlow, { Connection, Controls, Edge, EdgeChange, Node, NodeChange, ReactFlowInstance } from "reactflow"; +import ReactFlow, { Connection, Edge, EdgeChange, Node, NodeChange, ReactFlowInstance } from "reactflow"; import { GraphConnectionRecord, GraphPatcherNodeRecord, NodeType } from "../../models/graph"; import EditorPatcherNode from "./patcherNode"; import EditorSystemNode from "./systemNode"; @@ -12,17 +12,28 @@ import classes from "./editor.module.css"; import GraphEdge, { RNBOGraphEdgeType } from "./edge"; import { useRouter } from "next/router"; import EditorControlNode from "./controlNode"; -import { useMantineColorScheme } from "@mantine/core"; +import { ActionIcon, Tooltip, useMantineColorScheme } from "@mantine/core"; +import { IconElement } from "../elements/icon"; +import { mdiFitToScreen, mdiLock, mdiLockOpen, mdiMinus, mdiPlus } from "@mdi/js"; +import { maxEditorZoom, minEditorZoom } from "../../lib/constants"; export type GraphEditorProps = { connections: RootStateType["graph"]["connections"]; nodes: RootStateType["graph"]["nodes"]; - onInit: (instance: ReactFlowInstance) => void; + onConnect: (connection: Connection) => any; onNodesDelete: (nodes: Pick[]) => void; onNodesChange: (changes: NodeChange[]) => void; onEdgesDelete: (edges: Pick[]) => void; onEdgesChange: (changes: EdgeChange[]) => void; + + zoom: number; + locked: boolean; + onInit: (instance: ReactFlowInstance) => void; + onFitView: () => void; + onToggleLocked: () => void; + onZoomIn: () => void; + onZoomOut: () => void; }; const nodeTypes: Record> = { @@ -37,13 +48,21 @@ const edgeTypes: Record const GraphEditor: FunctionComponent = memo(function WrappedFlowGraph({ connections, - nodes, - onInit, + onConnect, onNodesChange, onNodesDelete, onEdgesChange, - onEdgesDelete + onEdgesDelete, + + nodes, + onInit, + onFitView, + locked, + onToggleLocked, + zoom, + onZoomIn, + onZoomOut }) { const { colorScheme } = useMantineColorScheme(); @@ -110,11 +129,38 @@ const GraphEditor: FunctionComponent = memo(function WrappedFl edgesUpdatable={ false } fitView onInit={ onInit } - minZoom={ 0.1 } - maxZoom={ 5 } - > - - + minZoom={ minEditorZoom } + maxZoom={ maxEditorZoom } + nodesFocusable={ !locked } + nodesDraggable={ !locked } + nodesConnectable={ !locked } + edgesFocusable={ !locked } + elementsSelectable={ !locked } + /> +
+ + + = maxEditorZoom } onClick={ onZoomIn } > + + + + + + + + + + + + + + + + + + + +
); }); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9f9f088b..7583fc61 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -29,6 +29,9 @@ export const nodeHeaderHeight = 50; export const nodePortSpacing = 30; export const nodePortHeight = 20; +export const maxEditorZoom = 5; +export const minEditorZoom = 0.25; + export enum Breakpoints { xs = 36 * 16, sm = 48 * 16, diff --git a/src/pages/index.tsx b/src/pages/index.tsx index bbbff5d9..2cf0c605 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -7,11 +7,15 @@ import { getConnections, getNodes } from "../selectors/graph"; import GraphEditor from "../components/editor"; import PresetDrawer from "../components/presets"; import { Connection, Edge, EdgeChange, Node, NodeChange, ReactFlowInstance } from "reactflow"; +import { loadPatcherNodeOnRemote } from "../actions/graph"; import { applyEditorEdgeChanges, applyEditorNodeChanges, createEditorConnection, + editorZoomIn, + editorZoomOut, removeEditorConnectionsById, removeEditorNodesById, - loadPatcherNodeOnRemote -} from "../actions/graph"; + toggleEditorLockedState, + triggerEditorFitView +} from "../actions/editor"; import SetsDrawer from "../components/sets"; import { destroySetPresetOnRemote, loadSetPresetOnRemote, saveSetPresetToRemote, renameSetPresetOnRemote, clearGraphSetOnRemote, destroyGraphSetOnRemote, loadGraphSetOnRemote, renameGraphSetOnRemote, saveGraphSetOnRemote } from "../actions/sets"; import { destroyPatcherOnRemote, renamePatcherOnRemote } from "../actions/patchers"; @@ -27,6 +31,7 @@ import { IconElement } from "../components/elements/icon"; import { mdiCamera, mdiFileExport, mdiGroup } from "@mdi/js"; import { ResponsiveButton } from "../components/elements/responsiveButton"; import { initEditor, unmountEditor } from "../actions/editor"; +import { getGraphEditorLockedState } from "../selectors/editor"; const Index: FunctionComponent> = () => { @@ -36,13 +41,15 @@ const Index: FunctionComponent> = () => { nodes, connections, graphSets, - graphPresets + graphPresets, + editorLocked ] = useAppSelector((state: RootStateType) => [ getPatchersSortedByName(state, SortOrder.Asc), getNodes(state), getConnections(state), getGraphSetsSortedByName(state, SortOrder.Asc), - getGraphSetPresetsSortedByName(state, SortOrder.Asc) + getGraphSetPresetsSortedByName(state, SortOrder.Asc), + getGraphEditorLockedState(state) ]); const [patcherDrawerIsOpen, { close: closePatcherDrawer, toggle: togglePatcherDrawer }] = useDisclosure(); @@ -60,6 +67,22 @@ const Index: FunctionComponent> = () => { dispatch(initEditor(instance)); }, [dispatch]); + const onEditorFitView = useCallback(() => { + dispatch(triggerEditorFitView()); + }, [dispatch]); + + const onEditorToggleLocked = useCallback(() => { + dispatch(toggleEditorLockedState()); + }, [dispatch]); + + const onEditorZoomIn = useCallback(() => { + dispatch(editorZoomIn()); + }, [dispatch]); + + const onEditorZoomOut = useCallback(() => { + dispatch(editorZoomOut()); + }, [dispatch]); + // Nodes const onConnectNodes = useCallback((connection: Connection) => { dispatch(createEditorConnection(connection)); @@ -182,12 +205,20 @@ const Index: FunctionComponent> = () => { { +export const editor = (state: EditorState = { + isLocked: false +}, action: EditorAction): EditorState => { switch (action.type) { case EditorActionType.INIT: { @@ -22,6 +25,13 @@ export const editor = (state: EditorState = {}, action: EditorAction): EditorSta }; } + case EditorActionType.SET_LOCKED: { + return { + ...state, + isLocked: action.payload.locked + }; + } + default: return state; } diff --git a/src/selectors/editor.ts b/src/selectors/editor.ts new file mode 100644 index 00000000..9a177a98 --- /dev/null +++ b/src/selectors/editor.ts @@ -0,0 +1,5 @@ +import { ReactFlowInstance } from "reactflow"; +import { RootStateType } from "../lib/store"; + +export const getGraphEditorInstance = (state: RootStateType): ReactFlowInstance | undefined => state.editor.instance; +export const getGraphEditorLockedState = (state: RootStateType): boolean => state.editor.isLocked; From fcbfcef799a8de72c937b7aac6d16167576e9963 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Thu, 12 Dec 2024 15:41:11 +0000 Subject: [PATCH 5/9] graph: added auto layouting --- package-lock.json | 19 ++++++++++++++++ package.json | 1 + src/actions/editor.ts | 40 +++++++++++++++++++++++++++++++-- src/actions/graph.ts | 27 +++++++++++----------- src/components/editor/index.tsx | 9 +++++++- src/lib/constants.ts | 1 + src/pages/index.tsx | 16 +++++++++++++ 7 files changed, 96 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4fe2ac62..c60f46c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "rnbo-runner-panel", "version": "2.1.1-beta.0", "dependencies": { + "@dagrejs/dagre": "^1.1.4", "@mantine/core": "^7.10.2", "@mantine/dropzone": "^7.10.2", "@mantine/hooks": "^7.10.2", @@ -70,6 +71,24 @@ "node": ">=6.9.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz", + "integrity": "sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", diff --git a/package.json b/package.json index c386eb6e..ad5db3ce 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "preversion": "next lint" }, "dependencies": { + "@dagrejs/dagre": "^1.1.4", "@mantine/core": "^7.10.2", "@mantine/dropzone": "^7.10.2", "@mantine/hooks": "^7.10.2", diff --git a/src/actions/editor.ts b/src/actions/editor.ts index ddbeeccc..145ec0ca 100644 --- a/src/actions/editor.ts +++ b/src/actions/editor.ts @@ -1,7 +1,8 @@ +import Dagre from "@dagrejs/dagre"; import { Connection, EdgeChange, NodeChange, ReactFlowInstance } from "reactflow"; import { Map as ImmuMap } from "immutable"; import { ActionBase, AppThunk } from "../lib/store"; -import { getConnection, getConnectionByNodesAndPorts, getNode, getNodes } from "../selectors/graph"; +import { getConnection, getConnectionByNodesAndPorts, getConnections, getControlNodes, getNode, getNodes, getPatcherNodes, getSystemNodes } from "../selectors/graph"; import { GraphConnectionRecord, GraphNode, GraphNodeRecord, GraphPatcherNode, NodeType } from "../models/graph"; import { showNotification } from "./notifications"; import { NotificationLevel } from "../models/notification"; @@ -10,8 +11,9 @@ import { oscQueryBridge } from "../controller/oscqueryBridgeController"; import { isValidConnection } from "../lib/editorUtils"; import throttle from "lodash.throttle"; import { OSCQuerySetMeta } from "../lib/types"; -import { setConnection, setNode, unloadPatcherNodeByIndexOnRemote } from "./graph"; +import { setConnection, setNode, setNodes, unloadPatcherNodeByIndexOnRemote } from "./graph"; import { getGraphEditorInstance, getGraphEditorLockedState } from "../selectors/editor"; +import { defaultNodeGap } from "../lib/constants"; export enum EditorActionType { INIT = "EDITOR_INIT", @@ -324,4 +326,38 @@ export const editorZoomOut = (): AppThunk => getGraphEditorInstance(state)?.zoomOut(); }; +export const generateEditorLayout = (): AppThunk => + (dispatch, getState) => { + + const state = getState(); + + const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + g.setGraph({ align: "UL", ranksep: defaultNodeGap, nodesep: defaultNodeGap, rankdir: "LR" }); + + const connections = getConnections(state); + connections.valueSeq().forEach(conn => g.setEdge(conn.sourceNodeId, conn.sinkNodeId)); + + const nodes = getNodes(state); + + nodes.valueSeq().forEach(n => g.setNode(n.id, { + height: n.height, + width: n.width + })); + + Dagre.layout(g); + + const layoutedNodes = nodes.valueSeq().toArray().map((node: GraphNodeRecord): GraphNodeRecord => { + const pos = g.node(node.id); + // Shift from dagre anchor (center center) to reactflow anchor (top left) + return node.updatePosition( + pos.x - (node.width / 2), + pos.y - (node.height / 2) + ); + }); + + dispatch(setNodes(layoutedNodes)); + dispatch(updateSetMetaOnRemote()); + window.requestAnimationFrame(() => getGraphEditorInstance(getState())?.fitView()); + }; + diff --git a/src/actions/graph.ts b/src/actions/graph.ts index a7a479ca..384b6129 100644 --- a/src/actions/graph.ts +++ b/src/actions/graph.ts @@ -12,10 +12,9 @@ import { deleteInstance, setInstance, setInstances } from "./instances"; import { getInstance } from "../selectors/instances"; import { PatcherRecord } from "../models/patcher"; import { getPatchers } from "../selectors/patchers"; -import { nodeDefaultWidth, nodeHeaderHeight } from "../lib/constants"; +import { defaultNodeGap, nodeDefaultWidth, nodeHeaderHeight } from "../lib/constants"; import { getGraphEditorInstance } from "../selectors/editor"; -const defaultNodeSpacing = 150; const getPatcherOrControlNodeCoordinates = (node: GraphPatcherNodeRecord | GraphControlNodeRecord, nodes: GraphNodeRecord[]): { x: number, y: number } => { let y = 0; @@ -26,10 +25,10 @@ const getPatcherOrControlNodeCoordinates = (node: GraphPatcherNodeRecord | Graph return current.y > n.y ? current : n; }, undefined as GraphNodeRecord | undefined); - y = bottomNode ? bottomNode.y + bottomNode.height + defaultNodeSpacing : 0; + y = bottomNode ? bottomNode.y + bottomNode.height + defaultNodeGap : 0; } - return { x: 435 + defaultNodeSpacing, y }; + return { x: nodeDefaultWidth + defaultNodeGap, y }; }; const deserializeSetMeta = (metaString: string): OSCQuerySetMeta => { @@ -385,8 +384,8 @@ export const initNodes = (jackPortsInfo: OSCQueryRNBOJackPortInfo, instanceInfo: // as we assume moving forward that they are SystemNames const systemJackNames = ImmuSet(getSystemNodeJackNamesFromPortInfo(jackPortsInfo, patcherAndControlNodes)); - let systemInputY = -defaultNodeSpacing; - let systemOutputY = -defaultNodeSpacing; + let systemInputY = -defaultNodeGap; + let systemOutputY = -defaultNodeGap; const systemNodes: GraphSystemNodeRecord[] = GraphSystemNodeRecord .fromDescription(systemJackNames, jackPortsInfo) @@ -400,13 +399,13 @@ export const initNodes = (jackPortsInfo: OSCQueryRNBOJackPortInfo, instanceInfo: if (node.id.endsWith(GraphSystemNodeRecord.inputSuffix)) { node = node.updatePosition( 0, - systemInputY + defaultNodeSpacing + systemInputY + defaultNodeGap ); systemInputY = node.y + node.contentHeight; } else { node = node.updatePosition( - node.width + 300 + defaultNodeSpacing * 2, - systemOutputY + defaultNodeSpacing + node.width + 300 + defaultNodeGap * 2, + systemOutputY + defaultNodeGap ); systemOutputY = node.y + node.contentHeight; } @@ -531,8 +530,8 @@ export const updateSystemOrControlPortInfo = (type: ConnectionType, direction: P } // Create New Nodes - let systemInputY = -defaultNodeSpacing; - let systemOutputY = -defaultNodeSpacing; + let systemInputY = -defaultNodeGap; + let systemOutputY = -defaultNodeGap; const patchers = getPatchers(state).valueSeq(); const missingSystemOrControlJackName = Array.from(systemOrControlJackNames.values()) @@ -562,13 +561,13 @@ export const updateSystemOrControlPortInfo = (type: ConnectionType, direction: P if (direction === PortDirection.Source) { node = node.updatePosition( 0, - systemInputY + defaultNodeSpacing + systemInputY + defaultNodeGap ); systemInputY = node.y + node.contentHeight; } else { node = node.updatePosition( - node.width + 300 + defaultNodeSpacing * 2, - systemOutputY + defaultNodeSpacing + node.width + 300 + defaultNodeGap * 2, + systemOutputY + defaultNodeGap ); systemOutputY = node.y + node.contentHeight; } diff --git a/src/components/editor/index.tsx b/src/components/editor/index.tsx index 8f318476..9c5af6a0 100644 --- a/src/components/editor/index.tsx +++ b/src/components/editor/index.tsx @@ -14,7 +14,7 @@ import { useRouter } from "next/router"; import EditorControlNode from "./controlNode"; import { ActionIcon, Tooltip, useMantineColorScheme } from "@mantine/core"; import { IconElement } from "../elements/icon"; -import { mdiFitToScreen, mdiLock, mdiLockOpen, mdiMinus, mdiPlus } from "@mdi/js"; +import { mdiFitToScreen, mdiLock, mdiLockOpen, mdiMinus, mdiPlus, mdiSitemap } from "@mdi/js"; import { maxEditorZoom, minEditorZoom } from "../../lib/constants"; export type GraphEditorProps = { @@ -31,6 +31,7 @@ export type GraphEditorProps = { locked: boolean; onInit: (instance: ReactFlowInstance) => void; onFitView: () => void; + onAutoLayout: () => void; onToggleLocked: () => void; onZoomIn: () => void; onZoomOut: () => void; @@ -58,6 +59,7 @@ const GraphEditor: FunctionComponent = memo(function WrappedFl nodes, onInit, onFitView, + onAutoLayout, locked, onToggleLocked, zoom, @@ -159,6 +161,11 @@ const GraphEditor: FunctionComponent = memo(function WrappedFl + + + + +
diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 7583fc61..570ed458 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -28,6 +28,7 @@ export const nodeDefaultWidth = 435; export const nodeHeaderHeight = 50; export const nodePortSpacing = 30; export const nodePortHeight = 20; +export const defaultNodeGap = 150; export const maxEditorZoom = 5; export const minEditorZoom = 0.25; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 2cf0c605..d386c6f1 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -12,6 +12,7 @@ import { applyEditorEdgeChanges, applyEditorNodeChanges, createEditorConnection, editorZoomIn, editorZoomOut, + generateEditorLayout, removeEditorConnectionsById, removeEditorNodesById, toggleEditorLockedState, triggerEditorFitView @@ -75,6 +76,20 @@ const Index: FunctionComponent> = () => { dispatch(toggleEditorLockedState()); }, [dispatch]); + const onEditorAutoLayout = useCallback(() => { + modals.openConfirmModal({ + title: "Rerrange Graph", + centered: true, + children: ( + + Are you sure you want to automatically layout the current graph? This action cannot be undone. + + ), + labels: { confirm: "Confirm", cancel: "Cancel" }, + onConfirm: () => dispatch(generateEditorLayout()) + }); + }, []); + const onEditorZoomIn = useCallback(() => { dispatch(editorZoomIn()); }, [dispatch]); @@ -213,6 +228,7 @@ const Index: FunctionComponent> = () => { onEdgesDelete={ onEdgesDelete } onInit={ onEditorInit } + onAutoLayout={ onEditorAutoLayout } onFitView={ onEditorFitView } onToggleLocked={ onEditorToggleLocked } locked={ editorLocked } From 2be8f3e2c751f1a7b52d2fdb8cc5ef96c95e6b2c Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Thu, 12 Dec 2024 15:49:57 +0000 Subject: [PATCH 6/9] fixed forwardref error for graphset drawer --- src/components/elements/icon.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/elements/icon.tsx b/src/components/elements/icon.tsx index ddbf5f6c..4662778b 100644 --- a/src/components/elements/icon.tsx +++ b/src/components/elements/icon.tsx @@ -1,8 +1,10 @@ import Icon from "@mdi/react"; import classes from "./elements.module.css"; import { parseThemeColor, useMantineTheme } from "@mantine/core"; +import { forwardRef, RefObject } from "react"; +import { IconProps } from "@mdi/react/dist/IconProps"; -export const IconElement: typeof Icon = ({ color, ...props }) => { +export const IconElement = forwardRef(({ color, ...props }, ref) => { const theme = useMantineTheme(); let iconColor: string | undefined = undefined; @@ -12,6 +14,6 @@ export const IconElement: typeof Icon = ({ color, ...props }) => { } return ( - + } /> ); -}; +}); From eb7605b362cece3fbd5c4a9dfc11f220fc92062e Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Thu, 12 Dec 2024 15:50:16 +0000 Subject: [PATCH 7/9] slight wording improvement for auto layout dialogue --- src/pages/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/index.tsx b/src/pages/index.tsx index d386c6f1..ecea97b4 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -82,7 +82,7 @@ const Index: FunctionComponent> = () => { centered: true, children: ( - Are you sure you want to automatically layout the current graph? This action cannot be undone. + Are you sure you want to rearrange and auto-layout the current graph? This action cannot be undone. ), labels: { confirm: "Confirm", cancel: "Cancel" }, From 37316a97fdcc9500ef4f11397b31cfeb4742299e Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Thu, 12 Dec 2024 15:53:28 +0000 Subject: [PATCH 8/9] align new nodes on 0,0 --- src/actions/graph.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/actions/graph.ts b/src/actions/graph.ts index 384b6129..adec1325 100644 --- a/src/actions/graph.ts +++ b/src/actions/graph.ts @@ -675,8 +675,8 @@ export const addPatcherNode = (desc: OSCQueryRNBOInstance, metaString: string): const state = getState(); const { x, y } = nodeMeta?.position || getGraphEditorInstance(state)?.project({ - y: 10, - x: 10 + y: 0, + x: 0 }) || getPatcherOrControlNodeCoordinates(node, []); node = node.updatePosition(x, y); From 66010f07b8d4915558388ad9541fbc2d718b1153 Mon Sep 17 00:00:00 2001 From: Florian Demmer Date: Thu, 12 Dec 2024 17:24:34 +0000 Subject: [PATCH 9/9] fixed linter errors --- src/actions/editor.ts | 2 +- src/components/elements/icon.tsx | 2 +- src/pages/index.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/actions/editor.ts b/src/actions/editor.ts index 145ec0ca..0858c7bc 100644 --- a/src/actions/editor.ts +++ b/src/actions/editor.ts @@ -2,7 +2,7 @@ import Dagre from "@dagrejs/dagre"; import { Connection, EdgeChange, NodeChange, ReactFlowInstance } from "reactflow"; import { Map as ImmuMap } from "immutable"; import { ActionBase, AppThunk } from "../lib/store"; -import { getConnection, getConnectionByNodesAndPorts, getConnections, getControlNodes, getNode, getNodes, getPatcherNodes, getSystemNodes } from "../selectors/graph"; +import { getConnection, getConnectionByNodesAndPorts, getConnections, getNode, getNodes } from "../selectors/graph"; import { GraphConnectionRecord, GraphNode, GraphNodeRecord, GraphPatcherNode, NodeType } from "../models/graph"; import { showNotification } from "./notifications"; import { NotificationLevel } from "../models/notification"; diff --git a/src/components/elements/icon.tsx b/src/components/elements/icon.tsx index 4662778b..e1590750 100644 --- a/src/components/elements/icon.tsx +++ b/src/components/elements/icon.tsx @@ -4,7 +4,7 @@ import { parseThemeColor, useMantineTheme } from "@mantine/core"; import { forwardRef, RefObject } from "react"; import { IconProps } from "@mdi/react/dist/IconProps"; -export const IconElement = forwardRef(({ color, ...props }, ref) => { +export const IconElement = forwardRef(function WrappedIconElement({ color, ...props }, ref) { const theme = useMantineTheme(); let iconColor: string | undefined = undefined; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ecea97b4..81b5e1a2 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -88,7 +88,7 @@ const Index: FunctionComponent> = () => { labels: { confirm: "Confirm", cancel: "Cancel" }, onConfirm: () => dispatch(generateEditorLayout()) }); - }, []); + }, [dispatch]); const onEditorZoomIn = useCallback(() => { dispatch(editorZoomIn());