diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/editor-oss-graph.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/editor-oss-graph.tsx index 8c206c30..066aa2a0 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/editor-oss-graph.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/editor-oss-graph.tsx @@ -3,11 +3,14 @@ import { ReactFlowProvider } from 'reactflow'; import { OssFlow } from './oss-flow'; +import { OssFlowState } from './oss-flow-state'; export function EditorOssGraph() { return ( - + + + ); } diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/block-node.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/block-node.tsx index 3ddf347a..d3b76bc7 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/block-node.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/graph/block-node.tsx @@ -11,12 +11,14 @@ import { globalIDs } from '@/utils/constants'; import { type BlockInternalNode } from '../../../../models/oss-layout'; import { useOssEdit } from '../../oss-edit-context'; +import { useOssFlow } from '../oss-flow-context'; export const BLOCK_NODE_MIN_WIDTH = 160; export const BLOCK_NODE_MIN_HEIGHT = 100; export function BlockNode(node: BlockInternalNode) { const { selected, schema } = useOssEdit(); + const { dropTarget } = useOssFlow(); const showCoordinates = useOSSGraphStore(state => state.showCoordinates); const setHover = useOperationTooltipStore(state => state.setHoverItem); @@ -42,7 +44,8 @@ export function BlockNode(node: BlockInternalNode) {
diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-context.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-context.tsx new file mode 100644 index 00000000..2171ddfb --- /dev/null +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-context.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { createContext, use } from 'react'; + +interface IOssFlowContext { + dropTarget: number | null; + setDropTarget: React.Dispatch>; + containMovement: boolean; + setContainMovement: React.Dispatch>; +} + +export const OssFlowContext = createContext(null); +export const useOssFlow = () => { + const context = use(OssFlowContext); + if (context === null) { + throw new Error('useOssFlow has to be used within '); + } + return context; +}; diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-state.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-state.tsx new file mode 100644 index 00000000..980122e2 --- /dev/null +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow-state.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { useState } from 'react'; + +import { OssFlowContext } from './oss-flow-context'; + +export const OssFlowState = ({ children }: React.PropsWithChildren) => { + const [dropTarget, setDropTarget] = useState(null); + const [containMovement, setContainMovement] = useState(false); + + return ( + + {children} + + ); +}; diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow.tsx index 29953dd3..61ca52a1 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/oss-flow.tsx @@ -11,6 +11,7 @@ import { useReactFlow, useStoreApi } from 'reactflow'; +import clsx from 'clsx'; import { useDeleteBlock } from '@/features/oss/backend/use-delete-block'; import { type IOperationSchema } from '@/features/oss/models/oss'; @@ -30,6 +31,7 @@ import { useOssEdit } from '../oss-edit-context'; import { ContextMenu, type ContextMenuData } from './context-menu/context-menu'; import { OssNodeTypes } from './graph/oss-node-types'; +import { useOssFlow } from './oss-flow-context'; import { ToolbarOssGraph } from './toolbar-oss-graph'; import { useGetLayout } from './use-get-layout'; @@ -51,7 +53,8 @@ export function OssFlow() { isMutable, canDeleteOperation: canDelete } = useOssEdit(); - const { fitView, screenToFlowPosition } = useReactFlow(); + const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow(); + const { setDropTarget, setContainMovement, containMovement } = useOssFlow(); const store = useStoreApi(); const { resetSelectedElements, addSelectedNodes } = store.getState(); @@ -109,8 +112,6 @@ export function OssFlow() { height: block.height }, parentId: block.parent ? `-${block.parent}` : undefined, - expandParent: true, - extent: 'parent' as const, zIndex: Z_BLOCK }; }), @@ -120,8 +121,6 @@ export function OssFlow() { data: { label: operation.alias, operation: operation }, position: computeRelativePosition(schema, { x: operation.x, y: operation.y }, operation.parent), parentId: operation.parent ? `-${operation.parent}` : undefined, - expandParent: true, - extent: 'parent' as const, zIndex: Z_SCHEMA })) ]); @@ -266,6 +265,81 @@ export function OssFlow() { setMouseCoords(targetPosition); } + function handleDragStart(event: React.MouseEvent, target: Node) { + if (event.shiftKey) { + setContainMovement(true); + setNodes(prev => + prev.map(node => + node.id === target.id || selected.includes(Number(node.id)) + ? { + ...node, + extent: 'parent', + expandParent: true + } + : node + ) + ); + } else { + setContainMovement(false); + } + setIsContextMenuOpen(false); + } + + function handleDrag(event: React.MouseEvent) { + if (containMovement) { + return; + } + const mousePosition = screenToFlowPosition({ x: event.clientX, y: event.clientY }); + const blocks = getIntersectingNodes({ + x: mousePosition.x, + y: mousePosition.y, + width: 1, + height: 1 + }) + .map(node => Number(node.id)) + .filter(id => id < 0) + .map(id => schema.blockByID.get(-id)) + .filter(block => !!block); + + if (blocks.length === 0) { + setDropTarget(null); + return; + } else if (blocks.length === 1) { + setDropTarget(blocks[0].id); + return; + } + + const parents = blocks.map(block => block.parent).filter(id => !!id); + const potentialTargets = blocks.map(block => block.id).filter(id => !parents.includes(id)); + if (potentialTargets.length === 0) { + setDropTarget(null); + return; + } else { + setDropTarget(potentialTargets[0]); + return; + } + } + + function handleDragStop(_: React.MouseEvent, target: Node) { + if (containMovement) { + setNodes(prev => + prev.map(node => + node.id === target.id || selected.includes(Number(node.id)) + ? { + ...node, + extent: undefined, + expandParent: undefined + } + : node + ) + ); + } else { + // TODO: process drop event + } + setContainMovement(false); + setDropTarget(null); + } + return (
setIsContextMenuOpen(false)} {...menuProps} /> -
+
setIsContextMenuOpen(false)} onNodeDoubleClick={handleNodeDoubleClick} onNodeContextMenu={handleContextMenu} - onNodeDragStart={() => setIsContextMenuOpen(false)} + onNodeDragStart={handleDragStart} + onNodeDrag={handleDrag} + onNodeDragStop={handleDragStop} > {showGrid ? : null} diff --git a/rsconcept/frontend/src/styling/components.css b/rsconcept/frontend/src/styling/components.css index 20a16396..44d3c01c 100644 --- a/rsconcept/frontend/src/styling/components.css +++ b/rsconcept/frontend/src/styling/components.css @@ -304,6 +304,10 @@ outline-color: var(--color-graph-selected); border-color: var(--color-foreground); } + + .cursor-relocate .dragging & { + cursor: move; + } } @utility cc-node-block { @@ -326,4 +330,8 @@ &:hover { color: var(--color-foreground); } + + .cursor-relocate .dragging & { + cursor: move; + } }