import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; import { applyCanvasLiveViewport, canvasDotGridPx, canvasDotPx, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; import { useThemeStore } from "@/stores/use-theme-store"; import type { ViewportTransform } from "@/types/canvas"; type InfiniteCanvasProps = { containerRef: React.RefObject; viewport: ViewportTransform; backgroundMode?: CanvasBackgroundMode; onViewportChange: (viewport: ViewportTransform) => void; onViewportPreviewChange?: (viewport: ViewportTransform) => void; onCanvasMouseDown?: (event: React.PointerEvent) => void; boxSelectEnabled?: boolean; onCanvasDoubleClick?: (event: React.MouseEvent) => void; onCanvasDeselect?: () => void; onContextMenu?: (event: React.MouseEvent) => void; onDrop?: (event: React.DragEvent) => void; onFileDragEnter?: (event: React.DragEvent) => void; onFileDragLeave?: (event: React.DragEvent) => void; onFileDragOver?: (event: React.DragEvent) => void; children: React.ReactNode; graphicsLayer?: React.ReactNode; }; const CANVAS_WHEEL_IGNORE_SELECTOR = "[data-canvas-no-zoom],[data-canvas-wheel-scroll],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown"; const CANVAS_POINTER_IGNORE_SELECTOR = "[data-canvas-no-zoom],[data-connection-create-menu],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown"; const WHEEL_ZOOM_DELTA = 72; const TRACKPAD_PINCH_ZOOM_DELTA = 24; type TouchPoint = { x: number; y: number }; type PinchState = { active: boolean; pointerIds: [number, number]; initialDistance: number; worldX: number; worldY: number; initialScale: number; }; export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, boxSelectEnabled = false, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const panState = useRef({ isPanning: false, pointerId: -1, startX: 0, startY: 0, initialX: 0, initialY: 0, hasMoved: false, }); const viewportRef = useRef(viewport); const scaleRef = useRef(viewport.k); const containerRectRef = useRef(null); const frameRef = useRef(null); const nextViewportRef = useRef(null); const syncTimerRef = useRef | null>(null); const lastPreviewNotifyRef = useRef(0); const interactingRef = useRef(false); const touchPointsRef = useRef(new Map()); const pinchStateRef = useRef({ active: false, pointerIds: [-1, -1], initialDistance: 1, worldX: 0, worldY: 0, initialScale: viewport.k }); const [isSpacePressed, setIsSpacePressed] = useState(false); const [isPanning, setIsPanning] = useState(false); useLayoutEffect(() => { if (interactingRef.current) return; viewportRef.current = viewport; scaleRef.current = viewport.k; applyCanvasLiveViewport(containerRef.current, viewport); }, [containerRef, viewport]); useEffect(() => { const container = containerRef.current; if (!container) return; return subscribeCanvasViewportPreview(container, (next) => { viewportRef.current = next; scaleRef.current = next.k; }); }, [containerRef]); useEffect( () => () => { if (frameRef.current) cancelAnimationFrame(frameRef.current); if (syncTimerRef.current) clearTimeout(syncTimerRef.current); delete containerRef.current?.dataset.canvasViewportInteracting; }, [containerRef], ); const syncViewport = useCallback(() => onViewportChange(viewportRef.current), [onViewportChange]); const scheduleViewportChange = useCallback( (next: ViewportTransform, commitAfterIdle = false) => { viewportRef.current = next; scaleRef.current = next.k; onViewportPreviewChange?.(next); const container = containerRef.current; if (container) container.dataset.canvasViewportInteracting = "true"; nextViewportRef.current = next; if (frameRef.current) return; frameRef.current = requestAnimationFrame((now) => { frameRef.current = null; const pending = nextViewportRef.current; if (!pending) return; const notify = now - lastPreviewNotifyRef.current >= 32; applyCanvasLiveViewport(containerRef.current, pending, notify); if (notify) lastPreviewNotifyRef.current = now; }); if (!commitAfterIdle) return; if (syncTimerRef.current) clearTimeout(syncTimerRef.current); syncTimerRef.current = setTimeout(() => { interactingRef.current = false; delete containerRef.current?.dataset.canvasViewportInteracting; syncViewport(); syncTimerRef.current = null; }, 120); }, [containerRef, onViewportPreviewChange, syncViewport], ); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.code !== "Space") return; if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return; setIsSpacePressed(true); }; const handleKeyUp = (event: KeyboardEvent) => { if (event.code === "Space") setIsSpacePressed(false); }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); }; }, []); const handleWheel = useCallback( (event: WheelEvent) => { const target = event.target instanceof Element ? event.target : null; const deltaX = wheelDeltaToPixels(event.deltaX, event.deltaMode); const deltaY = wheelDeltaToPixels(event.deltaY, event.deltaMode); const absX = Math.abs(deltaX); const absY = Math.abs(deltaY); const isPinchZoom = event.ctrlKey || event.metaKey; if (target?.closest(CANVAS_WHEEL_IGNORE_SELECTOR) && !isPinchZoom) { // 内部区域保留纵向滚动,但横向手势不能泄漏为 macOS 浏览器前进/后退。 if (event.shiftKey || absX > absY) event.preventDefault(); return; } // Ctrl/Meta + 滚轮在画布内始终由画布接管,避免浮层区域触发浏览器页面缩放。 event.preventDefault(); interactingRef.current = true; const current = viewportRef.current; const rawAbsY = Math.abs(event.deltaY); const looksLikeMouseWheel = event.deltaMode !== 0 || (rawAbsY >= 80 && Math.abs(rawAbsY - Math.round(rawAbsY / 100) * 100) < 1); const looksLikeTrackpadPan = !isPinchZoom && (event.shiftKey || absX > 0 || (!looksLikeMouseWheel && absY > 0)); if (looksLikeTrackpadPan) { const panX = event.shiftKey && absX < 1 ? deltaY : deltaX; scheduleViewportChange({ x: current.x - panX, y: current.y - (event.shiftKey && absX < 1 ? 0 : deltaY), k: current.k, }, true); return; } const rect = containerRectRef.current || containerRef.current?.getBoundingClientRect(); if (!rect) return; const mouseX = event.clientX - rect.left; const mouseY = event.clientY - rect.top; const zoomDelta = isPinchZoom && !looksLikeMouseWheel ? TRACKPAD_PINCH_ZOOM_DELTA : WHEEL_ZOOM_DELTA; const factor = Math.pow(1.1, -deltaY / zoomDelta); const newScale = clampScale(current.k * factor); const worldX = (mouseX - current.x) / current.k; const worldY = (mouseY - current.y) / current.k; scheduleViewportChange({ x: mouseX - worldX * newScale, y: mouseY - worldY * newScale, k: newScale, }, true); }, [containerRef, scheduleViewportChange], ); const handlePointerDown = (event: React.PointerEvent) => { const target = event.target instanceof Element ? event.target : null; // AntD 浮层通过 Portal 渲染到节点 DOM 之外;若不统一排除,会被误判为画布空白并捕获指针。 if (target?.closest(CANVAS_POINTER_IGNORE_SELECTOR)) return; const isBackgroundClick = !target?.closest("[data-node-id],[data-connection-id]"); const isTouch = event.pointerType === "touch"; const hasSelectionModifier = event.shiftKey || event.ctrlKey || event.metaKey || event.altKey; if (event.button === 0 && !isSpacePressed && !isTouch && isBackgroundClick && (hasSelectionModifier || boxSelectEnabled)) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); onCanvasMouseDown?.(event); return; } if (isTouch) { touchPointsRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); if (touchPointsRef.current.size >= 2) { const [[firstId, first], [secondId, second]] = Array.from(touchPointsRef.current.entries()); event.preventDefault(); event.currentTarget.setPointerCapture(firstId); event.currentTarget.setPointerCapture(secondId); const rect = containerRectRef.current || event.currentTarget.getBoundingClientRect(); const current = viewportRef.current; const centerX = (first.x + second.x) / 2 - rect.left; const centerY = (first.y + second.y) / 2 - rect.top; pinchStateRef.current = { active: true, pointerIds: [firstId, secondId], initialDistance: Math.max(Math.hypot(second.x - first.x, second.y - first.y), 1), worldX: (centerX - current.x) / current.k, worldY: (centerY - current.y) / current.k, initialScale: current.k, }; panState.current.isPanning = false; interactingRef.current = true; return; } if (!isBackgroundClick) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); if (!event.isPrimary) return; const current = viewportRef.current; interactingRef.current = true; panState.current = { isPanning: true, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, initialX: current.x, initialY: current.y, hasMoved: false, }; setIsPanning(true); document.body.style.cursor = "grabbing"; return; } if (isBackgroundClick && (event.button === 1 || event.button === 0)) { const current = viewportRef.current; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); interactingRef.current = true; panState.current = { isPanning: true, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, initialX: current.x, initialY: current.y, hasMoved: false, }; setIsPanning(true); document.body.style.cursor = "grabbing"; } }; useEffect(() => { const handlePointerMove = (event: PointerEvent) => { if (event.pointerType === "touch" && touchPointsRef.current.has(event.pointerId)) { touchPointsRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }); const pinch = pinchStateRef.current; if (pinch.active) { const first = touchPointsRef.current.get(pinch.pointerIds[0]); const second = touchPointsRef.current.get(pinch.pointerIds[1]); const rect = containerRectRef.current || containerRef.current?.getBoundingClientRect(); if (!first || !second || !rect) return; event.preventDefault(); const centerX = (first.x + second.x) / 2 - rect.left; const centerY = (first.y + second.y) / 2 - rect.top; const distance = Math.max(Math.hypot(second.x - first.x, second.y - first.y), 1); const scale = clampScale(pinch.initialScale * (distance / pinch.initialDistance)); scheduleViewportChange({ x: centerX - pinch.worldX * scale, y: centerY - pinch.worldY * scale, k: scale, }); return; } } if (!panState.current.isPanning || panState.current.pointerId !== event.pointerId) 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.hasMoved = true; } scheduleViewportChange({ x: panState.current.initialX + dx, y: panState.current.initialY + dy, k: scaleRef.current, }); }; const handlePointerEnd = (event: PointerEvent) => { if (event.pointerType === "touch" && pinchStateRef.current.active && pinchStateRef.current.pointerIds.includes(event.pointerId)) { pinchStateRef.current.active = false; touchPointsRef.current.clear(); panState.current.isPanning = false; panState.current.pointerId = -1; interactingRef.current = false; if (syncTimerRef.current) clearTimeout(syncTimerRef.current); delete containerRef.current?.dataset.canvasViewportInteracting; syncViewport(); setIsPanning(false); document.body.style.cursor = "default"; return; } if (event.pointerType === "touch") touchPointsRef.current.delete(event.pointerId); if (!panState.current.isPanning || panState.current.pointerId !== event.pointerId) return; if (event.type === "pointerup" && !panState.current.hasMoved) { onCanvasDeselect?.(); } panState.current.isPanning = false; panState.current.pointerId = -1; interactingRef.current = false; if (syncTimerRef.current) clearTimeout(syncTimerRef.current); delete containerRef.current?.dataset.canvasViewportInteracting; syncViewport(); setIsPanning(false); document.body.style.cursor = "default"; }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerEnd); window.addEventListener("pointercancel", handlePointerEnd); return () => { window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerEnd); window.removeEventListener("pointercancel", handlePointerEnd); }; }, [containerRef, onCanvasDeselect, scheduleViewportChange, syncViewport]); useEffect(() => { const container = containerRef.current; if (!container) return; const updateRect = () => { containerRectRef.current = container.getBoundingClientRect(); }; updateRect(); const observer = new ResizeObserver(updateRect); observer.observe(container); window.addEventListener("resize", updateRect); container.addEventListener("wheel", handleWheel, { passive: false, capture: true }); return () => { observer.disconnect(); window.removeEventListener("resize", updateRect); container.removeEventListener("wheel", handleWheel, { capture: true }); }; }, [containerRef, handleWheel]); return (
{ const target = event.target instanceof Element ? event.target : null; if (!target?.closest("[data-node-id],[data-connection-id],[data-canvas-no-zoom]")) onCanvasDoubleClick?.(event); }} onContextMenu={onContextMenu} onDragEnter={onFileDragEnter} onDragLeave={onFileDragLeave} onDragOver={(event) => { event.preventDefault(); onFileDragOver?.(event); }} onDrop={onDrop} > {graphicsLayer}
{children}
); } function CanvasGrid({ mode }: { mode: CanvasBackgroundMode }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const backgroundImage = mode === "dots" ? `radial-gradient(circle, ${theme.canvas.dot} var(--canvas-dot-size), transparent calc(var(--canvas-dot-size) + 0.2px))` : `linear-gradient(${theme.canvas.line} 1px, transparent 1px), linear-gradient(90deg, ${theme.canvas.line} 1px, transparent 1px)`; if (mode === "blank") return null; return (
); } function wheelDeltaToPixels(delta: number, deltaMode: number) { if (deltaMode === 1) return delta * 16; if (deltaMode === 2) return delta * 720; return delta; } function clampScale(scale: number) { return Math.min(Math.max(scale, 0.05), 2); }