From 32b8a480d62ff0bfbe5ba3226218487369538956 Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Tue, 29 Apr 2025 13:03:30 +0300 Subject: [PATCH] R: Improve ossFlow structure --- .../editor-oss-graph/context-menu/index.tsx | 1 + .../context-menu/use-context-menu.ts | 48 +++ .../editor-oss-graph/coordinate-display.tsx | 16 + .../editor-oss-graph/graph/block-node.tsx | 5 +- .../editor-oss-graph/oss-flow-context.tsx | 14 +- .../editor-oss-graph/oss-flow-state.tsx | 129 +++++++- .../oss-page/editor-oss-graph/oss-flow.tsx | 278 ++---------------- .../editor-oss-graph/toolbar-oss-graph.tsx | 12 +- .../editor-oss-graph/use-dragging.tsx | 102 +++++++ .../editor-oss-graph/use-drop-target.tsx | 62 ++++ rsconcept/frontend/src/stores/dragging.ts | 15 + 11 files changed, 402 insertions(+), 280 deletions(-) create mode 100644 rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/use-context-menu.ts create mode 100644 rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/coordinate-display.tsx create mode 100644 rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-dragging.tsx create mode 100644 rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-drop-target.tsx create mode 100644 rsconcept/frontend/src/stores/dragging.ts diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/index.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/index.tsx index f628d09c..61dc369c 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/index.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/index.tsx @@ -1 +1,2 @@ export { ContextMenu } from './context-menu'; +export { useContextMenu } from './use-context-menu'; diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/use-context-menu.ts b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/use-context-menu.ts new file mode 100644 index 00000000..b07a5b95 --- /dev/null +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/context-menu/use-context-menu.ts @@ -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({ + item: null, + cursorX: 0, + cursorY: 0 + }); + + const setHoverOperation = useOperationTooltipStore(state => state.setHoverItem); + const { addSelectedNodes } = useStoreApi().getState(); + + function handleContextMenu(event: React.MouseEvent, 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 + }; +} diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/coordinate-display.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/coordinate-display.tsx new file mode 100644 index 00000000..5256878d --- /dev/null +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/coordinate-display.tsx @@ -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 ( +
+ {`X: ${mouseCoords.x.toFixed(0)} Y: ${mouseCoords.y.toFixed(0)}`} +
+ ); +} 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 231b9851..5f474eed 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 @@ -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); 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 index 71f0bfc9..b5aaafcd 100644 --- 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 @@ -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>; - dropTarget: number | null; - setDropTarget: React.Dispatch>; containMovement: boolean; setContainMovement: React.Dispatch>; + + nodes: Node[]; + setNodes: React.Dispatch>; + onNodesChange: (changes: NodeChange[]) => void; + edges: Edge[]; + setEdges: React.Dispatch>; + onEdgesChange: (changes: EdgeChange[]) => void; + resetGraph: () => void; + resetView: () => void; } export const OssFlowContext = createContext(null); 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 index a74e0487..63d277e0 100644 --- 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 @@ -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(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([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + 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 ( {children} ); }; + +// ====== 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 + }; +} 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 e72026b9..f99c427e 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 @@ -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({ item: null, cursorX: 0, cursorY: 0 }); - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const [mouseCoords, setMouseCoords] = useState({ 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, 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, 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 (
- {showCoordinates ? ( -
- {`X: ${mouseCoords.x.toFixed(0)} Y: ${mouseCoords.y.toFixed(0)}`} -
- ) : null} + {showCoordinates ? : null} setToggleReset(prev => !prev)} + onResetPositions={resetGraph} /> - setIsContextMenuOpen(false)} {...menuProps} /> +
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; diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx index 342c9b95..0dd13ea8 100644 --- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/toolbar-oss-graph.tsx @@ -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({ } - onClick={handleFitView} + onClick={resetView} /> 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 + }; +} diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-drop-target.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-drop-target.tsx new file mode 100644 index 00000000..0029fe1d --- /dev/null +++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-graph/use-drop-target.tsx @@ -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 }; +} diff --git a/rsconcept/frontend/src/stores/dragging.ts b/rsconcept/frontend/src/stores/dragging.ts new file mode 100644 index 00000000..42914a50 --- /dev/null +++ b/rsconcept/frontend/src/stores/dragging.ts @@ -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()(set => ({ + isDragging: false, + setIsDragging: value => set({ isDragging: value }), + dropTarget: null, + setDropTarget: value => set({ dropTarget: value }) +}));