Add complete AI-powered diagram generation pipeline: natural language input → type inference → graph patch generation → validated canvas render with ELK.js layout animation. Includes adversarial code review fixes for diagramType enum validation, duplicate ID detection, tool part history preservation, PATCH error handling, and graphData structural validation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
163 lines
5.1 KiB
TypeScript
163 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef } from "react";
|
|
import { useReactFlow } from "@xyflow/react";
|
|
import { toast } from "sonner";
|
|
|
|
import { useGraphStore } from "../stores/useGraphStore";
|
|
import {
|
|
computeLayout,
|
|
terminateWorker,
|
|
SOFT_CAP_NODE_COUNT,
|
|
} from "../lib/elk-layout";
|
|
|
|
import type { ElkLayoutOptions, LayoutResult } from "../lib/elk-layout";
|
|
|
|
const DEBOUNCE_MS = 300;
|
|
const LAYOUT_ANIMATION_MS = 200;
|
|
const LOADING_INDICATOR_DELAY_MS = 200;
|
|
|
|
export function useAutoLayout() {
|
|
const { fitView } = useReactFlow();
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const isFirstLayout = useRef(true);
|
|
|
|
const nodeCount = useGraphStore((s) => s.nodeCount);
|
|
const setNodes = useGraphStore((s) => s.setNodes);
|
|
const setEdges = useGraphStore((s) => s.setEdges);
|
|
const layoutDirection = useGraphStore((s) => s.layoutDirection);
|
|
const edgeRouting = useGraphStore((s) => s.edgeRouting);
|
|
const isLayouting = useGraphStore((s) => s.isLayouting);
|
|
const setIsLayouting = useGraphStore((s) => s.setIsLayouting);
|
|
|
|
const runLayout = useCallback(
|
|
async (options?: Partial<ElkLayoutOptions>) => {
|
|
const currentNodes = useGraphStore.getState().nodes;
|
|
const currentEdges = useGraphStore.getState().edges;
|
|
|
|
if (currentNodes.length === 0) return;
|
|
|
|
if (currentNodes.length > SOFT_CAP_NODE_COUNT) {
|
|
toast.warning(
|
|
`This diagram has ${currentNodes.length} nodes (recommended max: ${SOFT_CAP_NODE_COUNT}). Consider splitting into smaller diagrams for better performance.`,
|
|
);
|
|
}
|
|
|
|
// Only show loading indicator if layout takes > 200ms (AC#2)
|
|
const loadingTimer = setTimeout(() => {
|
|
setIsLayouting(true);
|
|
}, LOADING_INDICATOR_DELAY_MS);
|
|
|
|
// Add layouting class for CSS transition animation
|
|
const flowNodes =
|
|
document.querySelectorAll<HTMLElement>(".react-flow__node");
|
|
flowNodes.forEach((el) => el.classList.add("layouting"));
|
|
|
|
try {
|
|
const result: LayoutResult = await computeLayout(
|
|
currentNodes,
|
|
currentEdges,
|
|
{
|
|
direction:
|
|
options?.direction ??
|
|
useGraphStore.getState().layoutDirection,
|
|
edgeRouting:
|
|
options?.edgeRouting ??
|
|
useGraphStore.getState().edgeRouting,
|
|
...options,
|
|
},
|
|
);
|
|
|
|
setNodes(result.nodes);
|
|
if (result.edges) {
|
|
setEdges(result.edges);
|
|
}
|
|
|
|
// Fit view after layout, with a small delay to let transition run
|
|
setTimeout(() => {
|
|
fitView({ duration: LAYOUT_ANIMATION_MS, padding: 0.1 });
|
|
}, LAYOUT_ANIMATION_MS);
|
|
} catch (error) {
|
|
// Ignore cancellations from single-flight pattern
|
|
if (
|
|
error instanceof Error &&
|
|
(error.message === "Layout superseded" ||
|
|
error.message === "Layout cancelled")
|
|
) {
|
|
return;
|
|
}
|
|
toast.error(
|
|
`Layout failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
);
|
|
} finally {
|
|
clearTimeout(loadingTimer);
|
|
// Remove layouting class after animation
|
|
setTimeout(() => {
|
|
const flowNodesFinal =
|
|
document.querySelectorAll<HTMLElement>(".react-flow__node");
|
|
flowNodesFinal.forEach((el) => el.classList.remove("layouting"));
|
|
setIsLayouting(false);
|
|
}, LAYOUT_ANIMATION_MS);
|
|
}
|
|
},
|
|
[setNodes, setEdges, setIsLayouting, fitView],
|
|
);
|
|
|
|
const triggerLayout = useCallback(
|
|
(options?: Partial<ElkLayoutOptions>) => {
|
|
if (debounceRef.current) {
|
|
clearTimeout(debounceRef.current);
|
|
}
|
|
debounceRef.current = setTimeout(() => {
|
|
runLayout(options);
|
|
}, DEBOUNCE_MS);
|
|
},
|
|
[runLayout],
|
|
);
|
|
|
|
// Run layout on initial load when there are nodes
|
|
useEffect(() => {
|
|
if (isFirstLayout.current && nodeCount > 0) {
|
|
isFirstLayout.current = false;
|
|
// Check if all nodes are at default position (0,0) — meaning they need layout
|
|
const currentNodes = useGraphStore.getState().nodes;
|
|
const needsLayout = currentNodes.every(
|
|
(n) => n.position.x === 0 && n.position.y === 0,
|
|
);
|
|
if (needsLayout) {
|
|
runLayout();
|
|
}
|
|
}
|
|
}, [nodeCount, runLayout]);
|
|
|
|
// Re-layout when direction or routing changes
|
|
useEffect(() => {
|
|
if (!isFirstLayout.current && nodeCount > 0) {
|
|
triggerLayout();
|
|
}
|
|
// Only trigger on direction/routing changes, not on node count
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [layoutDirection, edgeRouting]);
|
|
|
|
// Re-layout on explicit request (e.g., AI-generated graph patch)
|
|
const layoutRequestId = useGraphStore((s) => s.layoutRequestId);
|
|
useEffect(() => {
|
|
if (layoutRequestId > 0 && nodeCount > 0) {
|
|
runLayout();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [layoutRequestId]);
|
|
|
|
// Cleanup worker on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceRef.current) {
|
|
clearTimeout(debounceRef.current);
|
|
}
|
|
terminateWorker();
|
|
};
|
|
}, []);
|
|
|
|
return { triggerLayout, isLayouting };
|
|
}
|