mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-25 20:40:36 +03:00
R: Improve ossFlow structure
This commit is contained in:
parent
309c1ba323
commit
32b8a480d6
|
@ -1 +1,2 @@
|
|||
export { ContextMenu } from './context-menu';
|
||||
export { useContextMenu } from './use-context-menu';
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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='Исправить позиции узлов'
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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 };
|
||||
}
|
15
rsconcept/frontend/src/stores/dragging.ts
Normal file
15
rsconcept/frontend/src/stores/dragging.ts
Normal 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 })
|
||||
}));
|
Loading…
Reference in New Issue
Block a user