Portal/rsconcept/frontend/src/pages/OssPage/EditorOssGraph/OssFlow.tsx

398 lines
12 KiB
TypeScript
Raw Normal View History

2024-06-27 14:43:06 +03:00
'use client';
import { toPng } from 'html-to-image';
2024-07-23 23:03:58 +03:00
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
2024-07-24 18:11:28 +03:00
import { toast } from 'react-toastify';
2024-07-20 18:26:32 +03:00
import {
2024-07-24 18:11:28 +03:00
Background,
getNodesBounds,
getViewportForBounds,
2024-07-23 23:03:58 +03:00
Node,
2024-07-20 18:26:32 +03:00
NodeChange,
NodeTypes,
ReactFlow,
useEdgesState,
useNodesState,
2024-07-23 23:03:58 +03:00
useOnSelectionChange,
useReactFlow
2024-07-20 18:26:32 +03:00
} from 'reactflow';
2024-06-27 14:43:06 +03:00
2024-07-26 17:30:37 +03:00
import { CProps } from '@/components/props';
2024-07-20 18:26:32 +03:00
import Overlay from '@/components/ui/Overlay';
import AnimateFade from '@/components/wrap/AnimateFade';
2024-06-27 14:43:06 +03:00
import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { useOSS } from '@/context/OssContext';
2024-07-26 21:08:31 +03:00
import useLocalStorage from '@/hooks/useLocalStorage';
2024-07-26 17:30:37 +03:00
import { OssNode } from '@/models/miscellaneous';
2024-09-16 19:38:24 +03:00
import { OperationID } from '@/models/oss';
2024-07-26 21:08:31 +03:00
import { PARAMETER, storage } from '@/utils/constants';
2024-07-24 18:11:28 +03:00
import { errors } from '@/utils/labels';
2024-06-27 14:43:06 +03:00
2024-07-20 18:26:32 +03:00
import { useOssEdit } from '../OssEditContext';
2024-06-27 14:43:06 +03:00
import InputNode from './InputNode';
2024-07-26 17:30:37 +03:00
import NodeContextMenu, { ContextMenuData } from './NodeContextMenu';
2024-06-27 14:43:06 +03:00
import OperationNode from './OperationNode';
2024-07-20 18:26:32 +03:00
import ToolbarOssGraph from './ToolbarOssGraph';
2024-06-27 14:43:06 +03:00
2024-07-23 23:03:58 +03:00
interface OssFlowProps {
isModified: boolean;
setIsModified: React.Dispatch<React.SetStateAction<boolean>>;
}
2024-07-26 21:08:31 +03:00
function OssFlow({ isModified, setIsModified }: OssFlowProps) {
const { mainHeight, colors } = useConceptOptions();
2024-06-27 14:43:06 +03:00
const model = useOSS();
2024-07-20 18:26:32 +03:00
const controller = useOssEdit();
2024-07-23 23:03:58 +03:00
const flow = useReactFlow();
2024-06-27 14:43:06 +03:00
2024-07-26 21:08:31 +03:00
const [showGrid, setShowGrid] = useLocalStorage<boolean>(storage.ossShowGrid, false);
const [edgeAnimate, setEdgeAnimate] = useLocalStorage<boolean>(storage.ossEdgeAnimate, false);
const [edgeStraight, setEdgeStraight] = useLocalStorage<boolean>(storage.ossEdgeStraight, false);
2024-07-21 22:50:43 +03:00
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
2024-07-23 23:03:58 +03:00
const [toggleReset, setToggleReset] = useState(false);
2024-07-26 17:30:37 +03:00
const [menuProps, setMenuProps] = useState<ContextMenuData | undefined>(undefined);
2024-07-23 23:03:58 +03:00
const onSelectionChange = useCallback(
({ nodes }: { nodes: Node[] }) => {
const ids = nodes.map(node => Number(node.id));
controller.setSelected(prev => [
...prev.filter(nodeID => ids.includes(nodeID)),
...ids.filter(nodeID => !prev.includes(Number(nodeID)))
]);
2024-07-23 23:03:58 +03:00
},
[controller]
);
useOnSelectionChange({
onChange: onSelectionChange
});
2024-07-21 22:50:43 +03:00
useLayoutEffect(() => {
if (!model.schema) {
setNodes([]);
setEdges([]);
2024-07-23 23:03:58 +03:00
} else {
setNodes(
model.schema.items.map(operation => ({
id: String(operation.id),
2024-07-26 00:33:22 +03:00
data: { label: operation.alias, operation: operation },
2024-07-23 23:03:58 +03:00
position: { x: operation.position_x, y: operation.position_y },
type: operation.operation_type.toString()
}))
);
setEdges(
model.schema.arguments.map((argument, index) => ({
id: String(index),
source: String(argument.argument),
target: String(argument.operation),
2024-07-27 22:49:20 +03:00
type: edgeStraight ? 'straight' : 'simplebezier',
2024-07-26 21:08:31 +03:00
animated: edgeAnimate,
2024-07-23 23:03:58 +03:00
targetHandle:
model.schema!.operationByID.get(argument.argument)!.position_x >
model.schema!.operationByID.get(argument.operation)!.position_x
? 'right'
: 'left'
}))
);
2024-07-21 22:50:43 +03:00
}
2024-07-23 23:03:58 +03:00
setTimeout(() => {
setIsModified(false);
}, PARAMETER.graphRefreshDelay);
2024-07-26 21:08:31 +03:00
}, [model.schema, setNodes, setEdges, setIsModified, toggleReset, edgeStraight, edgeAnimate]);
2024-06-27 14:43:06 +03:00
2024-07-21 15:17:36 +03:00
const getPositions = useCallback(
() =>
nodes.map(node => ({
id: Number(node.id),
position_x: node.position.x,
position_y: node.position.y
})),
[nodes]
);
2024-07-20 18:26:32 +03:00
const handleNodesChange = useCallback(
(changes: NodeChange[]) => {
2024-07-23 23:03:58 +03:00
if (changes.some(change => change.type === 'position' && change.position)) {
setIsModified(true);
}
2024-07-20 18:26:32 +03:00
onNodesChange(changes);
},
2024-07-23 23:03:58 +03:00
[onNodesChange, setIsModified]
2024-07-20 18:26:32 +03:00
);
2024-07-23 23:03:58 +03:00
const handleSavePositions = useCallback(() => {
controller.savePositions(getPositions(), () => setIsModified(false));
}, [controller, getPositions, setIsModified]);
2024-07-20 18:26:32 +03:00
const handleCreateOperation = useCallback(
(inputs: OperationID[]) => {
if (!controller.schema) {
return;
}
2024-08-02 11:17:27 +03:00
const positions = getPositions();
2024-09-16 19:38:24 +03:00
const target = flow.project({ x: window.innerWidth / 2, y: window.innerHeight / 2 });
2024-08-02 11:17:27 +03:00
controller.promptCreateOperation({
2024-09-16 19:38:24 +03:00
defaultX: target.x,
defaultY: target.y,
2024-08-02 11:17:27 +03:00
inputs: inputs,
positions: positions,
callback: () => flow.fitView({ duration: PARAMETER.zoomDuration })
});
},
[controller, getPositions, flow]
);
2024-07-23 23:03:58 +03:00
2024-07-26 17:30:37 +03:00
const handleDeleteOperation = useCallback(
(target: OperationID) => {
if (!controller.canDelete(target)) {
return;
}
controller.promptDeleteOperation(target, getPositions());
2024-07-26 17:30:37 +03:00
},
[controller, getPositions]
);
const handleDeleteSelected = useCallback(() => {
if (controller.selected.length !== 1) {
return;
}
handleDeleteOperation(controller.selected[0]);
}, [controller, handleDeleteOperation]);
const handleCreateInput = useCallback(
(target: OperationID) => {
controller.createInput(target, getPositions());
},
[controller, getPositions]
);
2024-07-28 21:29:46 +03:00
const handleEditSchema = useCallback(
(target: OperationID) => {
controller.promptEditInput(target, getPositions());
},
[controller, getPositions]
);
2024-07-29 16:55:48 +03:00
const handleEditOperation = useCallback(
(target: OperationID) => {
controller.promptEditOperation(target, getPositions());
},
[controller, getPositions]
);
const handleExecuteOperation = useCallback(
2024-07-29 22:30:24 +03:00
(target: OperationID) => {
controller.executeOperation(target, getPositions());
2024-07-29 22:30:24 +03:00
},
[controller, getPositions]
);
const handleExecuteSelected = useCallback(() => {
if (controller.selected.length !== 1) {
return;
}
handleExecuteOperation(controller.selected[0]);
}, [controller, handleExecuteOperation]);
2024-10-23 15:18:46 +03:00
const handleRelocateConstituents = useCallback(
(target: OperationID) => {
2024-10-28 14:52:30 +03:00
controller.promptRelocateConstituents(target, getPositions());
2024-10-23 15:18:46 +03:00
},
2024-10-28 14:52:30 +03:00
[controller, getPositions]
2024-10-23 15:18:46 +03:00
);
2024-07-24 18:11:28 +03:00
const handleFitView = useCallback(() => {
flow.fitView({ duration: PARAMETER.zoomDuration });
}, [flow]);
const handleResetPositions = useCallback(() => {
setToggleReset(prev => !prev);
}, []);
const handleSaveImage = useCallback(() => {
2024-08-20 14:35:30 +03:00
if (!model.schema) {
return;
}
2024-07-24 18:11:28 +03:00
const canvas: HTMLElement | null = document.querySelector('.react-flow__viewport');
if (canvas === null) {
toast.error(errors.imageFailed);
return;
}
const imageWidth = PARAMETER.ossImageWidth;
const imageHeight = PARAMETER.ossImageHeight;
const nodesBounds = getNodesBounds(nodes);
const viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, 0.5, 2);
toPng(canvas, {
2024-07-24 18:11:28 +03:00
backgroundColor: colors.bgDefault,
width: imageWidth,
height: imageHeight,
style: {
width: String(imageWidth),
height: String(imageHeight),
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`
}
})
.then(dataURL => {
const a = document.createElement('a');
a.setAttribute('download', `${model.schema?.alias ?? 'oss'}.png`);
2024-07-24 18:11:28 +03:00
a.setAttribute('href', dataURL);
a.click();
})
.catch(error => {
console.error(error);
toast.error(errors.imageFailed);
});
2024-08-20 14:35:30 +03:00
}, [colors, nodes, model.schema]);
2024-07-24 18:11:28 +03:00
2024-07-26 00:33:22 +03:00
const handleContextMenu = useCallback(
2024-07-26 17:30:37 +03:00
(event: CProps.EventMouse, node: OssNode) => {
2024-07-26 00:33:22 +03:00
event.preventDefault();
event.stopPropagation();
2024-07-26 17:30:37 +03:00
setMenuProps({
operation: node.data.operation,
cursorX: event.clientX,
cursorY: event.clientY
});
controller.setShowTooltip(false);
2024-07-26 00:33:22 +03:00
},
[controller]
);
2024-07-26 17:30:37 +03:00
const handleContextMenuHide = useCallback(() => {
controller.setShowTooltip(true);
setMenuProps(undefined);
}, [controller]);
const handleClickCanvas = useCallback(() => {
handleContextMenuHide();
}, [handleContextMenuHide]);
const handleNodeDoubleClick = useCallback(
2024-07-29 22:30:24 +03:00
(event: CProps.EventMouse, node: OssNode) => {
event.preventDefault();
event.stopPropagation();
2024-08-17 12:16:50 +03:00
if (node.data.operation.result) {
controller.openOperationSchema(Number(node.id));
} else {
handleEditOperation(Number(node.id));
}
2024-07-29 22:30:24 +03:00
},
[handleEditOperation, controller]
2024-07-29 22:30:24 +03:00
);
2024-07-23 23:03:58 +03:00
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (controller.isProcessing) {
return;
}
if (!controller.isMutable) {
return;
}
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
2024-07-24 18:11:28 +03:00
event.preventDefault();
event.stopPropagation();
handleSavePositions();
return;
}
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyQ') {
event.preventDefault();
event.stopPropagation();
handleCreateOperation(controller.selected);
return;
}
2024-07-23 23:03:58 +03:00
if (event.key === 'Delete') {
event.preventDefault();
event.stopPropagation();
2024-07-26 17:30:37 +03:00
handleDeleteSelected();
2024-07-23 23:03:58 +03:00
return;
}
}
2024-06-27 14:43:06 +03:00
2024-07-20 18:26:32 +03:00
const OssNodeTypes: NodeTypes = useMemo(
() => ({
synthesis: OperationNode,
input: InputNode
}),
[]
);
2024-07-21 15:17:36 +03:00
const graph = useMemo(
() => (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
2024-07-23 23:03:58 +03:00
onEdgesChange={onEdgesChange}
onNodeDoubleClick={handleNodeDoubleClick}
fitView
2024-07-21 15:17:36 +03:00
nodeTypes={OssNodeTypes}
2024-07-21 22:50:43 +03:00
maxZoom={2}
2024-08-19 18:32:21 +03:00
minZoom={0.5}
2024-07-23 23:03:58 +03:00
nodesConnectable={false}
2024-07-24 18:11:28 +03:00
snapToGrid={true}
snapGrid={[PARAMETER.ossGridSize, PARAMETER.ossGridSize]}
2024-07-26 17:30:37 +03:00
onNodeContextMenu={handleContextMenu}
onClick={handleClickCanvas}
2024-07-24 18:11:28 +03:00
>
{showGrid ? <Background gap={PARAMETER.ossGridSize} /> : null}
2024-07-24 18:11:28 +03:00
</ReactFlow>
2024-07-21 15:17:36 +03:00
),
[
nodes,
edges,
handleNodesChange,
handleContextMenu,
handleClickCanvas,
onEdgesChange,
handleNodeDoubleClick,
OssNodeTypes,
showGrid
]
2024-07-21 15:17:36 +03:00
);
2024-06-27 14:43:06 +03:00
return (
2024-07-23 23:03:58 +03:00
<AnimateFade tabIndex={-1} onKeyDown={handleKeyDown}>
<Overlay position='top-[1.9rem] pt-1 right-1/2 translate-x-1/2' className='rounded-b-2xl cc-blur'>
2024-07-23 23:03:58 +03:00
<ToolbarOssGraph
isModified={isModified}
2024-07-24 18:11:28 +03:00
showGrid={showGrid}
2024-07-26 21:08:31 +03:00
edgeAnimate={edgeAnimate}
edgeStraight={edgeStraight}
2024-07-24 18:11:28 +03:00
onFitView={handleFitView}
onCreate={() => handleCreateOperation(controller.selected)}
2024-07-26 17:30:37 +03:00
onDelete={handleDeleteSelected}
onEdit={() => handleEditOperation(controller.selected[0])}
onExecute={handleExecuteSelected}
2024-07-24 18:11:28 +03:00
onResetPositions={handleResetPositions}
2024-07-23 23:03:58 +03:00
onSavePositions={handleSavePositions}
2024-07-24 18:11:28 +03:00
onSaveImage={handleSaveImage}
toggleShowGrid={() => setShowGrid(prev => !prev)}
2024-07-26 21:08:31 +03:00
toggleEdgeAnimate={() => setEdgeAnimate(prev => !prev)}
toggleEdgeStraight={() => setEdgeStraight(prev => !prev)}
2024-07-23 23:03:58 +03:00
/>
2024-07-20 18:26:32 +03:00
</Overlay>
2024-07-26 17:30:37 +03:00
{menuProps ? (
<NodeContextMenu
onHide={handleContextMenuHide}
onDelete={handleDeleteOperation}
onCreateInput={handleCreateInput}
2024-07-28 21:29:46 +03:00
onEditSchema={handleEditSchema}
2024-07-29 16:55:48 +03:00
onEditOperation={handleEditOperation}
onExecuteOperation={handleExecuteOperation}
2024-10-23 15:18:46 +03:00
onRelocateConstituents={handleRelocateConstituents}
{...menuProps}
/>
2024-07-26 17:30:37 +03:00
) : null}
<div className='relative w-[100vw]' style={{ height: mainHeight, fontFamily: 'Rubik' }}>
2024-07-21 15:17:36 +03:00
{graph}
2024-07-20 18:26:32 +03:00
</div>
</AnimateFade>
2024-06-27 14:43:06 +03:00
);
}
export default OssFlow;