Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-angle-scene.tsx
T
soonstudio-ai 818a3aa6f4 feat(canvas): 多角度贴节点、宫格切分菜单与多选虚线框跟随
把多角度改成贴在图片节点下方的 3D 编辑器并跟随拖动;宫格切分并入图片工具,自定义面板不关菜单;切分后子图按网格排开;多选虚线框随拖动预览平移。
2026-09-14 17:19:56 +08:00

226 lines
12 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react";
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
import "./canvas-angle-scene.css";
/** 预览交互范围对齐 recombyn:俯仰 ±60;水平保留 ±180 以便「背面」绕到主体后方。 */
const rotateMin = -180;
const rotateMax = 180;
const tiltMin = -60;
const tiltMax = 60;
const rotateStep = 5;
const tiltStep = 5;
const orbitRingY = [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165] as const;
const orbitRingX = [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165] as const;
const cameraOrbitRadius = 75;
const cameraSightLine = 67;
const subjectMaxBox = 64;
const cubeSize = 100;
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function snapInt(value: number, min: number, max: number) {
return Math.round(clamp(value, min, max));
}
function visualScale(distance: number) {
if (distance <= 3) return 0.78;
if (distance >= 7) return 1;
return 0.88;
}
function subjectBoxSize(naturalW: number, naturalH: number) {
const scale = Math.min(subjectMaxBox / Math.max(1, naturalW), subjectMaxBox / Math.max(1, naturalH));
return { width: Math.max(20, Math.round(naturalW * scale)), height: Math.max(20, Math.round(naturalH * scale)) };
}
export function CanvasAngleScene({
mode,
rotate,
tilt,
distance,
imageSrc,
faceLabels,
dirLabels,
onRotateChange,
onTiltChange,
}: {
mode: "camera" | "skybox";
rotate: number;
tilt: number;
distance: number;
imageSrc: string;
faceLabels: { back: string; right: string; left: string; top: string; bottom: string };
dirLabels: { up: string; down: string; left: string; right: string };
onRotateChange: (value: number) => void;
onTiltChange: (value: number) => void;
}) {
const scale = visualScale(distance);
const [naturalSize, setNaturalSize] = useState<{ w: number; h: number } | null>(null);
const [dragging, setDragging] = useState(false);
const sceneRef = useRef<HTMLDivElement>(null);
const dragPointer = useRef<number | null>(null);
const dragStart = useRef<{ x: number; y: number; rotate: number; tilt: number } | null>(null);
const rotateRef = useRef(onRotateChange);
const tiltRef = useRef(onTiltChange);
const modeRef = useRef(mode);
rotateRef.current = onRotateChange;
tiltRef.current = onTiltChange;
modeRef.current = mode;
useEffect(() => {
setNaturalSize(null);
let cancelled = false;
const image = new Image();
image.onload = () => {
if (cancelled) return;
const w = image.naturalWidth || image.width;
const h = image.naturalHeight || image.height;
if (w > 0 && h > 0) setNaturalSize({ w, h });
};
image.src = imageSrc;
return () => { cancelled = true; };
}, [imageSrc]);
const subjectSize = useMemo(() => {
if (!naturalSize) return { width: subjectMaxBox, height: subjectMaxBox };
return subjectBoxSize(naturalSize.w, naturalSize.h);
}, [naturalSize]);
const screenBg = useMemo<CSSProperties>(() => ({
backgroundImage: `url("${imageSrc}")`,
backgroundSize: "contain",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}), [imageSrc]);
useEffect(() => {
if (!dragging) return;
const onMove = (event: PointerEvent) => {
if (dragPointer.current == null || event.pointerId !== dragPointer.current || !dragStart.current) return;
const dx = event.clientX - dragStart.current.x;
const dy = event.clientY - dragStart.current.y;
rotateRef.current(snapInt(dragStart.current.rotate + dx / 1.8, rotateMin, rotateMax));
const tiltDelta = modeRef.current === "skybox" ? dy / 2.2 : -dy / 2.2;
tiltRef.current(snapInt(dragStart.current.tilt + tiltDelta, tiltMin, tiltMax));
};
const onUp = (event: PointerEvent) => {
if (dragPointer.current == null || event.pointerId !== dragPointer.current) return;
const start = dragStart.current;
if (start) {
const dx = event.clientX - start.x;
const dy = event.clientY - start.y;
rotateRef.current(snapInt(start.rotate + dx / 1.8, rotateMin, rotateMax));
const tiltDelta = modeRef.current === "skybox" ? dy / 2.2 : -dy / 2.2;
tiltRef.current(snapInt(start.tilt + tiltDelta, tiltMin, tiltMax));
}
dragPointer.current = null;
dragStart.current = null;
setDragging(false);
const el = sceneRef.current;
if (el?.hasPointerCapture?.(event.pointerId)) {
try { el.releasePointerCapture(event.pointerId); } catch { /* ignore */ }
}
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
};
}, [dragging]);
const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest(".canvas-angle-dir")) return;
event.preventDefault();
event.stopPropagation();
dragPointer.current = event.pointerId;
dragStart.current = { x: event.clientX, y: event.clientY, rotate, tilt };
setDragging(true);
try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* ignore */ }
};
const bumpRotate = (delta: number) => onRotateChange(snapInt(rotate + delta, rotateMin, rotateMax));
const bumpTilt = (delta: number) => onTiltChange(snapInt(tilt + delta, tiltMin, tiltMax));
const cubeStyle = { width: cubeSize, height: cubeSize, ["--angle-cube-half" as string]: `${cubeSize / 2}px` } as CSSProperties;
const dirButtons = (
<>
<button type="button" className="canvas-angle-dir is-up" aria-label={dirLabels.up} onClick={() => bumpTilt(tiltStep)}><ChevronUp className="size-3.5" strokeWidth={2} /></button>
<button type="button" className="canvas-angle-dir is-down" aria-label={dirLabels.down} onClick={() => bumpTilt(-tiltStep)}><ChevronDown className="size-3.5" strokeWidth={2} /></button>
<button type="button" className="canvas-angle-dir is-left" aria-label={dirLabels.left} onClick={() => bumpRotate(-rotateStep)}><ChevronLeft className="size-3.5" strokeWidth={2} /></button>
<button type="button" className="canvas-angle-dir is-right" aria-label={dirLabels.right} onClick={() => bumpRotate(rotateStep)}><ChevronRight className="size-3.5" strokeWidth={2} /></button>
</>
);
if (mode === "skybox") {
return (
<div className="canvas-angle-scene">
<div ref={sceneRef} className={cn("canvas-angle-unified is-skybox", dragging && "is-dragging")} style={{ perspective: 900 }} onPointerDown={onPointerDown}>
<div className="canvas-angle-skybox-stage">
<div className="canvas-angle-skybox-space" style={{ perspective: 900 }}>
<div className="canvas-angle-cube-wrap" style={{ transition: dragging ? "none" : undefined, transform: `scale(${scale}) rotateX(${-tilt}deg) rotateY(${rotate}deg)` }}>
<div className="canvas-angle-cube" style={cubeStyle}>
<div className="canvas-angle-cube-face is-front has-image"><img src={imageSrc} alt="" draggable={false} /></div>
<div className="canvas-angle-cube-face is-back"><span>{faceLabels.back}</span></div>
<div className="canvas-angle-cube-face is-right"><span>{faceLabels.right}</span></div>
<div className="canvas-angle-cube-face is-left"><span>{faceLabels.left}</span></div>
<div className="canvas-angle-cube-face is-top"><span>{faceLabels.top}</span></div>
<div className="canvas-angle-cube-face is-bottom"><span>{faceLabels.bottom}</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="canvas-angle-scene">
<div ref={sceneRef} className={cn("canvas-angle-unified is-camera", dragging && "is-dragging")} style={{ perspective: 1200 }} onPointerDown={onPointerDown}>
<div className="canvas-angle-subject-wrap">
<div className="canvas-angle-subject" style={{ width: subjectSize.width, height: subjectSize.height, transition: dragging ? "none" : "transform 120ms ease-out", transform: `scale(${scale})` }}>
<img src={imageSrc} alt="" draggable={false} />
</div>
</div>
<div className="canvas-angle-orbit" aria-hidden>
<div className="canvas-angle-orbit-core" style={{ transform: `rotateY(${rotate}deg) rotateX(${tilt}deg)`, transition: dragging ? "none" : "transform 120ms ease-out" }}>
{orbitRingY.map((deg) => <div key={`y-${deg}`} className="canvas-angle-orbit-ring" style={{ transform: `rotateY(${deg}deg)` }} />)}
{orbitRingX.map((deg) => <div key={`x-${deg}`} className="canvas-angle-orbit-ring" style={{ transform: `rotateX(${deg}deg)` }} />)}
</div>
<div className="canvas-angle-orbit-axis" />
</div>
<div className="canvas-angle-camera-layer">
<div className="canvas-angle-cam-pivot" style={{ transform: `rotateX(${tilt}deg) rotateY(${rotate}deg)` }}>
<div className="canvas-angle-cam-pos" style={{ transform: `translateZ(${cameraOrbitRadius}px) scale(1) rotateZ(0deg)` }}>
<div className="canvas-angle-cam-body is-front" style={{ transform: "translate(-50%, -50%) translateZ(-8px)" }}>
<div className="canvas-angle-cam-lens"><span /></div>
</div>
<div className="canvas-angle-cam-body is-back" style={{ transform: "translate(-50%, -50%) translateZ(8px)" }}>
<div className="canvas-angle-cam-screen" style={screenBg} />
</div>
<div className="canvas-angle-cam-body is-top" style={{ transform: "translate(-50%, -50%) rotateX(90deg) translateZ(8.2px)" }}>
<span className="canvas-angle-cam-shutter" />
</div>
<div className="canvas-angle-cam-body is-bottom" style={{ transform: "translate(-50%, -50%) rotateX(-90deg) translateZ(8.2px)" }} />
<div className="canvas-angle-cam-body is-side" style={{ transform: "translate(-50%, -50%) rotateY(-90deg) translateZ(11px)" }} />
<div className="canvas-angle-cam-body is-side" style={{ transform: "translate(-50%, -50%) rotateY(90deg) translateZ(11px)" }} />
<div className="canvas-angle-cam-hotshoe" style={{ transform: "translate(-50%, -50%) translateY(-12px)" }}>
<div className="canvas-angle-cam-hotshoe-body" style={{ transform: "translateZ(2px)" }}><span /></div>
</div>
<div className="canvas-angle-cam-line" style={{ height: cameraSightLine, transform: "translate(-50%, 0) translateZ(-8px) rotateX(-90deg)" }} />
</div>
</div>
<div className="canvas-angle-orbit-nav">{dirButtons}</div>
</div>
</div>
</div>
);
}