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

226 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}