Files
imagegen-tools/src/components/canvas/infinite-canvas.tsx
T

319 lines
9.4 KiB
TypeScript

"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<HTMLDivElement | null>;
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<typeof setTimeout> | null,
});
const viewportRef = useRef(viewport);
const frameRef = useRef<number | null>(null);
const nextViewportRef = useRef<CanvasViewport | null>(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<HTMLDivElement>) {
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<HTMLDivElement>) {
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 (
<div
ref={containerRef}
className="relative h-full w-full cursor-grab select-none overflow-hidden bg-zinc-100"
onPointerDown={handlePointerDown}
onWheel={handleWheel}
>
<CanvasGrid mode={backgroundMode} viewport={viewport} />
<div
className="absolute origin-top-left"
style={{
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.k})`,
}}
>
{children}
</div>
</div>
);
}
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 (
<div
className="pointer-events-none absolute inset-0 opacity-80"
style={{
backgroundImage,
backgroundPosition: `${x}px ${y}px`,
backgroundSize: `${gridSize}px ${gridSize}px`,
}}
/>
);
}
function isTypingTarget(target: EventTarget | null) {
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
);
}