Files
ttstd-infinite-canvas/web/src/components/canvas/infinite-canvas.tsx

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);
}