Save current image generation workspace
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
"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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user