F: Improve diagram flow management

This commit is contained in:
Ivan 2025-04-30 01:04:04 +03:00
parent caf11aa329
commit 6bc7993797
12 changed files with 213 additions and 134 deletions

View File

@ -0,0 +1,100 @@
'use client';
import { type ReactNode, useState } from 'react';
import { Background, ReactFlow, type ReactFlowProps } from 'reactflow';
export { useReactFlow, useStoreApi } from 'reactflow';
import { cn } from '../utils';
type DiagramFlowProps = {
children?: ReactNode;
height?: number | string;
showGrid?: boolean;
gridSize?: number;
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
onKeyUp?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
} & ReactFlowProps;
export function DiagramFlow({
children,
className,
style,
height,
showGrid = false,
gridSize = 20,
onKeyDown,
onKeyUp,
nodesDraggable = true,
nodesFocusable,
edgesFocusable,
onNodesChange,
onEdgesChange,
onNodeClick,
onNodeDoubleClick,
onNodeContextMenu,
onNodeMouseEnter,
onNodeMouseLeave,
onNodeDragStart,
onNodeDrag,
onNodeDragStop,
onContextMenu,
...restProps
}: DiagramFlowProps) {
const [spaceMode, setSpaceMode] = useState(false);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.code === 'Space') {
event.preventDefault();
event.stopPropagation();
setSpaceMode(true);
}
onKeyDown?.(event);
}
function handleKeyUp(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.code === 'Space') {
setSpaceMode(false);
}
onKeyUp?.(event);
}
function handleContextMenu(event: React.MouseEvent<HTMLDivElement>) {
event.preventDefault();
onContextMenu?.(event);
}
return (
<div
tabIndex={-1}
className={cn('relative cc-mask-sides w-[100dvw]', spaceMode && 'space-mode', className)}
style={{ ...style, height: height }}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
>
<ReactFlow
{...restProps}
onNodesChange={spaceMode ? undefined : onNodesChange}
onEdgesChange={spaceMode ? undefined : onEdgesChange}
onNodeClick={spaceMode ? undefined : onNodeClick}
onNodeDoubleClick={spaceMode ? undefined : onNodeDoubleClick}
nodesDraggable={!spaceMode && nodesDraggable}
nodesFocusable={!spaceMode && nodesFocusable}
edgesFocusable={!spaceMode && edgesFocusable}
onNodeDragStart={spaceMode ? undefined : onNodeDragStart}
onNodeDrag={spaceMode ? undefined : onNodeDrag}
onNodeDragStop={spaceMode ? undefined : onNodeDragStop}
onNodeContextMenu={spaceMode ? undefined : onNodeContextMenu}
onNodeMouseEnter={spaceMode ? undefined : onNodeMouseEnter}
onNodeMouseLeave={spaceMode ? undefined : onNodeMouseLeave}
onContextMenu={handleContextMenu}
>
{showGrid ? <Background gap={gridSize} /> : null}
{children}
</ReactFlow>
</div>
);
}

View File

