From 05275f87af9acccd43a2f31c62355258d3bb1d7b Mon Sep 17 00:00:00 2001
From: Ivan <8611739+IRBorisov@users.noreply.github.com>
Date: Mon, 28 Jul 2025 22:53:14 +0300
Subject: [PATCH] F: Implement hotkey navigation
---
.../features/help/items/ui/help-oss-graph.tsx | 5 +-
.../src/features/oss/models/oss-layout-api.ts | 109 +++++++++++++++++-
.../editor-oss-graph/oss-flow-state.tsx | 20 +++-
.../oss-page/editor-oss-graph/oss-flow.tsx | 69 ++++++++++-
4 files changed, 196 insertions(+), 7 deletions(-)
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) {