feat(canvas): PR #500 - 合并多角度与切分并保留本地 Agent 改造
This commit is contained in:
500
web/src/components/canvas/canvas-angle-scene.css
Normal file
500
web/src/components/canvas/canvas-angle-scene.css
Normal file
@@ -0,0 +1,500 @@
|
||||
/* CSS 3D orbit/skybox preview reconstructed from recombyn AngleEditorScene, scoped to canvas-angle-*. */
|
||||
.canvas-angle-scene {
|
||||
--mae-face-bg: color-mix(in srgb, var(--fg) 8%, var(--bg));
|
||||
--mae-face-edge: color-mix(in srgb, var(--fg) 28%, transparent);
|
||||
--mae-face-edge-soft: color-mix(in srgb, var(--fg) 12%, transparent);
|
||||
--mae-face-label: color-mix(in srgb, var(--fg) 38%, transparent);
|
||||
--mae-wire: color-mix(in srgb, var(--fg) 28%, transparent);
|
||||
--mae-wire-axis: color-mix(in srgb, var(--fg) 32%, transparent);
|
||||
--mae-arrow: color-mix(in srgb, var(--fg) 55%, transparent);
|
||||
--mae-arrow-hover-bg: color-mix(in srgb, var(--fg) 10%, transparent);
|
||||
--mae-subject-border: color-mix(in srgb, var(--fg) 16%, transparent);
|
||||
--mae-subject-bg: color-mix(in srgb, var(--fg) 6%, var(--bg));
|
||||
--mae-subject-shadow: 0 1px 8px color-mix(in srgb, var(--fg) 18%, transparent);
|
||||
--mae-cam-front: color-mix(in srgb, var(--fg) 10%, var(--bg));
|
||||
--mae-cam-body: color-mix(in srgb, var(--fg) 16%, var(--bg));
|
||||
--mae-cam-side: color-mix(in srgb, var(--fg) 22%, var(--bg));
|
||||
--mae-cam-lens: color-mix(in srgb, var(--fg) 42%, var(--bg));
|
||||
--mae-cam-lens-core: var(--fg);
|
||||
--mae-cam-edge: color-mix(in srgb, var(--fg) 14%, transparent);
|
||||
--mae-globe-size: 150px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 280px;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-unified {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
perspective: 1200px;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-unified.is-camera {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-unified.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-subject-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-skybox-space {
|
||||
perspective: 1200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cube-wrap {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cube {
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cube-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--r-xs, 2px);
|
||||
border: 1px solid var(--mae-face-edge-soft);
|
||||
background: var(--mae-face-bg);
|
||||
color: var(--mae-face-label);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .is-skybox .canvas-angle-cube-face {
|
||||
border: none;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--mae-face-edge),
|
||||
0 0 0 0.5px var(--mae-face-edge-soft);
|
||||
background: var(--mae-face-bg);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 500;
|
||||
color: var(--mae-face-label);
|
||||
backface-visibility: visible;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .is-skybox .canvas-angle-cube-face.has-image img {
|
||||
width: 72%;
|
||||
height: 72%;
|
||||
object-fit: contain;
|
||||
border-radius: var(--r-xs, 2px);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-subject {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
max-width: 64px;
|
||||
max-height: 64px;
|
||||
transform-style: flat;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-subject img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
border-radius: var(--r-xs, 2px);
|
||||
border: 1px solid var(--mae-subject-border);
|
||||
background: var(--mae-subject-bg);
|
||||
box-shadow: var(--mae-subject-shadow);
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cube-face.is-front { transform: translateZ(var(--angle-cube-half, 50px)); }
|
||||
.canvas-angle-scene .canvas-angle-cube-face.is-back { transform: rotateY(180deg) translateZ(var(--angle-cube-half, 50px)); }
|
||||
.canvas-angle-scene .canvas-angle-cube-face.is-right { transform: rotateY(90deg) translateZ(var(--angle-cube-half, 50px)); }
|
||||
.canvas-angle-scene .canvas-angle-cube-face.is-left { transform: rotateY(-90deg) translateZ(var(--angle-cube-half, 50px)); }
|
||||
.canvas-angle-scene .canvas-angle-cube-face.is-top { transform: rotateX(90deg) translateZ(var(--angle-cube-half, 50px)); }
|
||||
.canvas-angle-scene .canvas-angle-cube-face.is-bottom { transform: rotateX(-90deg) translateZ(var(--angle-cube-half, 50px)); }
|
||||
|
||||
.canvas-angle-scene .canvas-angle-orbit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-orbit-core {
|
||||
position: relative;
|
||||
width: var(--mae-globe-size);
|
||||
height: var(--mae-globe-size);
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-orbit-ring {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: var(--mae-globe-size);
|
||||
height: var(--mae-globe-size);
|
||||
margin-left: calc(var(--mae-globe-size) / -2);
|
||||
margin-top: calc(var(--mae-globe-size) / -2);
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--mae-wire);
|
||||
border-radius: 50%;
|
||||
opacity: 0.55;
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: visible;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-orbit-axis {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 1px;
|
||||
height: var(--mae-globe-size);
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--mae-wire-axis);
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-orbit-nav {
|
||||
--mae-orbit-arrow-size: 18px;
|
||||
--mae-orbit-arrow-gap: 10px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: calc(var(--mae-globe-size) + 2 * (var(--mae-orbit-arrow-gap) + var(--mae-orbit-arrow-size)));
|
||||
height: calc(var(--mae-globe-size) + 2 * (var(--mae-orbit-arrow-gap) + var(--mae-orbit-arrow-size)));
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-camera-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-pivot {
|
||||
position: relative;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
transform-style: preserve-3d;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-pos {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-body {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
box-sizing: border-box;
|
||||
background: var(--mae-cam-body);
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-body.is-front,
|
||||
.canvas-angle-scene .canvas-angle-cam-body.is-back {
|
||||
width: 22px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-body.is-front {
|
||||
background: var(--mae-cam-front);
|
||||
box-shadow: 0 0 0 1px var(--mae-cam-edge);
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-body.is-back {
|
||||
background: var(--mae-cam-body);
|
||||
box-shadow: 0 0 0 1px var(--mae-cam-edge);
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-lens {
|
||||
position: relative;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin: 3px auto 0;
|
||||
border-radius: 50%;
|
||||
background: var(--mae-cam-lens);
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-lens span {
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--mae-cam-lens-core);
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-screen {
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: var(--r-xs, 2px);
|
||||
background-color: color-mix(in srgb, var(--fg) 88%, var(--bg));
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-body.is-top,
|
||||
.canvas-angle-scene .canvas-angle-cam-body.is-bottom {
|
||||
width: 22px;
|
||||
height: 16px;
|
||||
border-radius: var(--r-xs, 2px);
|
||||
background: var(--mae-cam-body);
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-body.is-side {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--r-xs, 2px);
|
||||
background: var(--mae-cam-side);
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-shutter {
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: 4px auto 0;
|
||||
border-radius: 50%;
|
||||
background: var(--mae-cam-lens);
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-hotshoe {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-hotshoe-body {
|
||||
width: 10px;
|
||||
height: 4px;
|
||||
margin-left: -5px;
|
||||
border-radius: 1px;
|
||||
background: var(--mae-cam-side);
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-hotshoe-body span {
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 2px;
|
||||
margin: 1px auto 0;
|
||||
border-radius: 1px;
|
||||
background: var(--mae-cam-lens);
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-cam-line {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 1px;
|
||||
background: var(--mae-cam-lens);
|
||||
opacity: 0.75;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-dir {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--mae-arrow);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-dir:hover {
|
||||
color: var(--fg);
|
||||
background: var(--mae-arrow-hover-bg);
|
||||
}
|
||||
|
||||
.canvas-angle-scene .canvas-angle-orbit-nav .canvas-angle-dir.is-up {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.canvas-angle-scene .canvas-angle-orbit-nav .canvas-angle-dir.is-down {
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.canvas-angle-scene .canvas-angle-orbit-nav .canvas-angle-dir.is-left {
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.canvas-angle-scene .canvas-angle-orbit-nav .canvas-angle-dir.is-right {
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.canvas-angle-scene .is-skybox .canvas-angle-skybox-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-angle-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.canvas-angle-body {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.canvas-angle-seg {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border-radius: var(--r-lg);
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
}
|
||||
|
||||
.canvas-angle-seg button {
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: var(--fs-label);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.canvas-angle-seg button[aria-pressed="true"] {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.canvas-angle-tab {
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 10px;
|
||||
border: var(--stroke-1) solid var(--dock-border);
|
||||
border-radius: var(--r-md);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: var(--fs-tiny);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.canvas-angle-tab:hover {
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
}
|
||||
|
||||
.canvas-angle-tab[aria-pressed="true"] {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
border-color: var(--fg);
|
||||
}
|
||||
|
||||
.canvas-angle-preset {
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: var(--r-lg);
|
||||
background: color-mix(in srgb, var(--fg) 7%, transparent);
|
||||
color: inherit;
|
||||
font-size: var(--fs-label);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.canvas-angle-preset:hover {
|
||||
background: color-mix(in srgb, var(--fg) 12%, transparent);
|
||||
}
|
||||
|
||||
.canvas-angle-preset[aria-pressed="true"] {
|
||||
background: var(--fg);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.canvas-angle-generate {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.canvas-angle-slider.ant-slider .ant-slider-rail {
|
||||
background: color-mix(in srgb, var(--fg) 16%, transparent) !important;
|
||||
}
|
||||
|
||||
.canvas-angle-slider.ant-slider .ant-slider-track {
|
||||
background: var(--fg) !important;
|
||||
}
|
||||
|
||||
.canvas-angle-slider.ant-slider .ant-slider-handle::after {
|
||||
box-shadow: 0 0 0 2px var(--fg) !important;
|
||||
background: var(--bg) !important;
|
||||
}
|
||||
|
||||
.canvas-angle-stage {
|
||||
position: relative;
|
||||
min-height: 280px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--bg);
|
||||
}
|
||||
225
web/src/components/canvas/canvas-angle-scene.tsx
Normal file
225
web/src/components/canvas/canvas-angle-scene.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
179
web/src/components/canvas/canvas-grid-split-picker.css
Normal file
179
web/src/components/canvas/canvas-grid-split-picker.css
Normal file
@@ -0,0 +1,179 @@
|
||||
.canvas-node-toolbar-menu-split {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.canvas-node-toolbar-menu-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.canvas-node-toolbar-menu-split .canvas-node-toolbar-menu-stack {
|
||||
min-width: 13.5rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--workspace-overlay-border, var(--dock-border));
|
||||
border-radius: 8px;
|
||||
background: var(--workspace-overlay-bg-strong, var(--dock-surface));
|
||||
}
|
||||
|
||||
.canvas-node-toolbar-menu-split .ant-dropdown-menu {
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.canvas-node-toolbar-menu .ant-dropdown-menu-item,
|
||||
.canvas-node-toolbar-menu .ant-dropdown-menu-submenu-title {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.canvas-node-toolbar-menu .ant-dropdown-menu-item-icon {
|
||||
display: inline-flex;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 14px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.canvas-node-toolbar-menu .ant-dropdown-menu-item-icon svg {
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.canvas-grid-split-menu-item.is-open {
|
||||
background: var(--workspace-overlay-hover, color-mix(in srgb, var(--popover-foreground) 6%, var(--popover)));
|
||||
}
|
||||
|
||||
.canvas-grid-split-menu-label {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.canvas-grid-split-menu-label > div:first-child {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.canvas-grid-split-item .canvas-grid-split-icon {
|
||||
display: inline-flex;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 14px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.canvas-grid-split-chevron {
|
||||
display: block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: auto;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.canvas-grid-split-picker {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: visible;
|
||||
border: var(--stroke-1) solid var(--dock-border);
|
||||
border-radius: var(--dock-radius);
|
||||
background: var(--dock-surface);
|
||||
color: var(--fg);
|
||||
box-shadow: var(--elevation-overlay);
|
||||
}
|
||||
|
||||
.canvas-grid-split-presets {
|
||||
display: flex;
|
||||
width: 11.25rem;
|
||||
flex-direction: column;
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.canvas-grid-split-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.canvas-grid-split-item:hover,
|
||||
.canvas-grid-split-item.is-active {
|
||||
background: color-mix(in srgb, var(--fg) 12%, transparent);
|
||||
}
|
||||
|
||||
.canvas-grid-split-item-label {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.canvas-grid-split-mini {
|
||||
display: grid;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.canvas-grid-split-mini span {
|
||||
border-radius: 1px;
|
||||
background: currentColor;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.canvas-grid-split-custom {
|
||||
display: flex;
|
||||
width: 9.75rem;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
border-left: var(--stroke-1) solid var(--dock-border);
|
||||
padding: var(--space-3);
|
||||
background: color-mix(in srgb, var(--fg) 4%, transparent);
|
||||
}
|
||||
|
||||
.canvas-grid-split-custom-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--fs-tiny);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.canvas-grid-split-custom-size {
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.canvas-grid-split-board {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.canvas-grid-split-cell {
|
||||
aspect-ratio: 1;
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: color-mix(in srgb, var(--fg) 14%, var(--bg));
|
||||
box-shadow: inset 0 0 0 var(--stroke-1) color-mix(in srgb, var(--fg) 22%, transparent);
|
||||
}
|
||||
|
||||
.canvas-grid-split-cell.is-active {
|
||||
background: var(--fg);
|
||||
box-shadow: none;
|
||||
}
|
||||
99
web/src/components/canvas/canvas-grid-split-picker.tsx
Normal file
99
web/src/components/canvas/canvas-grid-split-picker.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { CANVAS_GRID_SPLIT_MAX, CANVAS_GRID_SPLIT_PRESETS, clampGridSplitSize, isValidGridSplit } from "@/lib/canvas/canvas-grid-split";
|
||||
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
|
||||
|
||||
import "./canvas-grid-split-picker.css";
|
||||
|
||||
function MiniGridIcon({ n }: { n: number }) {
|
||||
return (
|
||||
<span className="canvas-grid-split-mini" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }} aria-hidden>
|
||||
{Array.from({ length: n * n }, (_, index) => <span key={index} />)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasGridSplitPicker({ onPick }: { onPick: (params: ImageSplitParams) => void }) {
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [hoverRows, setHoverRows] = useState(2);
|
||||
const [hoverCols, setHoverCols] = useState(2);
|
||||
|
||||
const pick = (rows: number, columns: number) => {
|
||||
const params = { rows: clampGridSplitSize(rows), columns: clampGridSplitSize(columns) };
|
||||
if (!isValidGridSplit(params)) return;
|
||||
onPick(params);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="canvas-grid-split-picker"
|
||||
data-canvas-no-zoom
|
||||
role="dialog"
|
||||
aria-label="宫格切分"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="canvas-grid-split-presets">
|
||||
{CANVAS_GRID_SPLIT_PRESETS.map((preset) => (
|
||||
<button key={preset.label} type="button" className="canvas-grid-split-item" onMouseDown={(event) => event.preventDefault()} onClick={() => pick(preset.rows, preset.columns)}>
|
||||
<span className="canvas-grid-split-icon"><MiniGridIcon n={preset.rows} /></span>
|
||||
<span className="canvas-grid-split-item-label">{preset.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={`canvas-grid-split-item${customOpen ? " is-active" : ""}`}
|
||||
aria-expanded={customOpen}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setCustomOpen((current) => !current);
|
||||
}}
|
||||
>
|
||||
<span className="canvas-grid-split-icon"><MiniGridIcon n={3} /></span>
|
||||
<span className="canvas-grid-split-item-label">自定义</span>
|
||||
<ChevronRight className="canvas-grid-split-chevron" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
{customOpen ? (
|
||||
<div className="canvas-grid-split-custom">
|
||||
<div className="canvas-grid-split-custom-head">
|
||||
<span>自定义宫格</span>
|
||||
<span className="canvas-grid-split-custom-size">{hoverCols} × {hoverRows}</span>
|
||||
</div>
|
||||
<div
|
||||
className="canvas-grid-split-board"
|
||||
style={{ gridTemplateColumns: `repeat(${CANVAS_GRID_SPLIT_MAX}, minmax(0, 1fr))` }}
|
||||
onPointerLeave={() => {
|
||||
setHoverRows(2);
|
||||
setHoverCols(2);
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: CANVAS_GRID_SPLIT_MAX * CANVAS_GRID_SPLIT_MAX }, (_, index) => {
|
||||
const row = Math.floor(index / CANVAS_GRID_SPLIT_MAX) + 1;
|
||||
const col = (index % CANVAS_GRID_SPLIT_MAX) + 1;
|
||||
const active = row <= hoverRows && col <= hoverCols;
|
||||
return (
|
||||
<button
|
||||
key={`${row}-${col}`}
|
||||
type="button"
|
||||
className={`canvas-grid-split-cell${active ? " is-active" : ""}`}
|
||||
aria-label={`${col} × ${row}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onPointerEnter={() => {
|
||||
setHoverRows(row);
|
||||
setHoverCols(col);
|
||||
}}
|
||||
onClick={() => pick(row, col)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,6 @@ type ImageToolHandlers = {
|
||||
onEmotion: (node: CanvasNodeData) => void;
|
||||
onPortraitTexture: (node: CanvasNodeData) => void;
|
||||
onCrop: (node: CanvasNodeData) => void;
|
||||
onSplit: (node: CanvasNodeData) => void;
|
||||
onUpscale: (node: CanvasNodeData) => void;
|
||||
onSuperResolve: (node: CanvasNodeData) => void;
|
||||
onAngle: (node: CanvasNodeData) => void;
|
||||
@@ -131,8 +130,8 @@ const imageToolDefinitions: ImageToolDefinition[] = [
|
||||
description: "按行列拆成多个图片节点",
|
||||
icon: () => <Grid2x2 className="size-3.5" />,
|
||||
group: "process",
|
||||
order: 30,
|
||||
run: (node, handlers) => handlers.onSplit(node),
|
||||
order: 45,
|
||||
run: () => undefined,
|
||||
},
|
||||
{
|
||||
id: "upscale",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { activeConnectionPath, canvasConnectionPath } from "@/components/canvas/
|
||||
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
|
||||
import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasNodeDragPreview, subscribeCanvasSelectionPreview, type CanvasNodeDragPreview } from "@/lib/canvas/canvas-live-viewport";
|
||||
import { calculateCanvasPreviewTransform, sameCanvasViewport, shouldRebaseCanvasRaster } from "@/lib/canvas/canvas-leafer-viewport";
|
||||
import { offsetSelectedNodeBounds } from "@/lib/canvas/canvas-selection";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasDisplayConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox, ViewportTransform } from "@/types/canvas";
|
||||
|
||||
@@ -57,6 +58,7 @@ type OverlayScene = LeaferScene & {
|
||||
guides: Path;
|
||||
draft: Path;
|
||||
batchDrafts: Group;
|
||||
dragPreview: CanvasNodeDragPreview | null;
|
||||
};
|
||||
|
||||
export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps) {
|
||||
@@ -118,6 +120,8 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps)
|
||||
});
|
||||
const unsubscribeNodeDrag = subscribeCanvasNodeDragPreview(container, (preview) => {
|
||||
applyConnectionDragPreview(underlay, propsRef.current, preview);
|
||||
overlay.dragPreview = preview;
|
||||
syncLiveSelectionBounds(overlay, propsRef.current, viewportRef.current.k);
|
||||
});
|
||||
resize();
|
||||
|
||||
@@ -206,7 +210,7 @@ function createOverlayScene(host: HTMLDivElement): OverlayScene {
|
||||
world.add(draft);
|
||||
world.add(batchDrafts);
|
||||
leafer.add(world);
|
||||
return { leafer, world, host, selection, selectionBounds, guides, draft, batchDrafts };
|
||||
return { leafer, world, host, selection, selectionBounds, guides, draft, batchDrafts, dragPreview: null };
|
||||
}
|
||||
|
||||
function rebuildConnections(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps) {
|
||||
@@ -323,7 +327,7 @@ function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLaye
|
||||
const bounds = props.selectedNodeBounds;
|
||||
scene.selectionBounds.visible = Boolean(bounds && !selection);
|
||||
if (bounds && !selection) {
|
||||
syncSelectionBounds(scene.selectionBounds, bounds, viewportScale);
|
||||
syncSelectionBounds(scene.selectionBounds, liveSelectedNodeBounds(bounds, scene.dragPreview), viewportScale);
|
||||
scene.selectionBounds.stroke = props.theme.node.label;
|
||||
}
|
||||
|
||||
@@ -388,7 +392,7 @@ function syncViewport(viewport: ViewportTransform, width: number, height: number
|
||||
cornerRadius: 2 / scale,
|
||||
dashPattern: [4 / scale, 4 / scale],
|
||||
});
|
||||
if (props.selectedNodeBounds) syncSelectionBounds(overlay.selectionBounds, props.selectedNodeBounds, scale);
|
||||
if (props.selectedNodeBounds) syncSelectionBounds(overlay.selectionBounds, liveSelectedNodeBounds(props.selectedNodeBounds, overlay.dragPreview), scale);
|
||||
overlay.selectionBounds.set({
|
||||
strokeWidth: 1 / scale,
|
||||
cornerRadius: 2 / scale,
|
||||
@@ -438,6 +442,19 @@ function forceSceneRender(...scenes: LeaferScene[]) {
|
||||
for (const scene of scenes) scene.leafer.forceRender(undefined, true);
|
||||
}
|
||||
|
||||
function liveSelectedNodeBounds(bounds: NonNullable<NodeBounds>, preview: CanvasNodeDragPreview | null) {
|
||||
return offsetSelectedNodeBounds(bounds, preview);
|
||||
}
|
||||
|
||||
function syncLiveSelectionBounds(scene: OverlayScene, props: CanvasLeaferGraphicsLayerProps, viewportScale: number) {
|
||||
const bounds = props.selectedNodeBounds;
|
||||
const selection = props.selectionBox;
|
||||
scene.selectionBounds.visible = Boolean(bounds && !selection);
|
||||
if (!bounds || selection) return;
|
||||
syncSelectionBounds(scene.selectionBounds, liveSelectedNodeBounds(bounds, scene.dragPreview), viewportScale);
|
||||
scene.selectionBounds.stroke = props.theme.node.label;
|
||||
}
|
||||
|
||||
function syncSelectionBounds(rect: Rect, bounds: NonNullable<NodeBounds>, viewportScale: number) {
|
||||
const padding = 12 / Math.max(viewportScale, 0.05);
|
||||
rect.set({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Segmented, Slider } from "antd";
|
||||
import { useState } from "react";
|
||||
import { Slider } from "antd";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { Camera, RotateCcw, Send, X } from "lucide-react";
|
||||
|
||||
import { CanvasAngleScene } from "@/components/canvas/canvas-angle-scene";
|
||||
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
@@ -17,37 +17,32 @@ export type CanvasImageAngleParams = {
|
||||
wideAngle: boolean;
|
||||
};
|
||||
|
||||
const defaultParams: CanvasImageAngleParams = { horizontalAngle: 0, pitchAngle: 0, cameraDistance: 4.8, wideAngle: false };
|
||||
const presets = [
|
||||
{ label: "正面", horizontalAngle: 0, pitchAngle: 0 },
|
||||
{ label: "左侧", horizontalAngle: -90, pitchAngle: 0 },
|
||||
{ label: "右侧", horizontalAngle: 90, pitchAngle: 0 },
|
||||
{ label: "背面", horizontalAngle: 180, pitchAngle: 0 },
|
||||
{ label: "俯拍", horizontalAngle: 0, pitchAngle: 55 },
|
||||
{ label: "仰拍", horizontalAngle: 0, pitchAngle: -45 },
|
||||
type AnglePresetId = "front" | "left" | "right" | "back" | "top" | "bottom";
|
||||
|
||||
const defaultParams: CanvasImageAngleParams = { horizontalAngle: 45, pitchAngle: 0, cameraDistance: 4.8, wideAngle: false };
|
||||
const presets: Array<{ id: AnglePresetId; label: string; horizontalAngle: number; pitchAngle: number }> = [
|
||||
{ id: "front", label: "正面", horizontalAngle: 0, pitchAngle: 0 },
|
||||
{ id: "left", label: "左侧", horizontalAngle: -90, pitchAngle: 0 },
|
||||
{ id: "right", label: "右侧", horizontalAngle: 90, pitchAngle: 0 },
|
||||
{ id: "back", label: "背面", horizontalAngle: 180, pitchAngle: 0 },
|
||||
{ id: "top", label: "俯拍", horizontalAngle: 0, pitchAngle: 60 },
|
||||
{ id: "bottom", label: "仰拍", horizontalAngle: 0, pitchAngle: -60 },
|
||||
];
|
||||
|
||||
function distanceLabel(value: number) {
|
||||
if (value <= 3) return "近景";
|
||||
if (value >= 7) return "全景";
|
||||
return "中景";
|
||||
}
|
||||
|
||||
export function CanvasNodeAnglePanel({ dataUrl, onClose, onConfirm }: { dataUrl: string; onClose: () => void; onConfirm: (params: CanvasImageAngleParams) => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [params, setParams] = useState(defaultParams);
|
||||
const dragRef = useRef<{ x: number; y: number; horizontal: number; pitch: number } | null>(null);
|
||||
const [mode, setMode] = useState<"camera" | "skybox">("camera");
|
||||
const update = <Key extends keyof CanvasImageAngleParams>(key: Key, value: CanvasImageAngleParams[Key]) => setParams((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const startCameraDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragRef.current = { x: event.clientX, y: event.clientY, horizontal: params.horizontalAngle, pitch: params.pitchAngle };
|
||||
};
|
||||
const moveCamera = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
update("horizontalAngle", clamp(Math.round(drag.horizontal + (event.clientX - drag.x) * 0.8), -180, 180));
|
||||
update("pitchAngle", clamp(Math.round(drag.pitch - (event.clientY - drag.y) * 0.55), -75, 75));
|
||||
};
|
||||
const marker = cameraMarker(params.horizontalAngle, params.pitchAngle);
|
||||
const activePreset = presets.find((preset) => preset.horizontalAngle === params.horizontalAngle && preset.pitchAngle === params.pitchAngle);
|
||||
const viewLabel = activePreset?.label || "自定义";
|
||||
|
||||
return (
|
||||
<SpotlightSurface
|
||||
@@ -56,7 +51,7 @@ export function CanvasNodeAnglePanel({ dataUrl, onClose, onConfirm }: { dataUrl:
|
||||
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.panel}
|
||||
className="aceternity-floating-panel w-[580px] max-w-full overflow-hidden rounded-[var(--r-2xl)] border backdrop-blur-2xl"
|
||||
className="aceternity-floating-panel canvas-angle-panel w-full overflow-hidden rounded-[var(--r-2xl)] border backdrop-blur-2xl"
|
||||
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 28px 80px ${theme.spatial.shadow}` }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
@@ -68,67 +63,92 @@ export function CanvasNodeAnglePanel({ dataUrl, onClose, onConfirm }: { dataUrl:
|
||||
<Tooltip title="关闭"><button type="button" aria-label="关闭多角度编辑器" className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" onClick={onClose}><X className="size-3.5" /></button></Tooltip>
|
||||
</div>
|
||||
<div className="flex h-10 items-center gap-1 overflow-x-auto border-b px-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" style={{ borderColor: theme.toolbar.border }}>
|
||||
<AnglePresetButton active={!activePreset} label="自定义" theme={theme} />
|
||||
{presets.map((preset) => <AnglePresetButton key={preset.label} active={activePreset?.label === preset.label} label={preset.label} theme={theme} onClick={() => setParams((current) => ({ ...current, horizontalAngle: preset.horizontalAngle, pitchAngle: preset.pitchAngle }))} />)}
|
||||
<AnglePresetButton active={!activePreset} label="自定义" />
|
||||
{presets.map((preset) => (
|
||||
<AnglePresetButton
|
||||
key={preset.id}
|
||||
active={activePreset?.id === preset.id}
|
||||
label={preset.label}
|
||||
onClick={() => setParams((current) => ({ ...current, horizontalAngle: preset.horizontalAngle, pitchAngle: preset.pitchAngle }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid h-[208px] grid-cols-[188px_minmax(0,1fr)] gap-2.5 p-2.5">
|
||||
<div className="relative grid size-[188px] cursor-grab place-items-center overflow-hidden rounded-[var(--r-lg)] border active:cursor-grabbing" style={{ borderColor: theme.toolbar.border, background: theme.spatial.surface }} onPointerDown={startCameraDrag} onPointerMove={moveCamera} onPointerUp={(event) => { dragRef.current = null; event.currentTarget.releasePointerCapture(event.pointerId); }} onPointerCancel={() => { dragRef.current = null; }}>
|
||||
<GlobeGrid color={theme.node.muted} />
|
||||
<img src={dataUrl} alt="角度参考" className="relative z-10 size-[72px] rounded-[var(--r-md)] object-cover shadow-lg" draggable={false} style={{ transform: previewTransform(params) }} />
|
||||
<div className="pointer-events-none absolute z-20 grid size-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border shadow" style={{ left: `${marker.x}%`, top: `${marker.y}%`, background: theme.toolbar.panel, borderColor: theme.node.activeStroke, color: theme.node.activeStroke }}><Camera className="size-3.5" /></div>
|
||||
<span className="pointer-events-none absolute bottom-2 text-[var(--fs-micro)]" style={{ color: theme.node.muted }}>拖动调整摄影机</span>
|
||||
<div className="canvas-angle-body">
|
||||
<div className="canvas-angle-stage">
|
||||
<CanvasAngleScene
|
||||
mode={mode}
|
||||
rotate={params.horizontalAngle}
|
||||
tilt={params.pitchAngle}
|
||||
distance={params.cameraDistance}
|
||||
imageSrc={dataUrl}
|
||||
faceLabels={{
|
||||
back: "背面",
|
||||
right: "右侧",
|
||||
left: "左侧",
|
||||
top: "顶面",
|
||||
bottom: "底面",
|
||||
}}
|
||||
dirLabels={{
|
||||
up: "向上俯仰",
|
||||
down: "向下俯仰",
|
||||
left: "向左环绕",
|
||||
right: "向右环绕",
|
||||
}}
|
||||
onRotateChange={(value) => update("horizontalAngle", value)}
|
||||
onTiltChange={(value) => update("pitchAngle", value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col justify-center gap-2 rounded-[var(--r-lg)] border px-2.5 py-2" style={{ background: theme.toolbar.itemHover, borderColor: theme.toolbar.border }}>
|
||||
<AngleSeg value={mode} options={[{ label: "天空盒", value: "skybox" }, { label: "摄像头", value: "camera" }]} onChange={(value) => setMode(value as "camera" | "skybox")} />
|
||||
<span className="text-[var(--fs-tiny)] font-medium opacity-70">常用角度</span>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className="canvas-angle-preset"
|
||||
aria-pressed={activePreset?.id === preset.id}
|
||||
onClick={() => setParams((current) => ({ ...current, horizontalAngle: preset.horizontalAngle, pitchAngle: preset.pitchAngle }))}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<AngleSlider label="水平环绕" value={params.horizontalAngle} min={-180} max={180} suffix="°" onChange={(value) => update("horizontalAngle", value)} />
|
||||
<AngleSlider label="垂直俯仰" value={params.pitchAngle} min={-75} max={75} suffix="°" onChange={(value) => update("pitchAngle", value)} />
|
||||
<AngleSlider label="垂直俯仰" value={params.pitchAngle} min={-60} max={60} suffix="°" onChange={(value) => update("pitchAngle", value)} />
|
||||
<AngleSlider label="景别缩放" value={params.cameraDistance} min={1} max={10} step={0.1} suffix={distanceLabel(params.cameraDistance)} onChange={(value) => update("cameraDistance", value)} />
|
||||
<div className="grid h-8 grid-cols-[62px_minmax(0,1fr)] items-center gap-2">
|
||||
<span className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>镜头</span>
|
||||
<Segmented block size="small" value={params.wideAngle ? "wide" : "standard"} options={[{ label: "标准", value: "standard" }, { label: "广角", value: "wide" }]} onChange={(value) => update("wideAngle", value === "wide")} />
|
||||
<span className="text-[var(--fs-tiny)] font-medium opacity-60">镜头</span>
|
||||
<AngleSeg value={params.wideAngle ? "wide" : "standard"} options={[{ label: "标准", value: "standard" }, { label: "广角", value: "wide" }]} onChange={(value) => update("wideAngle", value === "wide")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-11 items-center gap-2 border-t px-3" style={{ borderColor: theme.toolbar.border }}>
|
||||
<span className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>当前视角</span>
|
||||
<span className="text-xs font-semibold">{activePreset?.label || "自定义"}</span>
|
||||
<span className="text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{params.horizontalAngle}° / {params.pitchAngle}° · {params.cameraDistance.toFixed(1)} {distanceLabel(params.cameraDistance)}</span>
|
||||
<span className="text-[var(--fs-tiny)] opacity-60">当前视角</span>
|
||||
<span className="text-xs font-semibold">{viewLabel}</span>
|
||||
<span className="text-[var(--fs-tiny)] opacity-60">{params.horizontalAngle}° / {params.pitchAngle}° · {params.cameraDistance.toFixed(1)} {distanceLabel(params.cameraDistance)}</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" className="flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-[var(--fs-label)] font-medium transition hover:bg-black/5 dark:hover:bg-white/10" onClick={() => setParams(defaultParams)}><RotateCcw className="size-3.5" />重置</button>
|
||||
<motion.button type="button" whileHover={reducedMotion ? undefined : { y: -1 }} whileTap={reducedMotion ? undefined : { scale: 0.97 }} className="flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-3 text-[var(--fs-label)] font-semibold" style={{ background: theme.node.activeStroke, color: theme.node.panel }} onClick={() => onConfirm(params)}><Send className="size-3.5" />生成新角度</motion.button>
|
||||
<motion.button type="button" whileHover={reducedMotion ? undefined : { y: -1 }} whileTap={reducedMotion ? undefined : { scale: 0.97 }} className="canvas-angle-generate flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-3 text-[var(--fs-label)] font-semibold" style={{ background: theme.node.activeStroke, color: theme.node.panel }} onClick={() => onConfirm(params)}><Send className="size-3.5" />生成新角度</motion.button>
|
||||
</div>
|
||||
</SpotlightSurface>
|
||||
);
|
||||
}
|
||||
|
||||
function AngleSeg({ value, options, onChange }: { value: string; options: { label: string; value: string }[]; onChange: (value: string) => void }) {
|
||||
return <div className="canvas-angle-seg">{options.map((option) => <button key={option.value} type="button" aria-pressed={value === option.value} onClick={() => onChange(option.value)}>{option.label}</button>)}</div>;
|
||||
}
|
||||
|
||||
function AngleSlider({ label, value, min, max, step = 1, suffix, onChange }: { label: string; value: number; min: number; max: number; step?: number; suffix: string; onChange: (value: number) => void }) {
|
||||
return <div className="grid h-8 grid-cols-[62px_minmax(0,1fr)_60px] items-center gap-2"><span className="text-[var(--fs-tiny)] font-medium opacity-60">{label}</span><Slider min={min} max={max} step={step} value={value} onChange={onChange} /><span className="text-right text-[var(--fs-tiny)] font-semibold">{Number.isInteger(value) ? value : value.toFixed(1)}{suffix.startsWith("°") ? suffix : ""}{!suffix.startsWith("°") ? ` ${suffix}` : ""}</span></div>;
|
||||
return (
|
||||
<div className="grid h-8 grid-cols-[62px_minmax(0,1fr)_60px] items-center gap-2">
|
||||
<span className="text-[var(--fs-tiny)] font-medium opacity-60">{label}</span>
|
||||
<Slider className="canvas-angle-slider m-0" min={min} max={max} step={step} value={value} tooltip={{ open: false }} onChange={onChange} />
|
||||
<span className="text-right text-[var(--fs-tiny)] font-semibold">{Number.isInteger(value) ? value : value.toFixed(1)}{suffix.startsWith("°") ? suffix : ` ${suffix}`}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnglePresetButton({ active, label, theme, onClick }: { active: boolean; label: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onClick?: () => void }) {
|
||||
return <button type="button" aria-pressed={active} className="h-7 shrink-0 rounded-[var(--r-md)] border px-2.5 text-[var(--fs-tiny)] font-medium transition hover:bg-black/5 dark:hover:bg-white/10" style={{ background: active ? theme.toolbar.activeBg : theme.spatial.surface, borderColor: active ? theme.spatial.glowStrong : theme.toolbar.border }} onClick={onClick}>{label}</button>;
|
||||
}
|
||||
|
||||
function GlobeGrid({ color }: { color: string }) {
|
||||
return <svg aria-hidden="true" className="pointer-events-none absolute inset-5 h-[calc(100%-40px)] w-[calc(100%-40px)] opacity-35" viewBox="0 0 200 200" fill="none" stroke={color} strokeWidth="1"><circle cx="100" cy="100" r="82" /><ellipse cx="100" cy="100" rx="42" ry="82" /><ellipse cx="100" cy="100" rx="68" ry="82" /><ellipse cx="100" cy="100" rx="82" ry="28" /><ellipse cx="100" cy="100" rx="82" ry="56" /><path d="M18 100h164M100 18v164" /></svg>;
|
||||
}
|
||||
|
||||
function cameraMarker(horizontal: number, pitch: number) {
|
||||
const horizontalRad = horizontal * Math.PI / 180;
|
||||
const pitchRad = pitch * Math.PI / 180;
|
||||
return { x: 50 + Math.sin(horizontalRad) * Math.cos(pitchRad) * 40, y: 50 - Math.sin(pitchRad) * 40 };
|
||||
}
|
||||
|
||||
function previewTransform(params: CanvasImageAngleParams) {
|
||||
const scale = clamp(1.08 - params.cameraDistance * 0.035 - (params.wideAngle ? 0.08 : 0), 0.72, 1.08);
|
||||
return `perspective(520px) rotateY(${params.horizontalAngle * -0.18}deg) rotateX(${params.pitchAngle * 0.16}deg) scale(${scale})`;
|
||||
}
|
||||
|
||||
function distanceLabel(value: number) {
|
||||
if (value <= 3) return "近景";
|
||||
if (value >= 7) return "全景";
|
||||
return "中景";
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
function AnglePresetButton({ active, label, onClick }: { active: boolean; label: string; onClick?: () => void }) {
|
||||
return <button type="button" aria-pressed={active} className="canvas-angle-tab" onClick={onClick}>{label}</button>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { App, Button, Dropdown, Input, Modal, Tag, Tooltip } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Camera, Check, ChevronDown, Ellipsis, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react";
|
||||
import { Camera, Check, ChevronDown, ChevronRight, Ellipsis, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react";
|
||||
|
||||
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
|
||||
@@ -9,12 +9,14 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { resolveNodeToolbarPlacement, resolveToolbarTools, type NodeToolbarGroup, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
|
||||
import { subscribeCanvasGraphicsViewportPreview } from "@/lib/canvas/canvas-live-viewport";
|
||||
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
|
||||
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
|
||||
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ViewportTransform } from "@/types/canvas";
|
||||
import { buildImageToolbarTools } from "./canvas-image-toolbar-tools";
|
||||
import { CanvasGridSplitPicker } from "./canvas-grid-split-picker";
|
||||
|
||||
type CanvasNodeToolbarProps = {
|
||||
node: CanvasNodeData | null;
|
||||
@@ -36,7 +38,7 @@ type CanvasNodeToolbarProps = {
|
||||
onEmotion: (node: CanvasNodeData) => void;
|
||||
onPortraitTexture: (node: CanvasNodeData) => void;
|
||||
onCrop: (node: CanvasNodeData) => void;
|
||||
onSplit: (node: CanvasNodeData) => void;
|
||||
onSplit: (node: CanvasNodeData, params: ImageSplitParams) => void;
|
||||
onUpscale: (node: CanvasNodeData) => void;
|
||||
onSuperResolve: (node: CanvasNodeData) => void;
|
||||
onAngle: (node: CanvasNodeData) => void;
|
||||
@@ -219,14 +221,14 @@ export function CanvasNodeToolbar({
|
||||
}
|
||||
copyText(prompt, "提示词已复制");
|
||||
};
|
||||
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
|
||||
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onMaskEdit, onEmotion, onPortraitTexture, onCrop, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
|
||||
|
||||
// 构建 ToolContext——供注册表解析工具
|
||||
const nodeHoverHandlers = {
|
||||
onNodeInfo: onInfo, onNodeDelete: onDelete, onNodeRetry: onRetry, onNodeEditText: onEditText, onNodeDecreaseFont: onDecreaseFont, onNodeIncreaseFont: onIncreaseFont,
|
||||
onNodeToggleDialog: onToggleDialog, onNodeAnnotate: onAnnotate, onNodeGenerateImage: onGenerateImage, onNodeUpload: onUpload, onNodeDownload: onDownload,
|
||||
onNodeSaveAsset: onSaveAsset, onNodeMaskEdit: onMaskEdit, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop,
|
||||
onNodeSplit: onSplit, onNodeUpscale: onUpscale, onNodeSuperResolve: onSuperResolve, onNodeAngle: onAngle, onNodeViewImage: onViewImage,
|
||||
onNodeSaveAsset: onSaveAsset, onNodeCreateConversion: onCreateConversion, onNodeMaskEdit: onMaskEdit, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop,
|
||||
onNodeSplit: (target) => onSplit(target, { rows: 2, columns: 2 }), onNodeUpscale: onUpscale, onNodeSuperResolve: onSuperResolve, onNodeAngle: onAngle, onNodeViewImage: onViewImage,
|
||||
onNodeExtractVideoFrames: onExtractVideoFrames, onNodeExtractAudioFromVideo: onExtractAudioFromVideo, onNodeTrimVideoSegments: onTrimVideoSegments, onNodeReversePrompt: onReversePrompt, onNodeToggleFreeResize: onToggleFreeResize,
|
||||
onNodeSubtitles: onSubtitles, onNodeTimeline: onTimeline, onNodeToggleLocked: onToggleLocked, onNodeCopyPrompt: copyImagePrompt,
|
||||
} as Partial<ToolbarHandlers> as ToolbarHandlers;
|
||||
@@ -317,7 +319,7 @@ export function CanvasNodeToolbar({
|
||||
{panoramaTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
{portraitTools.length ? <NodeDockMenuButton menuId="portrait" label="人像调整" icon={<UserRound className="size-3.5" />} tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||||
{viewpointLightingTools.length ? <NodeDockMenuButton menuId="viewpoint-lighting" label="视角" icon={<Camera className="size-3.5" />} tools={viewpointLightingTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||||
{processTools.length ? <NodeDockMenuButton menuId="process" label={processMenuLabel} icon={isVideo ? <Images className="size-3.5" /> : <SlidersHorizontal className="size-3.5" />} tools={processTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||||
{processTools.length ? <NodeDockMenuButton menuId="process" label={processMenuLabel} icon={isVideo ? <Images className="size-3.5" /> : <SlidersHorizontal className="size-3.5" />} tools={processTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} split={hasImage && !simpleMode ? { node, onSplit } : undefined} /> : null}
|
||||
{workspaceTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
|
||||
{workspaceTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
{utilityTools.length || moreTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
|
||||
@@ -353,14 +355,20 @@ function compareToolbarTools(left: ToolbarTool, right: ToolbarTool) {
|
||||
return left.order - right.order;
|
||||
}
|
||||
|
||||
function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChange, placement = "top", iconOnly = false }: { menuId: string; label: string; icon: ReactNode; tools: ToolbarTool[]; openMenuId: string | null; onOpenChange: (menuId: string, open: boolean) => void; placement?: "top" | "topRight"; iconOnly?: boolean }) {
|
||||
function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChange, placement = "top", iconOnly = false, split }: { menuId: string; label: string; icon: ReactNode; tools: ToolbarTool[]; openMenuId: string | null; onOpenChange: (menuId: string, open: boolean) => void; placement?: "top" | "topRight"; iconOnly?: boolean; split?: { node: CanvasNodeData; onSplit: (node: CanvasNodeData, params: ImageSplitParams) => void } }) {
|
||||
const open = openMenuId === menuId;
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [splitPanelOpen, setSplitPanelOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open) {
|
||||
setSplitPanelOpen(false);
|
||||
return;
|
||||
}
|
||||
const frame = requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [open]);
|
||||
const keepSplitMenuOpenRef = useRef(false);
|
||||
const splitEntry = split ? tools.find((tool) => tool.id === "split") : undefined;
|
||||
const sections = new Map<string, ToolbarTool[]>();
|
||||
for (const tool of tools) {
|
||||
const section = tool.danger ? "危险操作" : tool.section || "常用操作";
|
||||
@@ -368,10 +376,88 @@ function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChan
|
||||
}
|
||||
const items: MenuProps["items"] = [...sections].sort(([left], [right]) => Number(left === "危险操作") - Number(right === "危险操作")).map(([section, entries]) => ({
|
||||
type: "group", key: section, label: section,
|
||||
children: entries.map((tool) => ({ key: tool.id, icon: tool.icon, label: <div><span className="inline-flex items-center gap-2">{tool.label}{tool.active ? <Check className="size-3.5" /> : null}</span>{tool.description ? <div className="text-[var(--fs-tiny)] opacity-60">{tool.description}</div> : null}</div>, disabled: tool.disabled, danger: tool.danger, onClick: () => { onOpenChange(menuId, false); tool.onClick(); } })),
|
||||
children: entries.map((tool) => {
|
||||
const isSplit = Boolean(splitEntry && split && tool.id === "split");
|
||||
return {
|
||||
key: tool.id,
|
||||
icon: tool.icon,
|
||||
className: isSplit ? `canvas-grid-split-menu-item${splitPanelOpen ? " is-open" : ""}` : undefined,
|
||||
label: (
|
||||
<div
|
||||
className={isSplit ? "canvas-grid-split-menu-label" : undefined}
|
||||
onMouseDown={isSplit ? () => { keepSplitMenuOpenRef.current = true; } : undefined}
|
||||
>
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-2">{tool.label}{tool.active ? <Check className="size-3.5" /> : null}</span>
|
||||
{tool.description ? <div className="text-[var(--fs-tiny)] opacity-60">{tool.description}</div> : null}
|
||||
</div>
|
||||
{isSplit ? <ChevronRight className="canvas-grid-split-chevron" strokeWidth={2} /> : null}
|
||||
</div>
|
||||
),
|
||||
disabled: tool.disabled,
|
||||
danger: tool.danger,
|
||||
onClick: (info) => {
|
||||
if (isSplit) {
|
||||
info.domEvent.preventDefault();
|
||||
info.domEvent.stopPropagation();
|
||||
keepSplitMenuOpenRef.current = true;
|
||||
setSplitPanelOpen((current) => !current);
|
||||
return;
|
||||
}
|
||||
onOpenChange(menuId, false);
|
||||
tool.onClick();
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
return (
|
||||
<Dropdown open={open} trigger={["click"]} placement={placement} onOpenChange={(nextOpen) => onOpenChange(menuId, nextOpen)} menu={{ items }} autoFocus popupRender={(menu) => <div className="canvas-node-toolbar-menu" data-canvas-no-zoom data-canvas-wheel-scroll onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} onKeyDownCapture={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); triggerRef.current?.focus(); onOpenChange(menuId, false); } }} onKeyDown={(event) => event.stopPropagation()}>{menu}</div>}>
|
||||
<Dropdown
|
||||
open={open}
|
||||
trigger={["click"]}
|
||||
placement={placement}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && (keepSplitMenuOpenRef.current || document.querySelector(".canvas-node-toolbar-menu-split:hover, .canvas-grid-split-picker:hover"))) {
|
||||
keepSplitMenuOpenRef.current = false;
|
||||
return;
|
||||
}
|
||||
onOpenChange(menuId, nextOpen);
|
||||
if (!nextOpen) setSplitPanelOpen(false);
|
||||
}}
|
||||
menu={{ items }}
|
||||
autoFocus
|
||||
popupRender={(menu) => (
|
||||
<div
|
||||
className={`canvas-node-toolbar-menu${splitEntry && split ? " canvas-node-toolbar-menu-split" : ""}`}
|
||||
data-canvas-no-zoom
|
||||
data-canvas-wheel-scroll
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
triggerRef.current?.focus();
|
||||
onOpenChange(menuId, false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="canvas-node-toolbar-menu-stack">
|
||||
{menu}
|
||||
</div>
|
||||
{splitPanelOpen && split ? (
|
||||
<CanvasGridSplitPicker
|
||||
onPick={(params) => {
|
||||
setSplitPanelOpen(false);
|
||||
onOpenChange(menuId, false);
|
||||
split.onSplit(split.node, params);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Clapperboard, Image as ImageIcon, List, Music2, Pencil, Video, Workflow
|
||||
import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
|
||||
import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasNodeDragPreview, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type ConnectionHandle, type Position, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
@@ -102,19 +102,16 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt
|
||||
const panel = panelRef.current;
|
||||
if (!container || !panel) return;
|
||||
let liveViewport = viewport;
|
||||
let liveDragOffset = dragOffset;
|
||||
let viewportSize = { width: container.clientWidth, height: container.clientHeight };
|
||||
const update = (nextViewport: ViewportTransform) => {
|
||||
liveViewport = nextViewport;
|
||||
const nextWidth = resolveNodePanelWidth(node, nextViewport, panelWidth);
|
||||
panel.style.width = `${nextWidth}px`;
|
||||
const position = getNodePanelPosition(
|
||||
node,
|
||||
nextViewport,
|
||||
viewportSize,
|
||||
nextWidth,
|
||||
panelHeight,
|
||||
dragOffset,
|
||||
);
|
||||
const nodeElement = container.querySelector<HTMLElement>(`[data-node-id="${CSS.escape(node.id)}"]`);
|
||||
const position = nodeElement
|
||||
? getAttachedNodePanelPosition(nodeElement, container, nextWidth)
|
||||
: getNodePanelPosition(node, nextViewport, viewportSize, nextWidth, panelHeight, liveDragOffset);
|
||||
panel.style.transform = `translate3d(${position.left}px, ${position.top}px, 0)`;
|
||||
};
|
||||
update(viewport);
|
||||
@@ -124,9 +121,14 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
const unsubscribeViewport = subscribeCanvasGraphicsViewportPreview(container, update);
|
||||
const unsubscribeDrag = subscribeCanvasNodeDragPreview(container, (preview) => {
|
||||
liveDragOffset = preview?.nodeIds.has(node.id) ? { x: preview.x, y: preview.y } : null;
|
||||
update(liveViewport);
|
||||
});
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
unsubscribeViewport();
|
||||
unsubscribeDrag();
|
||||
};
|
||||
}, [containerRef, dragOffset?.x, dragOffset?.y, isDragging, node.height, node.id, node.position.x, node.position.y, node.width, panelHeight, panelWidth, viewport]);
|
||||
|
||||
@@ -262,17 +264,25 @@ function getConnectionMenuPosition(position: Position, viewport: ViewportTransfo
|
||||
};
|
||||
}
|
||||
|
||||
export function getNodePanelPosition(node: CanvasNodeData, viewport: ViewportTransform, viewportSize: { width: number; height: number }, panelWidth: number, _panelHeight: number, dragOffset?: Position | null) {
|
||||
function getAttachedNodePanelPosition(nodeElement: HTMLElement, container: HTMLElement, panelWidth: number) {
|
||||
const gap = 10;
|
||||
const nodeRect = nodeElement.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
return {
|
||||
left: nodeRect.left - containerRect.left + nodeRect.width / 2 - panelWidth / 2,
|
||||
top: nodeRect.bottom - containerRect.top + gap,
|
||||
placement: "below" as const,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNodePanelPosition(node: CanvasNodeData, viewport: ViewportTransform, _viewportSize: { width: number; height: number }, panelWidth: number, _panelHeight: number, dragOffset?: Position | null) {
|
||||
const gap = 10;
|
||||
const margin = 12;
|
||||
const offsetX = dragOffset?.x || 0;
|
||||
const offsetY = dragOffset?.y || 0;
|
||||
const nodeCenterX = viewport.x + (node.position.x + offsetX + node.width / 2) * viewport.k;
|
||||
const nodeBottom = viewport.y + (node.position.y + offsetY + node.height) * viewport.k;
|
||||
const maxLeft = Math.max(margin, viewportSize.width - panelWidth - margin);
|
||||
const left = clamp(nodeCenterX - panelWidth / 2, margin, maxLeft);
|
||||
return {
|
||||
left,
|
||||
left: nodeCenterX - panelWidth / 2,
|
||||
top: nodeBottom + gap,
|
||||
placement: "below" as const,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user