156 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|