diff --git a/components/canvas/canvas-command-palette.tsx b/components/canvas/canvas-command-palette.tsx index f36a915..1f3dfcd 100644 --- a/components/canvas/canvas-command-palette.tsx +++ b/components/canvas/canvas-command-palette.tsx @@ -15,6 +15,7 @@ import { } from "lucide-react"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; +import { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position"; import { Command, CommandDialog, @@ -53,6 +54,7 @@ const NODE_SEARCH_KEYWORDS: Partial< export function CanvasCommandPalette() { const [open, setOpen] = useState(false); const { createNodeWithIntersection } = useCanvasPlacement(); + const getCenteredPosition = useCenteredFlowNodePosition(); const { setTheme } = useTheme(); const nodeCountRef = useRef(0); @@ -73,12 +75,12 @@ export function CanvasCommandPalette() { width: number, height: number, ) => { - const offset = (nodeCountRef.current % 8) * 24; + const stagger = (nodeCountRef.current % 8) * 24; nodeCountRef.current += 1; setOpen(false); void createNodeWithIntersection({ type, - position: { x: 100 + offset, y: 100 + offset }, + position: getCenteredPosition(width, height, stagger), width, height, data, diff --git a/components/canvas/canvas-toolbar.tsx b/components/canvas/canvas-toolbar.tsx index 8e58838..f8b5626 100644 --- a/components/canvas/canvas-toolbar.tsx +++ b/components/canvas/canvas-toolbar.tsx @@ -5,6 +5,7 @@ import { useRef } from "react"; import { CreditDisplay } from "@/components/canvas/credit-display"; import { ExportButton } from "@/components/canvas/export-button"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; +import { useCenteredFlowNodePosition } from "@/hooks/use-centered-flow-node-position"; import { CANVAS_NODE_TEMPLATES, type CanvasNodeTemplate, @@ -18,6 +19,7 @@ export default function CanvasToolbar({ canvasName, }: CanvasToolbarProps) { const { createNodeWithIntersection } = useCanvasPlacement(); + const getCenteredPosition = useCenteredFlowNodePosition(); const nodeCountRef = useRef(0); const handleAddNode = async ( @@ -26,11 +28,11 @@ export default function CanvasToolbar({ width: number, height: number, ) => { - const offset = (nodeCountRef.current % 8) * 24; + const stagger = (nodeCountRef.current % 8) * 24; nodeCountRef.current += 1; await createNodeWithIntersection({ type, - position: { x: 100 + offset, y: 100 + offset }, + position: getCenteredPosition(width, height, stagger), width, height, data, diff --git a/hooks/use-centered-flow-node-position.ts b/hooks/use-centered-flow-node-position.ts new file mode 100644 index 0000000..698a79d --- /dev/null +++ b/hooks/use-centered-flow-node-position.ts @@ -0,0 +1,44 @@ +"use client"; + +import { useCallback } from "react"; +import { useReactFlow } from "@xyflow/react"; + +const SNAP = 16; + +function snapToGrid(x: number, y: number): { x: number; y: number } { + return { + x: Math.round(x / SNAP) * SNAP, + y: Math.round(y / SNAP) * SNAP, + }; +} + +/** + * Top-left flow position for a node centered in the visible React Flow pane + * (viewport), with optional stagger and 16px grid snap to match the canvas. + */ +export function useCenteredFlowNodePosition() { + const { screenToFlowPosition } = useReactFlow(); + + return useCallback( + (width: number, height: number, stagger: number) => { + const pane = document.querySelector(".react-flow__pane"); + const rect = + pane?.getBoundingClientRect() ?? + document.querySelector(".react-flow")?.getBoundingClientRect(); + + if (!rect || rect.width === 0 || rect.height === 0) { + return snapToGrid(100 + stagger, 100 + stagger); + } + + const center = screenToFlowPosition({ + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }); + + const x = center.x - width / 2 + stagger; + const y = center.y - height / 2 + stagger; + return snapToGrid(x, y); + }, + [screenToFlowPosition], + ); +}