@ -28,12 +28,12 @@ export function BlockNode(node: BlockInternalNode) {
return (
<>
<NodeResizeControl minWidth={BLOCK_NODE_MIN_WIDTH} minHeight={BLOCK_NODE_MIN_HEIGHT}>
<IconResize size={8} className='absolute bottom-[2px] right-[2px] cc-graph-interactive' />
<IconResize size={12} className='absolute bottom-[3px] right-[3px] cc-graph-interactive' />
</NodeResizeControl>
{showCoordinates ? (
<div
className={clsx(
'absolute top-full mt-1 right-[1px]',
'absolute top-full mt-[4px] right-[1px]',
'text-[7px]/[8px] font-math',
'text-muted-foreground hover:text-foreground'
)}
@ -43,24 +43,24 @@ export function BlockNode(node: BlockInternalNode) {
) : null}
<div
className={clsx(
'cc-node-block h-full w-full',
'cc-node-block h-full w-full cursor-pointer',
isDragging && isParent && dropTarget !== node.data.block.id && 'border-destructive',
((isParent && !isDragging) || dropTarget === node.data.block.id) && 'border-primary',
isChild && 'border-accent-orange'
)}
>
<div className='absolute top-0 left-0 w-full h-2 cc-graph-interactive cursor-pointer' />
<div className='absolute top-0 right-0 h-full w-2 cc-graph-interactive cursor-pointer' />
<div className='absolute bottom-0 right-0 w-full h-2 cc-graph-interactive cursor-pointer' />
<div className='absolute bottom-0 left-0 h-full w-2 cc-graph-interactive cursor-pointer' />
<div className='absolute -top-[8px] left-0 w-full h-[16px] cc-graph-interactive' />
<div className='absolute top-0 -right-[8px] h-full w-[16px] cc-graph-interactive' />
<div className='absolute -bottom-[8px] right-0 w-full h-[16px] cc-graph-interactive' />
<div className='absolute bottom-0 -left-[8px] h-full w-[16px] cc-graph-interactive' />
<div
className={clsx(
'w-fit mx-auto -translate-y-1/2 -mt-[8px]',
'px-2',
'px-[8px]',
'bg-background rounded-lg',
'text-[18px]/[20px] line-clamp-2 text-center text-ellipsis',
'cc-graph-interactive cursor-pointer'
'cc-graph-interactive'
)}
data-tooltip-id={globalIDs.operation_tooltip}
data-tooltip-hidden={node.dragging}

View File

@ -42,7 +42,7 @@ export function NodeCore({ node }: NodeCoreProps) {
data-tooltip-hidden={node.dragging}
onMouseEnter={() => setHover(node.data.operation)}
>
<div className='absolute z-pop top-0 right-0 flex flex-col gap-1 p-[2px]'>
<div className='absolute z-pop top-0 right-0 flex flex-col gap-[4px] p-[2px]'>
<Indicator
noPadding
title={hasFile ? 'Связанная КС' : 'Нет связанной КС'}
@ -59,7 +59,7 @@ export function NodeCore({ node }: NodeCoreProps) {
{showCoordinates ? (
<div
className={clsx(
'absolute top-full mt-1 right-[1px]',
'absolute top-full mt-[4px] right-[1px]',
'text-[7px]/[8px] font-math',
'text-muted-foreground hover:text-foreground',
node.selected && 'translate-y-[6px]'

View File

@ -11,10 +11,9 @@ import { PARAMETER } from '@/utils/constants';
import { useOssEdit } from '../oss-edit-context';
import { flowOptions } from './oss-flow';
import { OssFlowContext } from './oss-flow-context';
const VIEW_PADDING = 0.2;
const Z_BLOCK = 1;
const Z_SCHEMA = 10;
@ -86,7 +85,7 @@ export const OssFlowState = ({ children }: React.PropsWithChildren) => {
setNodes(newNodes);
setEdges(newEdges);
setTimeout(() => fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING }), PARAMETER.refreshTimeout);
setTimeout(() => fitView(flowOptions.fitViewOptions), PARAMETER.refreshTimeout);
}, [schema, setNodes, setEdges, edgeAnimate, edgeStraight, fitView]);
useEffect(() => {
@ -94,7 +93,7 @@ export const OssFlowState = ({ children }: React.PropsWithChildren) => {
}, [schema, edgeAnimate, edgeStraight, resetGraph]);
function resetView() {
setTimeout(() => fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING }), PARAMETER.refreshTimeout);
setTimeout(() => fitView(flowOptions.fitViewOptions), PARAMETER.refreshTimeout);
}
return (

View File

@ -1,11 +1,12 @@
'use client';
import { useState } from 'react';
import { Background, ReactFlow, useReactFlow, useStoreApi } from 'reactflow';
import clsx from 'clsx';
import { DiagramFlow, useReactFlow, useStoreApi } from '@/components/flow/diagram-flow';
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';
@ -25,8 +26,18 @@ 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;
export const flowOptions = {
fitView: true,
fitViewOptions: { padding: 0.3, duration: PARAMETER.zoomDuration },
edgesFocusable: false,
nodesFocusable: false,
nodesConnectable: false,
maxZoom: 2,
minZoom: 0.5,
gridSize: GRID_SIZE,
snapToGrid: true,
snapGrid: [GRID_SIZE, GRID_SIZE] as [number, number]
} as const;
export function OssFlow() {
const mainHeight = useMainHeight();
@ -46,7 +57,6 @@ export function OssFlow() {
const { deleteBlock } = useDeleteBlock();
const [mouseCoords, setMouseCoords] = useState<Position2D>({ x: 0, y: 0 });
const [spacePressed, setSpacePressed] = useState(false);
const showCreateOperation = useDialogsStore(state => state.showCreateOperation);
const showCreateBlock = useDialogsStore(state => state.showCreateBlock);
@ -131,12 +141,6 @@ export function OssFlow() {
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.code === 'Space') {
event.preventDefault();
event.stopPropagation();
setSpacePressed(true);
return;
}
if (isProcessing) {
return;
}
@ -173,25 +177,13 @@ export function OssFlow() {
}
}
function handleKeyUp(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.code === 'Space') {
setSpacePressed(false);
}
}
function handleMouseMove(event: React.MouseEvent<HTMLDivElement>) {
const targetPosition = screenToFlowPosition({ x: event.clientX, y: event.clientY });
setMouseCoords(targetPosition);
}
return (
<div
tabIndex={-1}
className='relative'
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onMouseMove={showCoordinates ? handleMouseMove : undefined}
>
<div tabIndex={-1} className='relative' onMouseMove={showCoordinates ? handleMouseMove : undefined}>
{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'
@ -203,43 +195,25 @@ export function OssFlow() {
<ContextMenu isOpen={isContextMenuOpen} onHide={hideContextMenu} {...menuProps} />
<div
className={clsx(
'relative w-[100vw] cc-mask-sides',
spacePressed ? 'space-mode' : '',
!containMovement && 'cursor-relocate'
)}
style={{ height: mainHeight, fontFamily: 'Rubik' }}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={spacePressed ? undefined : onNodesChange}
onEdgesChange={spacePressed ? undefined : onEdgesChange}
edgesFocusable={false}
nodesFocusable={false}
fitView
nodeTypes={OssNodeTypes}
maxZoom={ZOOM_MAX}
minZoom={ZOOM_MIN}
nodesConnectable={false}
snapToGrid={true}
snapGrid={[GRID_SIZE, GRID_SIZE]}
onClick={hideContextMenu}
onNodeDoubleClick={spacePressed ? undefined : handleNodeDoubleClick}
onNodeContextMenu={handleContextMenu}
onContextMenu={event => {
event.preventDefault();
hideContextMenu();
}}
nodesDraggable={!spacePressed}
onNodeDragStart={spacePressed ? undefined : handleDragStart}
onNodeDrag={spacePressed ? undefined : handleDrag}
onNodeDragStop={spacePressed ? undefined : handleDragStop}
>
{showGrid ? <Background gap={GRID_SIZE} /> : null}
</ReactFlow>
</div>
<DiagramFlow
{...flowOptions}
className={clsx(!containMovement && 'cursor-relocate')}
height={mainHeight}
onKeyDown={handleKeyDown}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={OssNodeTypes}
showGrid={showGrid}
onClick={hideContextMenu}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeContextMenu={handleContextMenu}
onContextMenu={hideContextMenu}
onNodeDragStart={handleDragStart}
onNodeDrag={handleDrag}
onNodeDragStop={handleDragStop}
/>
</div>
);
}

View File

@ -63,6 +63,9 @@ export function useDragging({ hideContextMenu }: DraggingProps) {
if (containMovement) {
applyContainMovement([target.id, ...selected.map(id => String(id))], false);
} else {
event.preventDefault();
event.stopPropagation();
const new_parent = dropTarget.evaluate(event);
const allSelected = [...selected.filter(id => id != Number(target.id)), Number(target.id)];
const operations = allSelected

View File

@ -1,7 +1,9 @@
'use client';
import { useEffect } from 'react';
import { type Edge, MarkerType, type Node, ReactFlow, useEdgesState, useNodesState } from 'reactflow';
import { type Edge, MarkerType, type Node, useEdgesState, useNodesState } from 'reactflow';
import { DiagramFlow } from '@/components/flow/diagram-flow';
import { type SyntaxTree } from '../../models/rslang';
@ -9,6 +11,16 @@ import { ASTEdgeTypes } from './graph/ast-edge-types';
import { applyLayout } from './graph/ast-layout';
import { ASTNodeTypes } from './graph/ast-node-types';
const flowOptions = {
fitView: true,
fitViewOptions: { padding: 0.25 },
edgesFocusable: false,
nodesFocusable: false,
nodesConnectable: false,
maxZoom: 2,
minZoom: 0.5
} as const;
interface ASTFlowProps {
data: SyntaxTree;
onNodeEnter: (node: Node) => void;
@ -53,11 +65,11 @@ export function ASTFlow({ data, onNodeEnter, onNodeLeave, onChangeDragging }: AS
}, [data, setNodes, setEdges]);
return (
<ReactFlow
<DiagramFlow
{...flowOptions}
className='h-full w-full'
nodes={nodes}
edges={edges}
edgesFocusable={false}
nodesFocusable={false}
onNodeMouseEnter={(_, node) => onNodeEnter(node)}
onNodeMouseLeave={(_, node) => onNodeLeave(node)}
onNodeDragStart={() => onChangeDragging(true)}
@ -65,11 +77,6 @@ export function ASTFlow({ data, onNodeEnter, onNodeLeave, onChangeDragging }: AS
onNodesChange={onNodesChange}
nodeTypes={ASTNodeTypes}
edgeTypes={ASTEdgeTypes}
fitView
maxZoom={2}
minZoom={0.5}
nodesConnectable={false}
onContextMenu={event => event.preventDefault()}
/>
);
}

View File

@ -34,7 +34,9 @@ export function ASTNode(node: ASTNodeInternal) {
<Handle type='source' position={Position.Bottom} className='opacity-0' />
<div
className={clsx(
'font-math mt-1 w-fit text-center translate-x-[calc(-50%+20px)]',
'mt-[4px] w-fit translate-x-[calc(-50%+20px)]',
'font-math text-center ',
'pointer-events-none',
label.length > LABEL_THRESHOLD ? 'text-[12px]/[16px]' : 'text-[14px]/[20px]'
)}
>

View File

@ -1,7 +1,9 @@
'use client';
import { useEffect } from 'react';
import { type Edge, ReactFlow, useEdgesState, useNodesState } from 'reactflow';
import { type Edge, useEdgesState, useNodesState } from 'reactflow';
import { DiagramFlow } from '@/components/flow/diagram-flow';
import { type TypificationGraph } from '../../models/typification-graph';
@ -9,8 +11,15 @@ import { TMGraphEdgeTypes } from './graph/mgraph-edge-types';
import { applyLayout } from './graph/mgraph-layout';
import { TMGraphNodeTypes } from './graph/mgraph-node-types';
const ZOOM_MAX = 2;
const ZOOM_MIN = 0.5;
const flowOptions = {
fitView: true,
fitViewOptions: { padding: 0.25 },
edgesFocusable: false,
nodesFocusable: false,
nodesConnectable: false,
maxZoom: 2,
minZoom: 0.5
} as const;
interface MGraphFlowProps {
data: TypificationGraph;
@ -57,19 +66,14 @@ export function MGraphFlow({ data }: MGraphFlowProps) {
}, [data, setNodes, setEdges]);
return (
<ReactFlow
<DiagramFlow
{...flowOptions}
className='h-full w-full'
nodes={nodes}
edges={edges}
edgesFocusable={false}
nodesFocusable={false}
onNodesChange={onNodesChange}
nodeTypes={TMGraphNodeTypes}
edgeTypes={TMGraphEdgeTypes}
fitView
maxZoom={ZOOM_MAX}
minZoom={ZOOM_MIN}
nodesConnectable={false}
onContextMenu={event => event.preventDefault()}
/>
);
}

View File

@ -54,7 +54,7 @@ export function TGNode(node: TGNodeInternal) {
<div
className={clsx(
'w-full h-full cursor-default flex items-center justify-center rounded-full',
isFocused && 'border-2 border-selected',
isFocused && 'border-[2px] border-selected',
label.length > LABEL_THRESHOLD ? 'text-[12px]/[16px]' : 'text-[14px]/[20px]'
)}
style={{
@ -76,13 +76,14 @@ export function TGNode(node: TGNodeInternal) {
{description ? (
<div
className={clsx(
'mt-1 w-[150px] px-1 text-center translate-x-[calc(-50%+20px)]',
'mt-[4px] w-[150px] px-[4px] text-center translate-x-[calc(-50%+20px)]',
'pointer-events-none',
description.length > DESCRIPTION_THRESHOLD ? 'text-[10px]/[12px]' : 'text-[12px]/[16px]'
)}
onContextMenu={handleContextMenu}
onDoubleClick={handleDoubleClick}
>
<div className='absolute top-0 px-1 left-0 text-center w-full line-clamp-3 hover:line-clamp-none'>
<div className='absolute top-0 px-[4px] left-0 text-center w-full line-clamp-3 hover:line-clamp-none'>
{description}
</div>
<div aria-hidden className='line-clamp-3 hover:line-clamp-none cc-text-outline'>

View File

@ -1,18 +1,9 @@
'use client';
import { useEffect, useRef } from 'react';
import {
type Edge,
MarkerType,
type Node,
ReactFlow,
useEdgesState,
useNodesState,
useOnSelectionChange,
useReactFlow,
useStoreApi
} from 'reactflow';
import { type Edge, MarkerType, type Node, useEdgesState, useNodesState, useOnSelectionChange } from 'reactflow';
import { DiagramFlow, useReactFlow, useStoreApi } from '@/components/flow/diagram-flow';
import { useMainHeight } from '@/stores/app-layout';
import { PARAMETER } from '@/utils/constants';
@ -32,9 +23,15 @@ import { ToolbarTermGraph } from './toolbar-term-graph';
import { useFilteredGraph } from './use-filtered-graph';
import { ViewHidden } from './view-hidden';
const ZOOM_MAX = 3;
const ZOOM_MIN = 0.25;
export const VIEW_PADDING = 0.3;
export const flowOptions = {
fitView: true,
fitViewOptions: { padding: 0.3, duration: PARAMETER.zoomDuration },
edgesFocusable: false,
nodesFocusable: false,
nodesConnectable: false,
maxZoom: 3,
minZoom: 0.25
} as const;
export function TGFlow() {
const mainHeight = useMainHeight();
@ -116,9 +113,7 @@ export function TGFlow() {
setNodes(newNodes);
setEdges(newEdges);
setTimeout(() => {
fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING });
}, PARAMETER.minimalTimeout);
setTimeout(() => fitView(flowOptions.fitViewOptions), PARAMETER.minimalTimeout);
}, [schema, filteredGraph, setNodes, setEdges, filter.noText, fitView, viewportInitialized, focusCst]);
const prevSelected = useRef<number[]>([]);
@ -190,22 +185,16 @@ export function TGFlow() {
<ViewHidden items={hidden} />
</div>
<div className='relative outline-hidden w-[100dvw] cc-mask-sides' style={{ height: mainHeight }}>
<ReactFlow
nodes={nodes}
onNodesChange={onNodesChange}
edges={edges}
fitView
edgesFocusable={false}
nodesFocusable={false}
nodesConnectable={false}
nodeTypes={TGNodeTypes}
edgeTypes={TGEdgeTypes}
maxZoom={ZOOM_MAX}
minZoom={ZOOM_MIN}
onContextMenu={event => event.preventDefault()}
/>
</div>
<DiagramFlow
{...flowOptions}
height={mainHeight}
nodes={nodes}
onNodesChange={onNodesChange}
edges={edges}
nodeTypes={TGNodeTypes}
edgeTypes={TGEdgeTypes}
onContextMenu={event => event.preventDefault()}
/>
</div>
);
}

View File

@ -25,7 +25,7 @@ import { useMutatingRSForm } from '../../../backend/use-mutating-rsform';
import { useTermGraphStore } from '../../../stores/term-graph';
import { useRSEdit } from '../rsedit-context';
import { VIEW_PADDING } from './tg-flow';
import { flowOptions } from './tg-flow';
export function ToolbarTermGraph() {
const isProcessing = useMutatingRSForm();
@ -76,7 +76,7 @@ export function ToolbarTermGraph() {
function handleFitView() {
setTimeout(() => {
fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING });
fitView(flowOptions.fitViewOptions);
}, PARAMETER.minimalTimeout);
}