R: Improve ossFlow structure

This commit is contained in:
Ivan 2025-04-29 13:03:30 +03:00
parent 309c1ba323
commit 32b8a480d6
11 changed files with 402 additions and 280 deletions

View File

@ -1 +1,2 @@
export { ContextMenu } from './context-menu';
export { useContextMenu } from './use-context-menu';

View File

@ -0,0 +1,48 @@
'use client';
import { useState } from 'react';
import { useStoreApi } from 'reactflow';
import { type OssNode } from '../../../../models/oss-layout';
import { useOperationTooltipStore } from '../../../../stores/operation-tooltip';
import { type ContextMenuData } from './context-menu';
export function useContextMenu() {
const [isOpen, setIsOpen] = useState(false);
const [menuProps, setMenuProps] = useState<ContextMenuData>({
item: null,
cursorX: 0,
cursorY: 0
});
const setHoverOperation = useOperationTooltipStore(state => state.setHoverItem);
const { addSelectedNodes } = useStoreApi().getState();
function handleContextMenu(event: React.MouseEvent<Element>, node: OssNode) {
event.preventDefault();
event.stopPropagation();
addSelectedNodes([node.id]);
setMenuProps({
item: node.type === 'block' ? node.data.block ?? null : node.data.operation ?? null,
cursorX: event.clientX,
cursorY: event.clientY
});
setIsOpen(true);
setHoverOperation(null);
}
function hideContextMenu() {
setIsOpen(false);
}
return {
isOpen,
menuProps,
handleContextMenu,
hideContextMenu
};
}

View File

@ -0,0 +1,16 @@
import { type Position2D } from '@/features/oss/models/oss-layout';
import { cn } from '@/components/utils';
interface CoordinateDisplayProps {
mouseCoords: Position2D;
className: string;
}
export function CoordinateDisplay({ mouseCoords, className }: CoordinateDisplayProps) {
return (
<div className={cn('hover:bg-background backdrop-blur-xs text-sm font-math', className)}>
{`X: ${mouseCoords.x.toFixed(0)} Y: ${mouseCoords.y.toFixed(0)}`}
</div>
);
}

View File

@ -4,20 +4,21 @@ import { NodeResizeControl } from 'reactflow';
import clsx from 'clsx';
import { IconResize } from '@/components/icons';
import { useDraggingStore } from '@/stores/dragging';
import { globalIDs } from '@/utils/constants';
import { type BlockInternalNode } from '../../../../models/oss-layout';
import { useOperationTooltipStore } from '../../../../stores/operation-tooltip';
import { useOSSGraphStore } from '../../../../stores/oss-graph';
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, isDragging } = useOssFlow();
const dropTarget = useDraggingStore(state => state.dropTarget);
const isDragging = useDraggingStore(state => state.isDragging);
const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const setHover = useOperationTooltipStore(state => state.setHoverItem);

View File

@ -1,14 +1,20 @@
'use client';
import { createContext, use } from 'react';
import { type Edge, type EdgeChange, type Node, type NodeChange } from 'reactflow';
interface IOssFlowContext {
isDragging: boolean;
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;
dropTarget: number | null;
setDropTarget: React.Dispatch<React.SetStateAction<number | null>>;
containMovement: boolean;
setContainMovement: React.Dispatch<React.SetStateAction<boolean>>;
nodes: Node[];
setNodes: React.Dispatch<React.SetStateAction<Node[]>>;
onNodesChange: (changes: NodeChange[]) => void;
edges: Edge[];
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
onEdgesChange: (changes: EdgeChange[]) => void;
resetGraph: () => void;
resetView: () => void;
}
export const OssFlowContext = createContext<IOssFlowContext | null>(null);

View File

