diff --git a/rsconcept/frontend/src/features/help/items/ui/help-oss-graph.tsx b/rsconcept/frontend/src/features/help/items/ui/help-oss-graph.tsx index 7e096958..84ac701c 100644 --- a/rsconcept/frontend/src/features/help/items/ui/help-oss-graph.tsx +++ b/rsconcept/frontend/src/features/help/items/ui/help-oss-graph.tsx @@ -111,7 +111,10 @@ export function HelpOssGraph() { Space – перемещение экрана
  • - Shift – перемещение в границах блока + Shift – расширять границы +
  • +
  • + Стрелки – выделение
  • diff --git a/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts b/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts index 56da2d66..23690e1c 100644 --- a/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts +++ b/rsconcept/frontend/src/features/oss/models/oss-layout-api.ts @@ -7,7 +7,7 @@ import { type IOssLayout } from '../backend/types'; -import { type IOperationSchema } from './oss'; +import { type IOperationSchema, NodeType } from './oss'; import { type Position2D, type Rectangle2D } from './oss-layout'; export const GRID_SIZE = 10; // pixels - size of OSS grid @@ -132,6 +132,100 @@ export class LayoutManager { this.extendParentBounds(parentNode, targetNode); } + /** Calculate closest node to the left */ + selectLeft(targetID: string): string | null { + const targetNode = this.layout.find(pos => pos.nodeID === targetID); + if (!targetNode) { + return null; + } + const operationNodes = this.layout.filter(pos => pos.nodeID !== targetID && pos.nodeID.startsWith('o')); + const leftNodes = operationNodes.filter(pos => pos.x <= targetNode.x); + if (leftNodes.length === 0) { + return null; + } + const similarYNodes = leftNodes.filter(pos => Math.abs(pos.y - targetNode.y) <= MIN_DISTANCE); + let closestNode: typeof targetNode | null = null; + if (similarYNodes.length > 0) { + closestNode = similarYNodes.reduce((prev, curr) => (curr.x > prev.x ? curr : prev)); + } else { + closestNode = findClosestNodeByDistance(leftNodes, targetNode); + } + return closestNode?.nodeID ?? null; + } + + /** Calculate closest node to the right */ + selectRight(targetID: string): string | null { + const targetNode = this.layout.find(pos => pos.nodeID === targetID); + if (!targetNode) { + return null; + } + const operationNodes = this.layout.filter(pos => pos.nodeID !== targetID && pos.nodeID.startsWith('o')); + const rightNodes = operationNodes.filter(pos => pos.x >= targetNode.x); + if (rightNodes.length === 0) { + return null; + } + const similarYNodes = rightNodes.filter(pos => Math.abs(pos.y - targetNode.y) <= MIN_DISTANCE); + let closestNode: typeof targetNode | null = null; + if (similarYNodes.length > 0) { + closestNode = similarYNodes.reduce((prev, curr) => (curr.x < prev.x ? curr : prev)); + } else { + closestNode = findClosestNodeByDistance(rightNodes, targetNode); + } + return closestNode?.nodeID ?? null; + } + + /** Calculate closest node upwards */ + selectUp(targetID: string): string | null { + const targetNode = this.layout.find(pos => pos.nodeID === targetID); + if (!targetNode) { + return null; + } + + const operationNodes = this.layout.filter(pos => pos.nodeID !== targetID && pos.nodeID.startsWith('o')); + const upperNodes = operationNodes.filter(pos => pos.y <= targetNode.y - MIN_DISTANCE); + const targetOperation = this.oss.itemByNodeID.get(targetID); + if (upperNodes.length === 0 || !targetOperation || targetOperation.nodeType === NodeType.BLOCK) { + return null; + } + + const predecessors = this.oss.graph.expandAllInputs([targetOperation.id]); + const predecessorNodes = upperNodes.filter(pos => predecessors.includes(Number(pos.nodeID.slice(1)))); + + let closestNode: typeof targetNode | null = null; + if (predecessorNodes.length > 0) { + closestNode = findClosestNodeByDistance(predecessorNodes, targetNode); + } else { + closestNode = findClosestNodeByDistance(upperNodes, targetNode); + } + return closestNode?.nodeID ?? null; + } + + /** Calculate closest node downwards */ + selectDown(targetID: string): string | null { + const targetNode = this.layout.find(pos => pos.nodeID === targetID); + if (!targetNode) { + return null; + } + + const operationNodes = this.layout.filter(pos => pos.nodeID !== targetID && pos.nodeID.startsWith('o')); + const lowerNodes = operationNodes.filter(pos => pos.y >= targetNode.y - MIN_DISTANCE); + const targetOperation = this.oss.itemByNodeID.get(targetID); + if (lowerNodes.length === 0 || !targetOperation || targetOperation.nodeType === NodeType.BLOCK) { + return null; + } + + const descendants = this.oss.graph.expandAllOutputs([targetOperation.id]); + const descendantsNodes = lowerNodes.filter(pos => descendants.includes(Number(pos.nodeID.slice(1)))); + + let closestNode: typeof targetNode | null = null; + if (descendantsNodes.length > 0) { + closestNode = findClosestNodeByDistance(descendantsNodes, targetNode); + } else { + closestNode = findClosestNodeByDistance(lowerNodes, targetNode); + } + return closestNode?.nodeID ?? null; + } + private extendParentBounds(parent: INodePosition | null, child: Rectangle2D) { if (!parent) { return; @@ -264,3 +358,16 @@ function calculatePositionFromChildren( height: bottom - top }; } + +function findClosestNodeByDistance(nodes: INodePosition[], target: INodePosition): INodePosition | null { + let minDist = Infinity; + let minNode = null; + for (const curr of nodes) { + const currDist = Math.hypot(curr.x - target.x, curr.y - target.y); + if (currDist < minDist) { + minDist = currDist; + minNode = curr; + } + } + return minNode; +} 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 eef62b8e..3d15bed5 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,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { type Edge, type Node, useEdgesState, useNodesState, useOnSelectionChange, useReactFlow } from 'reactflow'; import { PARAMETER } from '@/utils/constants'; @@ -18,8 +18,8 @@ const Z_BLOCK = 1; const Z_SCHEMA = 10; export const OssFlowState = ({ children }: React.PropsWithChildren) => { - const { schema, setSelected } = useOssEdit(); - const { fitView } = useReactFlow(); + const { schema, selected, setSelected } = useOssEdit(); + const { fitView, viewportInitialized } = useReactFlow(); const edgeAnimate = useOSSGraphStore(state => state.edgeAnimate); const edgeStraight = useOSSGraphStore(state => state.edgeStraight); @@ -95,6 +95,20 @@ export const OssFlowState = ({ children }: React.PropsWithChildren) => { setTimeout(() => fitView(flowOptions.fitViewOptions), PARAMETER.refreshTimeout); } + const prevSelected = useRef([]); + if ( + viewportInitialized && + (prevSelected.current.length !== selected.length || prevSelected.current.some((id, i) => id !== selected[i])) + ) { + prevSelected.current = selected; + setNodes(prev => + prev.map(node => ({ + ...node, + selected: selected.includes(node.id) + })) + ); + } + return ( item.nodeType === NodeType.OPERATION); + if (!selectedOperation) { + return; + } + const manager = new LayoutManager(schema, getLayout()); + const newNodeID = manager.selectLeft(selectedOperation.nodeID); + if (newNodeID) { + setSelected([newNodeID]); + } + } + + function handleSelectRight() { + const selectedOperation = selectedItems.find(item => item.nodeType === NodeType.OPERATION); + if (!selectedOperation) { + return; + } + const manager = new LayoutManager(schema, getLayout()); + const newNodeID = manager.selectRight(selectedOperation.nodeID); + if (newNodeID) { + setSelected([newNodeID]); + } + } + function handleSelectUp() { + const selectedOperation = selectedItems.find(item => item.nodeType === NodeType.OPERATION); + if (!selectedOperation) { + return; + } + const manager = new LayoutManager(schema, getLayout()); + const newNodeID = manager.selectUp(selectedOperation.nodeID); + if (newNodeID) { + setSelected([newNodeID]); + } + } + + function handleSelectDown() { + const selectedOperation = selectedItems.find(item => item.nodeType === NodeType.OPERATION); + if (!selectedOperation) { + return; + } + const manager = new LayoutManager(schema, getLayout()); + const newNodeID = manager.selectDown(selectedOperation.nodeID); + if (newNodeID) { + setSelected([newNodeID]); + } + } + function handleKeyDown(event: React.KeyboardEvent) { if (isProcessing) { return; @@ -202,10 +250,27 @@ export function OssFlow() { return; } } - if (event.key === 'Delete') { + if (event.code === 'Delete') { withPreventDefault(handleDeleteSelected)(event); return; } + + if (event.code === 'ArrowLeft') { + withPreventDefault(handleSelectLeft)(event); + return; + } + if (event.code === 'ArrowRight') { + withPreventDefault(handleSelectRight)(event); + return; + } + if (event.code === 'ArrowUp') { + withPreventDefault(handleSelectUp)(event); + return; + } + if (event.code === 'ArrowDown') { + withPreventDefault(handleSelectDown)(event); + return; + } } function handleMouseMove(event: React.MouseEvent) {