F: Implement hotkey navigation

This commit is contained in:
Ivan 2025-07-28 22:53:14 +03:00
parent de108074b1
commit 05275f87af
4 changed files with 196 additions and 7 deletions

View File

@ -111,7 +111,10 @@ export function HelpOssGraph() {
<kbd>Space</kbd> перемещение экрана
</li>
<li>
<kbd>Shift</kbd> перемещение в границах блока
<kbd>Shift</kbd> расширять границы
</li>
<li>
<kbd>Стрелки</kbd> выделение
</li>
</ul>
</div>

View File

@ -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;
}

View File

@ -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<string[]>([]);
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 (
<OssFlowContext
value={{

View File

@ -45,7 +45,8 @@ export const flowOptions = {
export function OssFlow() {
const mainHeight = useMainHeight();
const { navigateOperationSchema, schema, selected, selectedItems, isMutable, canDeleteOperation } = useOssEdit();
const { navigateOperationSchema, schema, selected, setSelected, selectedItems, isMutable, canDeleteOperation } =
useOssEdit();
const { screenToFlowPosition } = useReactFlow();
const { containMovement, nodes, onNodesChange, edges, onEdgesChange, resetGraph, resetView } = useOssFlow();
const store = useStoreApi();
@ -169,6 +170,53 @@ export function OssFlow() {
}
}
function handleSelectLeft() {
const selectedOperation = selectedItems.find(item => 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<HTMLDivElement>) {
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<HTMLDivElement>) {