@ -1,28 +1,137 @@
'use client';
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { type Edge, type Node, useEdgesState, useNodesState, useOnSelectionChange, useReactFlow } from 'reactflow';
import { type IOperationSchema } from '@/features/oss/models/oss';
import { type Position2D } from '@/features/oss/models/oss-layout';
import { useOSSGraphStore } from '@/features/oss/stores/oss-graph';
import { PARAMETER } from '@/utils/constants';
import { useOssEdit } from '../oss-edit-context';
import { OssFlowContext } from './oss-flow-context';
const VIEW_PADDING = 0.2;
const Z_BLOCK = 1;
const Z_SCHEMA = 10;
// TODO: decouple nodes and edges from controller callbacks
export const OssFlowState = ({ children }: React.PropsWithChildren) => {
const [isDragging, setIsDragging] = useState(false);
const [dropTarget, setDropTarget] = useState<number | null>(null);
const { schema, setSelected } = useOssEdit();
const { fitView } = useReactFlow();
const edgeAnimate = useOSSGraphStore(state => state.edgeAnimate);
const edgeStraight = useOSSGraphStore(state => state.edgeStraight);
const [containMovement, setContainMovement] = useState(false);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
function onSelectionChange({ nodes }: { nodes: Node[] }) {
const ids = nodes.map(node => Number(node.id));
setSelected(prev => [
...prev.filter(nodeID => ids.includes(nodeID)),
...ids.filter(nodeID => !prev.includes(Number(nodeID)))
]);
}
useOnSelectionChange({
onChange: onSelectionChange
});
const resetGraph = useCallback(() => {
const newNodes: Node[] = [
...schema.hierarchy
.topologicalOrder()
.filter(id => id < 0)
.map(id => {
const block = schema.blockByID.get(-id)!;
return {
id: String(id),
type: 'block',
data: { label: block.title, block: block },
position: computeRelativePosition(schema, { x: block.x, y: block.y }, block.parent),
style: {
width: block.width,
height: block.height
},
parentId: block.parent ? `-${block.parent}` : undefined,
zIndex: Z_BLOCK
};
}),
...schema.operations.map(operation => ({
id: String(operation.id),
type: operation.operation_type.toString(),
data: { label: operation.alias, operation: operation },
position: computeRelativePosition(schema, { x: operation.x, y: operation.y }, operation.parent),
parentId: operation.parent ? `-${operation.parent}` : undefined,
zIndex: Z_SCHEMA
}))
];
const newEdges: Edge[] = schema.arguments.map((argument, index) => ({
id: String(index),
source: String(argument.argument),
target: String(argument.operation),
type: edgeStraight ? 'straight' : 'simplebezier',
animated: edgeAnimate,
targetHandle:
schema.operationByID.get(argument.argument)!.x > schema.operationByID.get(argument.operation)!.x
? 'right'
: 'left'
}));
setNodes(newNodes);
setEdges(newEdges);
setTimeout(() => fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING }), PARAMETER.refreshTimeout);
}, [schema, setNodes, setEdges, edgeAnimate, edgeStraight, fitView]);
useEffect(() => {
resetGraph();
}, [schema, edgeAnimate, edgeStraight, resetGraph]);
function resetView() {
setTimeout(() => fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING }), PARAMETER.refreshTimeout);
}
return (
<OssFlowContext
value={{
isDragging,
setIsDragging,
dropTarget,
setDropTarget,
containMovement,
setContainMovement
setContainMovement,
nodes,
setNodes,
onNodesChange,
edges,
setEdges,
onEdgesChange,
resetView,
resetGraph
}}
>
{children}
</OssFlowContext>
);
};
// ====== Internals =========
function computeRelativePosition(schema: IOperationSchema, position: Position2D, parent: number | null): Position2D {
if (!parent) {
return position;
}
const parentBlock = schema.blockByID.get(parent);
if (!parentBlock) {
return position;
}
return {
x: position.x - parentBlock.x,
y: position.y - parentBlock.y
};
}

View File

