Files
minimap/frontend/components/MindmapCanvas.tsx
2026-03-20 19:40:17 +08:00

156 lines
4.2 KiB
TypeScript

"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>
);
}