449 lines
21 KiB
TypeScript
449 lines
21 KiB
TypeScript
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<HTMLDivElement | null>;
|
|
viewport: ViewportTransform;
|
|
backgroundMode?: CanvasBackgroundMode;
|
|
onViewportChange: (viewport: ViewportTransform) => void;
|
|
onViewportPreviewChange?: (viewport: ViewportTransform) => void;
|
|
onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
|
|
boxSelectEnabled?: boolean;
|
|
onCanvasDoubleClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
onCanvasDeselect?: () => void;
|
|
onContextMenu?: (event: React.MouseEvent) => void;
|
|
onDrop?: (event: React.DragEvent<HTMLDivElement>) => void;
|
|
onFileDragEnter?: (event: React.DragEvent<HTMLDivElement>) => void;
|
|
onFileDragLeave?: (event: React.DragEvent<HTMLDivElement>) => void;
|
|
onFileDragOver?: (event: React.DragEvent<HTMLDivElement>) => 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<DOMRect | null>(null);
|
|
const frameRef = useRef<number | null>(null);
|
|
const nextViewportRef = useRef<ViewportTransform | null>(null);
|
|
const syncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const lastPreviewNotifyRef = useRef(0);
|
|
const interactingRef = useRef(false);
|
|
const touchPointsRef = useRef(new Map<number, TouchPoint>());
|
|
const pinchStateRef = useRef<PinchState>({ 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<HTMLDivElement>) => {
|
|
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 (
|
|
<div
|
|
ref={containerRef}
|
|
className={`relative h-full w-full select-none overflow-hidden touch-none ${isPanning ? "cursor-grabbing" : boxSelectEnabled ? "cursor-crosshair" : "cursor-grab"}`}
|
|
style={{
|
|
background: theme.canvas.background,
|
|
overscrollBehavior: "none",
|
|
"--canvas-live-x": `${viewport.x}px`,
|
|
"--canvas-live-y": `${viewport.y}px`,
|
|
"--canvas-live-scale": viewport.k,
|
|
"--canvas-live-inverse-scale": 1 / Math.max(viewport.k, 0.05),
|
|
"--canvas-committed-scale": viewport.k,
|
|
"--canvas-live-scale-ratio": 1,
|
|
"--canvas-grid-size": `${48 * viewport.k}px`,
|
|
"--canvas-grid-x": `${viewport.x % (48 * viewport.k)}px`,
|
|
"--canvas-grid-y": `${viewport.y % (48 * viewport.k)}px`,
|
|
"--canvas-dot-grid-size": `${canvasDotGridPx(viewport.k)}px`,
|
|
"--canvas-dot-grid-x": `${viewport.x % canvasDotGridPx(viewport.k)}px`,
|
|
"--canvas-dot-grid-y": `${viewport.y % canvasDotGridPx(viewport.k)}px`,
|
|
"--canvas-dot-size": canvasDotPx(viewport.k),
|
|
} as React.CSSProperties}
|
|
onPointerDown={handlePointerDown}
|
|
onDoubleClick={(event) => {
|
|
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}
|
|
>
|
|
<CanvasGrid mode={backgroundMode} />
|
|
{graphicsLayer}
|
|
<div
|
|
data-canvas-world-layer
|
|
className="canvas-world-layer absolute origin-top-left"
|
|
>
|
|
<div data-canvas-world-raster-layer className="canvas-world-raster-layer absolute origin-top-left">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
data-canvas-grid-layer
|
|
className="pointer-events-none absolute"
|
|
style={{
|
|
inset: mode === "dots" ? "calc(-1 * var(--canvas-dot-grid-size))" : "calc(-1 * var(--canvas-grid-size))",
|
|
backgroundImage,
|
|
backgroundSize: mode === "dots" ? "var(--canvas-dot-grid-size) var(--canvas-dot-grid-size)" : "var(--canvas-grid-size) var(--canvas-grid-size)",
|
|
transform: mode === "dots" ? "translate3d(var(--canvas-dot-grid-x), var(--canvas-dot-grid-y), 0)" : "translate3d(var(--canvas-grid-x), var(--canvas-grid-y), 0)",
|
|
opacity: mode === "dots" ? 0.34 : 0.46,
|
|
willChange: "transform",
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|