@ -1,85 +1,49 @@
'use client';
import { useEffect, useState } from 'react';
import {
Background,
type Node,
ReactFlow,
useEdgesState,
useNodesState,
useOnSelectionChange,
useReactFlow,
useStoreApi
} from 'reactflow';
import { useState } from 'react';
import { Background, ReactFlow, useReactFlow, useStoreApi } from 'reactflow';
import clsx from 'clsx';
import { useThrottleCallback } from '@/hooks/use-throttle-callback';
import { useMainHeight } from '@/stores/app-layout';
import { useDialogsStore } from '@/stores/dialogs';
import { PARAMETER } from '@/utils/constants';
import { promptText } from '@/utils/labels';
import { useDeleteBlock } from '../../../backend/use-delete-block';
import { useMoveItems } from '../../../backend/use-move-items';
import { useMutatingOss } from '../../../backend/use-mutating-oss';
import { useUpdateLayout } from '../../../backend/use-update-layout';
import { type IOperationSchema } from '../../../models/oss';
import { type OssNode, type Position2D } from '../../../models/oss-layout';
import { GRID_SIZE, LayoutManager } from '../../../models/oss-layout-api';
import { useOperationTooltipStore } from '../../../stores/operation-tooltip';
import { useOSSGraphStore } from '../../../stores/oss-graph';
import { useOssEdit } from '../oss-edit-context';
import { ContextMenu, type ContextMenuData } from './context-menu/context-menu';
import { ContextMenu } from './context-menu/context-menu';
import { useContextMenu } from './context-menu/use-context-menu';
import { OssNodeTypes } from './graph/oss-node-types';
import { CoordinateDisplay } from './coordinate-display';
import { useOssFlow } from './oss-flow-context';
import { ToolbarOssGraph } from './toolbar-oss-graph';
import { useDragging } from './use-dragging';
import { useGetLayout } from './use-get-layout';
const ZOOM_MAX = 2;
const ZOOM_MIN = 0.5;
const Z_BLOCK = 1;
const Z_SCHEMA = 10;
const DRAG_THROTTLE_DELAY = 50; // ms
export const VIEW_PADDING = 0.2;
export function OssFlow() {
const mainHeight = useMainHeight();
const {
navigateOperationSchema,
schema,
setSelected,
selected,
isMutable,
canDeleteOperation: canDelete
} = useOssEdit();
const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow();
const { setDropTarget, setContainMovement, containMovement, setIsDragging } = useOssFlow();
const { navigateOperationSchema, schema, selected, isMutable, canDeleteOperation } = useOssEdit();
const { screenToFlowPosition } = useReactFlow();
const { containMovement, nodes, onNodesChange, edges, onEdgesChange, resetGraph, resetView } = useOssFlow();
const store = useStoreApi();
const { resetSelectedElements, addSelectedNodes } = store.getState();
const { resetSelectedElements } = store.getState();
const isProcessing = useMutatingOss();
const setHoverOperation = useOperationTooltipStore(state => state.setHoverItem);
const showGrid = useOSSGraphStore(state => state.showGrid);
const showCoordinates = useOSSGraphStore(state => state.showCoordinates);
const edgeAnimate = useOSSGraphStore(state => state.edgeAnimate);
const edgeStraight = useOSSGraphStore(state => state.edgeStraight);
const getLayout = useGetLayout();
const { updateLayout } = useUpdateLayout();
const { deleteBlock } = useDeleteBlock();
const { moveItems } = useMoveItems();
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [toggleReset, setToggleReset] = useState(false);
const [menuProps, setMenuProps] = useState<ContextMenuData>({ item: null, cursorX: 0, cursorY: 0 });
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [mouseCoords, setMouseCoords] = useState<Position2D>({ x: 0, y: 0 });
@ -88,62 +52,8 @@ export function OssFlow() {
const showDeleteOperation = useDialogsStore(state => state.showDeleteOperation);
const showEditBlock = useDialogsStore(state => state.showEditBlock);
function onSelectionChange({ nodes }: { nodes: Node[] }) {
const ids = nodes.map(node => Number(node.id));
setSelected(prev => [
...prev.filter(nodeID => ids.includes(nodeID)),
...ids.filter(nodeID => !prev.includes(Number(nodeID)))
]);
}
useOnSelectionChange({
onChange: onSelectionChange
});
useEffect(() => {
setNodes([
...schema.hierarchy
.topologicalOrder()
.filter(id => id < 0)
.map(id => {
const block = schema.blockByID.get(-id)!;
return {
id: String(id),
type: 'block',
data: { label: block.title, block: block },
position: computeRelativePosition(schema, { x: block.x, y: block.y }, block.parent),
style: {
width: block.width,
height: block.height
},
parentId: block.parent ? `-${block.parent}` : undefined,
zIndex: Z_BLOCK
};
}),
...schema.operations.map(operation => ({
id: String(operation.id),
type: operation.operation_type.toString(),
data: { label: operation.alias, operation: operation },
position: computeRelativePosition(schema, { x: operation.x, y: operation.y }, operation.parent),
parentId: operation.parent ? `-${operation.parent}` : undefined,
zIndex: Z_SCHEMA
}))
]);
setEdges(
schema.arguments.map((argument, index) => ({
id: String(index),
source: String(argument.argument),
target: String(argument.operation),
type: edgeStraight ? 'straight' : 'simplebezier',
animated: edgeAnimate,
targetHandle:
schema.operationByID.get(argument.argument)!.x > schema.operationByID.get(argument.operation)!.x
? 'right'
: 'left'
}))
);
setTimeout(() => fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING }), PARAMETER.refreshTimeout);
}, [schema, setNodes, setEdges, toggleReset, edgeStraight, edgeAnimate, fitView]);
const { isOpen: isContextMenuOpen, menuProps, handleContextMenu, hideContextMenu } = useContextMenu();
const { handleDragStart, handleDrag, handleDragStop } = useDragging({ hideContextMenu });
function handleSavePositions() {
void updateLayout({ itemID: schema.id, data: getLayout() });
@ -157,8 +67,7 @@ export function OssFlow() {
defaultY: targetPosition.y,
initialInputs: selected.filter(id => id > 0),
initialParent: extractSingleBlock(selected),
onCreate: () =>
setTimeout(() => fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING }), PARAMETER.refreshTimeout)
onCreate: resetView
});
}
@ -169,8 +78,7 @@ export function OssFlow() {
defaultX: targetPosition.x,
defaultY: targetPosition.y,
initialInputs: selected,
onCreate: () =>
setTimeout(() => fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING }), PARAMETER.refreshTimeout)
onCreate: resetView
});
}
@ -180,7 +88,7 @@ export function OssFlow() {
}
if (selected[0] > 0) {
const operation = schema.operationByID.get(selected[0]);
if (!operation || !canDelete(operation)) {
if (!operation || !canDeleteOperation(operation)) {
return;
}
showDeleteOperation({
@ -200,21 +108,6 @@ export function OssFlow() {
}
}
function handleContextMenu(event: React.MouseEvent<Element>, node: OssNode) {
event.preventDefault();
event.stopPropagation();
addSelectedNodes([node.id]);
setMenuProps({
item: node.type === 'block' ? node.data.block ?? null : node.data.operation ?? null,
cursorX: event.clientX,
cursorY: event.clientY
});
setIsContextMenuOpen(true);
setHoverOperation(null);
}
function handleNodeDoubleClick(event: React.MouseEvent<Element>, node: OssNode) {
event.preventDefault();
event.stopPropagation();
@ -276,114 +169,6 @@ export function OssFlow() {
setMouseCoords(targetPosition);
}
function determineDropTarget(event: React.MouseEvent): number | null {
const mousePosition = screenToFlowPosition({ x: event.clientX, y: event.clientY });
let blocks = getIntersectingNodes({
x: mousePosition.x,
y: mousePosition.y,
width: 1,
height: 1
})
.map(node => Number(node.id))
.filter(id => id < 0 && !selected.includes(id))
.map(id => schema.blockByID.get(-id))
.filter(block => !!block);
if (blocks.length === 0) {
return null;
}
const successors = schema.hierarchy.expandAllOutputs([...selected]).filter(id => id < 0);
blocks = blocks.filter(block => !successors.includes(-block.id));
if (blocks.length === 0) {
return null;
}
if (blocks.length === 1) {
return blocks[0].id;
}
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) {
return null;
} else {
return potentialTargets[0];
}
}
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);
setDropTarget(determineDropTarget(event));
}
setIsContextMenuOpen(false);
}
const handleDrag = useThrottleCallback((event: React.MouseEvent) => {
if (containMovement) {
return;
}
setIsDragging(true);
setDropTarget(determineDropTarget(event));
}, DRAG_THROTTLE_DELAY);
function handleDragStop(event: 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 {
const new_parent = determineDropTarget(event);
const allSelected = [...selected.filter(id => id != Number(target.id)), Number(target.id)];
const operations = allSelected
.filter(id => id > 0)
.map(id => schema.operationByID.get(id))
.filter(operation => !!operation);
const blocks = allSelected
.filter(id => id < 0)
.map(id => schema.blockByID.get(-id))
.filter(operation => !!operation);
const parents = [...blocks.map(block => block.parent), ...operations.map(operation => operation.parent)].filter(
id => !!id
);
if ((parents.length !== 1 || parents[0] !== new_parent) && (parents.length !== 0 || new_parent !== null)) {
void moveItems({
itemID: schema.id,
data: {
layout: getLayout(),
operations: operations.map(operation => operation.id),
blocks: blocks.map(block => block.id),
destination: new_parent
}
});
}
}
setIsDragging(false);
setContainMovement(false);
setDropTarget(null);
}
return (
<div
tabIndex={-1}
@ -391,20 +176,16 @@ export function OssFlow() {
onKeyDown={handleKeyDown}
onMouseMove={showCoordinates ? handleMouseMove : undefined}
>
{showCoordinates ? (
<div className='absolute top-1 right-2 hover:bg-background backdrop-blur-xs text-sm font-math'>
{`X: ${mouseCoords.x.toFixed(0)} Y: ${mouseCoords.y.toFixed(0)}`}
</div>
) : null}
{showCoordinates ? <CoordinateDisplay mouseCoords={mouseCoords} className='absolute top-1 right-2' /> : null}
<ToolbarOssGraph
className='absolute z-pop top-8 right-1/2 translate-x-1/2'
onCreateOperation={handleCreateOperation}
onCreateBlock={handleCreateBlock}
onDelete={handleDeleteSelected}
onResetPositions={() => setToggleReset(prev => !prev)}
onResetPositions={resetGraph}
/>
<ContextMenu isOpen={isContextMenuOpen} onHide={() => setIsContextMenuOpen(false)} {...menuProps} />
<ContextMenu isOpen={isContextMenuOpen} onHide={hideContextMenu} {...menuProps} />
<div
className={clsx('relative w-[100vw] cc-mask-sides', !containMovement && 'cursor-relocate')}
@ -424,10 +205,13 @@ export function OssFlow() {
nodesConnectable={false}
snapToGrid={true}
snapGrid={[GRID_SIZE, GRID_SIZE]}
onClick={() => setIsContextMenuOpen(false)}
onClick={hideContextMenu}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeContextMenu={handleContextMenu}
onContextMenu={event => event.preventDefault()}
onContextMenu={event => {
event.preventDefault();
hideContextMenu();
}}
onNodeDragStart={handleDragStart}
onNodeDrag={handleDrag}
onNodeDragStop={handleDragStop}
@ -440,22 +224,6 @@ export function OssFlow() {
}
// -------- Internals --------
function computeRelativePosition(schema: IOperationSchema, position: Position2D, parent: number | null): Position2D {
if (!parent) {
return position;
}
const parentBlock = schema.blockByID.get(parent);
if (!parentBlock) {
return position;
}
return {
x: position.x - parentBlock.x,
y: position.y - parentBlock.y
};
}
function extractSingleBlock(selected: number[]): number | null {
const blocks = selected.filter(id => id < 0);
return blocks.length === 1 ? -blocks[0] : null;

View File

@ -1,7 +1,6 @@
'use client';
import { toast } from 'react-toastify';
import { useReactFlow } from 'reactflow';
import { HelpTopic } from '@/features/help';
import { BadgeHelp } from '@/features/help/components';
@ -22,7 +21,6 @@ import {
import { type Styling } from '@/components/props';
import { cn } from '@/components/utils';
import { useDialogsStore } from '@/stores/dialogs';
import { PARAMETER } from '@/utils/constants';
import { prepareTooltip } from '@/utils/utils';
import { OperationType } from '../../../backend/types';
@ -32,7 +30,7 @@ import { useUpdateLayout } from '../../../backend/use-update-layout';
import { LayoutManager } from '../../../models/oss-layout-api';
import { useOssEdit } from '../oss-edit-context';
import { VIEW_PADDING } from './oss-flow';
import { useOssFlow } from './oss-flow-context';
import { useGetLayout } from './use-get-layout';
interface ToolbarOssGraphProps extends Styling {
@ -52,7 +50,7 @@ export function ToolbarOssGraph({
}: ToolbarOssGraphProps) {
const { schema, selected, isMutable, canDeleteOperation: canDelete } = useOssEdit();
const isProcessing = useMutatingOss();
const { fitView } = useReactFlow();
const { resetView } = useOssFlow();
const selectedOperation = selected.length !== 1 ? null : schema.operationByID.get(selected[0]) ?? null;
const selectedBlock = selected.length !== 1 ? null : schema.blockByID.get(-selected[0]) ?? null;
const getLayout = useGetLayout();
@ -85,10 +83,6 @@ export function ToolbarOssGraph({
return true;
})();
function handleFitView() {
fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING });
}
function handleFixLayout() {
// TODO: implement layout algorithm
toast.info('Еще не реализовано');
@ -145,7 +139,7 @@ export function ToolbarOssGraph({
<MiniButton
title='Сбросить вид'
icon={<IconFitImage size='1.25rem' className='icon-primary' />}
onClick={handleFitView}
onClick={resetView}
/>
<MiniButton
title='Исправить позиции узлов'

View File

@ -0,0 +1,102 @@
import { type Node } from 'reactflow';
import { useMoveItems } from '@/features/oss/backend/use-move-items';
import { useThrottleCallback } from '@/hooks/use-throttle-callback';
import { useDraggingStore } from '@/stores/dragging';
import { useOssEdit } from '../oss-edit-context';
import { useOssFlow } from './oss-flow-context';
import { useDropTarget } from './use-drop-target';
import { useGetLayout } from './use-get-layout';
const DRAG_THROTTLE_DELAY = 50; // ms
interface DraggingProps {
hideContextMenu: () => void;
}
/** Hook to encapsulate dragging logic. */
export function useDragging({ hideContextMenu }: DraggingProps) {
const { setContainMovement, containMovement, setNodes } = useOssFlow();
const setIsDragging = useDraggingStore(state => state.setIsDragging);
const getLayout = useGetLayout();
const { selected, schema } = useOssEdit();
const dropTarget = useDropTarget();
const { moveItems } = useMoveItems();
function applyContainMovement(target: string[], value: boolean) {
setNodes(prev =>
prev.map(node =>
target.includes(node.id)
? {
...node,
extent: value ? 'parent' : undefined,
expandParent: value ? true : undefined
}
: node
)
);
}
function handleDragStart(event: React.MouseEvent, target: Node) {
if (event.shiftKey) {
setContainMovement(true);
applyContainMovement([target.id, ...selected.map(id => String(id))], true);
} else {
setContainMovement(false);
dropTarget.update(event);
}
hideContextMenu();
}
const handleDrag = useThrottleCallback((event: React.MouseEvent) => {
if (containMovement) {
return;
}
setIsDragging(true);
dropTarget.update(event);
}, DRAG_THROTTLE_DELAY);
function handleDragStop(event: React.MouseEvent, target: Node) {
if (containMovement) {
applyContainMovement([target.id, ...selected.map(id => String(id))], false);
} else {
const new_parent = dropTarget.evaluate(event);
const allSelected = [...selected.filter(id => id != Number(target.id)), Number(target.id)];
const operations = allSelected
.filter(id => id > 0)
.map(id => schema.operationByID.get(id))
.filter(operation => !!operation);
const blocks = allSelected
.filter(id => id < 0)
.map(id => schema.blockByID.get(-id))
.filter(operation => !!operation);
const parents = [...blocks.map(block => block.parent), ...operations.map(operation => operation.parent)].filter(
id => !!id
);
if ((parents.length !== 1 || parents[0] !== new_parent) && (parents.length !== 0 || new_parent !== null)) {
void moveItems({
itemID: schema.id,
data: {
layout: getLayout(),
operations: operations.map(operation => operation.id),
blocks: blocks.map(block => block.id),
destination: new_parent
}
});
}
}
setIsDragging(false);
setContainMovement(false);
dropTarget.reset();
}
return {
handleDragStart,
handleDrag,
handleDragStop
};
}

View File

@ -0,0 +1,62 @@
import { useReactFlow } from 'reactflow';
import { useDraggingStore } from '@/stores/dragging';
import { useOssEdit } from '../oss-edit-context';
/** Hook to encapsulate drop target logic. */
export function useDropTarget() {
const { getIntersectingNodes, screenToFlowPosition } = useReactFlow();
const { selected, schema } = useOssEdit();
const dropTarget = useDraggingStore(state => state.dropTarget);
const setDropTarget = useDraggingStore(state => state.setDropTarget);
function evaluate(event: React.MouseEvent): number | null {
const mousePosition = screenToFlowPosition({ x: event.clientX, y: event.clientY });
let blocks = getIntersectingNodes({
x: mousePosition.x,
y: mousePosition.y,
width: 1,
height: 1
})
.map(node => Number(node.id))
.filter(id => id < 0 && !selected.includes(id))
.map(id => schema.blockByID.get(-id))
.filter(block => !!block);
if (blocks.length === 0) {
return null;
}
const successors = schema.hierarchy.expandAllOutputs([...selected]).filter(id => id < 0);
blocks = blocks.filter(block => !successors.includes(-block.id));
if (blocks.length === 0) {
return null;
}
if (blocks.length === 1) {
return blocks[0].id;
}
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) {
return null;
} else {
return potentialTargets[0];
}
}
function update(event: React.MouseEvent) {
setDropTarget(evaluate(event));
}
function reset() {
setDropTarget(null);
}
function get() {
return dropTarget;
}
return { get, update, reset, evaluate };
}

View File

@ -0,0 +1,15 @@
import { create } from 'zustand';
interface DraggingStore {
isDragging: boolean;
setIsDragging: (value: boolean) => void;
dropTarget: number | null;
setDropTarget: (value: number | null) => void;
}
export const useDraggingStore = create<DraggingStore>()(set => ({
isDragging: false,
setIsDragging: value => set({ isDragging: value }),
dropTarget: null,
setDropTarget: value => set({ dropTarget: value })
}));