F: Implement node dragging behavior

This commit is contained in:
Ivan 2025-04-23 21:11:56 +03:00
parent 6b68375b01
commit 9f5fe24ad6
6 changed files with 145 additions and 9 deletions

View File

@ -3,11 +3,14 @@
import { ReactFlowProvider } from 'reactflow'; import { ReactFlowProvider } from 'reactflow';
import { OssFlow } from './oss-flow'; import { OssFlow } from './oss-flow';
import { OssFlowState } from './oss-flow-state';
export function EditorOssGraph() { export function EditorOssGraph() {
return ( return (
<ReactFlowProvider> <ReactFlowProvider>
<OssFlow /> <OssFlowState>
<OssFlow />
</OssFlowState>
</ReactFlowProvider> </ReactFlowProvider>
); );
} }

View File

@ -11,12 +11,14 @@ import { globalIDs } from '@/utils/constants';
import { type BlockInternalNode } from '../../../../models/oss-layout'; import { type BlockInternalNode } from '../../../../models/oss-layout';
import { useOssEdit } from '../../oss-edit-context'; import { useOssEdit } from '../../oss-edit-context';
import { useOssFlow } from '../oss-flow-context';
export const BLOCK_NODE_MIN_WIDTH = 160; export const BLOCK_NODE_MIN_WIDTH = 160;
export const BLOCK_NODE_MIN_HEIGHT = 100; export const BLOCK_NODE_MIN_HEIGHT = 100;
export function BlockNode(node: BlockInternalNode) { export function BlockNode(node: BlockInternalNode) {
const { selected, schema } = useOssEdit(); const { selected, schema } = useOssEdit();
const { dropTarget } = useOssFlow();
const showCoordinates = useOSSGraphStore(state => state.showCoordinates); const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const setHover = useOperationTooltipStore(state => state.setHoverItem); const setHover = useOperationTooltipStore(state => state.setHoverItem);
@ -42,7 +44,8 @@ export function BlockNode(node: BlockInternalNode) {
<div <div
className={clsx( className={clsx(
'cc-node-block h-full w-full', 'cc-node-block h-full w-full',
isParent && 'border-primary', dropTarget && isParent && dropTarget !== node.data.block.id && 'border-destructive',
((isParent && !dropTarget) || dropTarget === node.data.block.id) && 'border-primary',
isChild && 'border-accent-orange50' isChild && 'border-accent-orange50'
)} )}
> >

View File

@ -0,0 +1,19 @@
'use client';
import { createContext, use } from 'react';
interface IOssFlowContext {
dropTarget: number | null;
setDropTarget: React.Dispatch<React.SetStateAction<number | null>>;
containMovement: boolean;
setContainMovement: React.Dispatch<React.SetStateAction<boolean>>;
}
export const OssFlowContext = createContext<IOssFlowContext | null>(null);
export const useOssFlow = () => {
const context = use(OssFlowContext);
if (context === null) {
throw new Error('useOssFlow has to be used within <OssFlowState>');
}
return context;
};

View File

@ -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<number | null>(null);
const [containMovement, setContainMovement] = useState(false);
return (
<OssFlowContext
value={{
dropTarget,
setDropTarget,
containMovement,
setContainMovement
}}
>
{children}
</OssFlowContext>
);
};

View File

@ -11,6 +11,7 @@ import {
useReactFlow, useReactFlow,
useStoreApi useStoreApi
} from 'reactflow'; } from 'reactflow';
import clsx from 'clsx';
import { useDeleteBlock } from '@/features/oss/backend/use-delete-block'; import { useDeleteBlock } from '@/features/oss/backend/use-delete-block';
import { type IOperationSchema } from '@/features/oss/models/oss'; 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 { ContextMenu, type ContextMenuData } from './context-menu/context-menu';
import { OssNodeTypes } from './graph/oss-node-types'; import { OssNodeTypes } from './graph/oss-node-types';
import { useOssFlow } from './oss-flow-context';
import { ToolbarOssGraph } from './toolbar-oss-graph'; import { ToolbarOssGraph } from './toolbar-oss-graph';
import { useGetLayout } from './use-get-layout'; import { useGetLayout } from './use-get-layout';
@ -51,7 +53,8 @@ export function OssFlow() {
isMutable, isMutable,
canDeleteOperation: canDelete canDeleteOperation: canDelete
} = useOssEdit(); } = useOssEdit();
const { fitView, screenToFlowPosition } = useReactFlow(); const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow();
const { setDropTarget, setContainMovement, containMovement } = useOssFlow();
const store = useStoreApi(); const store = useStoreApi();
const { resetSelectedElements, addSelectedNodes } = store.getState(); const { resetSelectedElements, addSelectedNodes } = store.getState();
@ -109,8 +112,6 @@ export function OssFlow() {
height: block.height height: block.height
}, },
parentId: block.parent ? `-${block.parent}` : undefined, parentId: block.parent ? `-${block.parent}` : undefined,
expandParent: true,
extent: 'parent' as const,
zIndex: Z_BLOCK zIndex: Z_BLOCK
}; };
}), }),
@ -120,8 +121,6 @@ export function OssFlow() {
data: { label: operation.alias, operation: operation }, data: { label: operation.alias, operation: operation },
position: computeRelativePosition(schema, { x: operation.x, y: operation.y }, operation.parent), position: computeRelativePosition(schema, { x: operation.x, y: operation.y }, operation.parent),
parentId: operation.parent ? `-${operation.parent}` : undefined, parentId: operation.parent ? `-${operation.parent}` : undefined,
expandParent: true,
extent: 'parent' as const,
zIndex: Z_SCHEMA zIndex: Z_SCHEMA
})) }))
]); ]);
@ -266,6 +265,81 @@ export function OssFlow() {
setMouseCoords(targetPosition); 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 ( return (
<div <div
tabIndex={-1} tabIndex={-1}
@ -288,7 +362,10 @@ export function OssFlow() {
<ContextMenu isOpen={isContextMenuOpen} onHide={() => setIsContextMenuOpen(false)} {...menuProps} /> <ContextMenu isOpen={isContextMenuOpen} onHide={() => setIsContextMenuOpen(false)} {...menuProps} />
<div className='cc-fade-in relative w-[100vw] cc-mask-sides' style={{ height: mainHeight, fontFamily: 'Rubik' }}> <div
className={clsx('cc-fade-in relative w-[100vw] cc-mask-sides', !containMovement && 'cursor-relocate')}
style={{ height: mainHeight, fontFamily: 'Rubik' }}
>
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
@ -306,7 +383,9 @@ export function OssFlow() {
onClick={() => setIsContextMenuOpen(false)} onClick={() => setIsContextMenuOpen(false)}
onNodeDoubleClick={handleNodeDoubleClick} onNodeDoubleClick={handleNodeDoubleClick}
onNodeContextMenu={handleContextMenu} onNodeContextMenu={handleContextMenu}
onNodeDragStart={() => setIsContextMenuOpen(false)} onNodeDragStart={handleDragStart}
onNodeDrag={handleDrag}
onNodeDragStop={handleDragStop}
> >
{showGrid ? <Background gap={GRID_SIZE} /> : null} {showGrid ? <Background gap={GRID_SIZE} /> : null}
</ReactFlow> </ReactFlow>

View File

@ -304,6 +304,10 @@
outline-color: var(--color-graph-selected); outline-color: var(--color-graph-selected);
border-color: var(--color-foreground); border-color: var(--color-foreground);
} }
.cursor-relocate .dragging & {
cursor: move;
}
} }
@utility cc-node-block { @utility cc-node-block {
@ -326,4 +330,8 @@
&:hover { &:hover {
color: var(--color-foreground); color: var(--color-foreground);
} }
.cursor-relocate .dragging & {
cursor: move;
}
} }