"use client"; import { useEffect, useRef, useState } from "react"; import type { PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react"; import type { CanvasBackgroundMode, CanvasPosition, CanvasViewport, } from "@/lib/canvas/types"; import { CANVAS_GRID_SIZE, clampScale, screenToWorld, } from "@/lib/canvas/geometry"; type InfiniteCanvasProps = { containerRef: RefObject; viewport: CanvasViewport; backgroundMode: CanvasBackgroundMode; selectionMode?: boolean; onBackgroundPointerDown?: () => boolean | void; onViewportChange: (viewport: CanvasViewport) => void; onCanvasPointerMove?: (point: CanvasPosition) => void; onCanvasPointerUp?: () => void; onCanvasDeselect?: () => void; onSelectionStart?: (point: CanvasPosition) => void; onSelectionMove?: (point: CanvasPosition) => void; onSelectionEnd?: (point: CanvasPosition) => void; children: ReactNode; }; export function InfiniteCanvas({ containerRef, viewport, backgroundMode, selectionMode = false, onBackgroundPointerDown, onViewportChange, onCanvasPointerMove, onCanvasPointerUp, onCanvasDeselect, onSelectionStart, onSelectionMove, onSelectionEnd, children, }: InfiniteCanvasProps) { const panState = useRef({ mode: "idle" as "idle" | "pending" | "panning" | "selecting", startX: 0, startY: 0, initialX: 0, initialY: 0, moved: false, pointerWorld: { x: 0, y: 0 }, longPressTimer: null as ReturnType | null, }); const viewportRef = useRef(viewport); const frameRef = useRef(null); const nextViewportRef = useRef(null); const [spacePressed, setSpacePressed] = useState(false); useEffect(() => { viewportRef.current = viewport; }, [viewport]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.code !== "Space") return; if (isTypingTarget(event.target)) return; event.preventDefault(); setSpacePressed(true); }; const handleKeyUp = (event: KeyboardEvent) => { if (event.code === "Space") setSpacePressed(false); }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); }; }, []); useEffect(() => { const container = containerRef.current; if (!container) return; const preventNativeScroll = (event: WheelEvent) => event.preventDefault(); container.addEventListener("wheel", preventNativeScroll, { passive: false }); return () => container.removeEventListener("wheel", preventNativeScroll); }, [containerRef]); useEffect( () => () => { if (frameRef.current) cancelAnimationFrame(frameRef.current); if (panState.current.longPressTimer) { clearTimeout(panState.current.longPressTimer); } }, [], ); useEffect(() => { const handlePointerMove = (event: PointerEvent) => { const rect = containerRef.current?.getBoundingClientRect(); if (rect) { const worldPoint = screenToWorld( { x: event.clientX - rect.left, y: event.clientY - rect.top }, viewportRef.current, ); panState.current.pointerWorld = worldPoint; onCanvasPointerMove?.(worldPoint); if (panState.current.mode === "selecting") { onSelectionMove?.(worldPoint); } } if (panState.current.mode === "pending") { const dx = event.clientX - panState.current.startX; const dy = event.clientY - panState.current.startY; if (Math.abs(dx) > 4 || Math.abs(dy) > 4) { if (panState.current.longPressTimer) { clearTimeout(panState.current.longPressTimer); panState.current.longPressTimer = null; } panState.current.mode = "panning"; document.body.style.cursor = "grabbing"; } } if (panState.current.mode !== "panning") return; const dx = event.clientX - panState.current.startX; const dy = event.clientY - panState.current.startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { panState.current.moved = true; } nextViewportRef.current = { x: panState.current.initialX + dx, y: panState.current.initialY + dy, k: viewportRef.current.k, }; if (frameRef.current) return; frameRef.current = requestAnimationFrame(() => { frameRef.current = null; if (nextViewportRef.current) onViewportChange(nextViewportRef.current); }); }; const handlePointerUp = () => { if (panState.current.longPressTimer) { clearTimeout(panState.current.longPressTimer); panState.current.longPressTimer = null; } if (panState.current.mode === "selecting") { onSelectionEnd?.(panState.current.pointerWorld); } else if ( (panState.current.mode === "pending" || panState.current.mode === "panning") && !panState.current.moved ) { onCanvasDeselect?.(); } panState.current.mode = "idle"; document.body.style.cursor = ""; onCanvasPointerUp?.(); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); return () => { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); }; }, [ containerRef, onCanvasDeselect, onCanvasPointerMove, onCanvasPointerUp, onViewportChange, onSelectionEnd, onSelectionMove, ]); function handleWheel(event: React.WheelEvent) { if (event.target instanceof Element && event.target.closest("[data-canvas-ui]")) { return; } const rect = containerRef.current?.getBoundingClientRect(); if (!rect) return; const mouse = { x: event.clientX - rect.left, y: event.clientY - rect.top, }; const delta = -event.deltaY; const factor = Math.pow(1.12, delta / 100); const nextScale = clampScale(viewport.k * factor); const world = screenToWorld(mouse, viewport); onViewportChange({ x: mouse.x - world.x * nextScale, y: mouse.y - world.y * nextScale, k: nextScale, }); } function handlePointerDown(event: ReactPointerEvent) { const target = event.target instanceof Element ? event.target : null; const isBackground = !target?.closest("[data-node-id],[data-connection-id],[data-canvas-ui]"); if (!isBackground) return; if (onBackgroundPointerDown?.()) { event.preventDefault(); return; } const rect = containerRef.current?.getBoundingClientRect(); const pointerWorld = rect ? screenToWorld( { x: event.clientX - rect.left, y: event.clientY - rect.top }, viewport, ) : { x: 0, y: 0 }; if (event.button === 0 || event.button === 1 || spacePressed) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); panState.current = { mode: selectionMode ? "selecting" : event.button === 1 || spacePressed ? "panning" : "pending", startX: event.clientX, startY: event.clientY, initialX: viewport.x, initialY: viewport.y, moved: false, pointerWorld, longPressTimer: null, }; if (selectionMode) { document.body.style.cursor = "crosshair"; onSelectionStart?.(pointerWorld); return; } if (event.button === 1 || spacePressed) { document.body.style.cursor = "grabbing"; return; } panState.current.longPressTimer = setTimeout(() => { panState.current.mode = "selecting"; panState.current.longPressTimer = null; document.body.style.cursor = "crosshair"; onSelectionStart?.(panState.current.pointerWorld); }, 260); } } return (
{children}
); } function CanvasGrid({ mode, viewport, }: { mode: CanvasBackgroundMode; viewport: CanvasViewport; }) { if (mode === "blank") return null; const gridSize = CANVAS_GRID_SIZE * viewport.k; const x = viewport.x % gridSize; const y = viewport.y % gridSize; const backgroundImage = mode === "dots" ? "radial-gradient(circle, rgba(113, 113, 122, 0.22) 1.2px, transparent 1.5px)" : "linear-gradient(rgba(161, 161, 170, 0.22) 1px, transparent 1px), linear-gradient(90deg, rgba(161, 161, 170, 0.22) 1px, transparent 1px)"; return (
); } function isTypingTarget(target: EventTarget | null) { return ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || (target instanceof HTMLElement && target.isContentEditable) ); }