1
This commit is contained in:
155
frontend/components/MindmapCanvas.tsx
Normal file
155
frontend/components/MindmapCanvas.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactFlow, { Background, Controls, MiniMap, type ReactFlowInstance } from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
import MindmapNodeCard from "@/components/MindmapNodeCard";
|
||||
import { treeToGraph } from "@/lib/treeToGraph";
|
||||
import type { MindmapNode } from "@/types/mindmap";
|
||||
|
||||
type MindmapCanvasProps = {
|
||||
tree: MindmapNode;
|
||||
className?: string;
|
||||
onNodeChat?: (nodeLabel: string) => void;
|
||||
};
|
||||
|
||||
const NODE_WIDTH = 220;
|
||||
const NODE_HEIGHT = 84;
|
||||
const nodeTypes = {
|
||||
mindmapNode: MindmapNodeCard,
|
||||
};
|
||||
|
||||
function createInitialExpandedSet(tree: MindmapNode): Set<string> {
|
||||
return new Set([tree.id]);
|
||||
}
|
||||
|
||||
export default function MindmapCanvas({ tree, className, onNodeChat }: MindmapCanvasProps) {
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(() =>
|
||||
createInitialExpandedSet(tree),
|
||||
);
|
||||
const [reactFlowInstance, setReactFlowInstance] =
|
||||
useState<ReactFlowInstance | null>(null);
|
||||
const hasFittedInitiallyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedNodeIds(createInitialExpandedSet(tree));
|
||||
hasFittedInitiallyRef.current = false;
|
||||
}, [tree]);
|
||||
|
||||
const panToNode = useCallback(
|
||||
(nodeId: string, nextExpandedNodeIds: Set<string>) => {
|
||||
if (!reactFlowInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextGraph = treeToGraph(tree, {
|
||||
expandedNodeIds: nextExpandedNodeIds,
|
||||
});
|
||||
const focusNode = nextGraph.nodes.find((node) => node.id === nodeId);
|
||||
|
||||
if (!focusNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = reactFlowInstance.getViewport();
|
||||
const revealOffset = nextExpandedNodeIds.has(nodeId)
|
||||
? NODE_WIDTH * 0.9
|
||||
: NODE_WIDTH * 0.45;
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
reactFlowInstance.setCenter(
|
||||
focusNode.position.x + revealOffset,
|
||||
focusNode.position.y + NODE_HEIGHT / 2,
|
||||
{
|
||||
zoom: viewport.zoom,
|
||||
duration: 320,
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
[reactFlowInstance, tree],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(nodeId: string) => {
|
||||
setExpandedNodeIds((currentExpandedNodeIds) => {
|
||||
const nextExpandedNodeIds = new Set(currentExpandedNodeIds);
|
||||
|
||||
if (nextExpandedNodeIds.has(nodeId)) {
|
||||
nextExpandedNodeIds.delete(nodeId);
|
||||
} else {
|
||||
nextExpandedNodeIds.add(nodeId);
|
||||
}
|
||||
|
||||
panToNode(nodeId, nextExpandedNodeIds);
|
||||
return nextExpandedNodeIds;
|
||||
});
|
||||
},
|
||||
[panToNode],
|
||||
);
|
||||
|
||||
const graph = useMemo(
|
||||
() =>
|
||||
treeToGraph(tree, {
|
||||
expandedNodeIds,
|
||||
onToggleNode: handleToggle,
|
||||
onOpenNodeChat: onNodeChat,
|
||||
}),
|
||||
[expandedNodeIds, handleToggle, onNodeChat, tree],
|
||||
);
|
||||
|
||||
const keepNodeInteractive = useCallback(() => {
|
||||
// React Flow only enables pointer events for nodes when at least one node handler exists.
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reactFlowInstance || hasFittedInitiallyRef.current || graph.nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
reactFlowInstance.fitView({
|
||||
padding: 0.16,
|
||||
duration: 420,
|
||||
maxZoom: 1,
|
||||
});
|
||||
hasFittedInitiallyRef.current = true;
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [graph.nodes, reactFlowInstance]);
|
||||
|
||||
return (
|
||||
<div className={`h-full w-full overflow-hidden bg-white ${className ?? ""}`}>
|
||||
<ReactFlow
|
||||
nodes={graph.nodes}
|
||||
edges={graph.edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onInit={setReactFlowInstance}
|
||||
onNodeClick={keepNodeInteractive}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
zoomOnPinch
|
||||
zoomOnDoubleClick={false}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
nodesFocusable={false}
|
||||
edgesFocusable={false}
|
||||
selectNodesOnDrag={false}
|
||||
minZoom={0.2}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeBorderRadius={12}
|
||||
maskColor="rgba(148, 163, 184, 0.12)"
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
<Background gap={20} color="#dbeafe" />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user