feat(canvas): PR #500 - 合并多角度与切分并保留本地 Agent 改造

This commit is contained in:
ddCat
2026-09-14 19:48:17 +08:00
26 changed files with 1431 additions and 153 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

View File

@@ -43,7 +43,8 @@ description: 从产品功能快速定位主要代码入口
| 顶栏、反馈和 screen-space 浮层 | `web/src/pages/canvas/canvas-project-top-bar.tsx`、`canvas-project-feedback.tsx`、`canvas-project-selection-toolbar.tsx`、`components/canvas/canvas-workspace-overlays.tsx` |
| 媒体弹窗装配 | `web/src/pages/canvas/canvas-project-media-dialogs.tsx` |
| 画布视口、拖拽、缩放和框选 | `web/src/components/canvas/infinite-canvas.tsx`、`web/src/lib/canvas/canvas-live-viewport.ts` |
| 节点渲染与节点工具 | `web/src/components/canvas/canvas-node.tsx`、`canvas-node-hover-toolbar.tsx` |
| 节点渲染与节点工具 | `web/src/components/canvas/canvas-node.tsx`、`canvas-node-hover-toolbar.tsx`、`canvas-node-toolbar.tsx` |
| 图片多角度、宫格切分与多选框 | `canvas-node-angle-dialog.tsx`、`canvas-angle-scene.tsx`、`canvas-grid-split-picker.tsx`、`web/src/lib/canvas/canvas-grid-split.ts`、`canvas-selection.ts` |
| 生成 Composer | `web/src/components/canvas/canvas-node-prompt-panel.tsx`、`canvas-config-composer.tsx` |
| 风格中心、用户风格编辑与项目快照 | `web/src/components/canvas/canvas-style-picker-modal.tsx`、`style-profile-editor-modal.tsx`、`style-asset-binding-modal.tsx`、`web/src/lib/canvas/style-profile.ts`、`web/src/services/api/style-profiles.ts` |
| 分镜脚本节点 | `web/src/components/canvas/canvas-script-node.tsx` |

View File

@@ -28,6 +28,16 @@ description: 当前项目已实现的核心工作台能力与使用入口
- `web/src/lib/canvas/tool-registry/definitions/selection-toolbar-tools.tsx`:多选悬浮栏默认工具。
- `web/src/lib/canvas/canvas-layout.ts`:`spreadCanvasNodes` 按相对占位加大边距。
## 图片节点多角度、宫格切分与多选框
图片节点「多角度」使用贴在节点下方的 3D 编辑器(天空盒/摄像头、常用角度、方向箭头),拖动节点或平移画布时面板跟着走。宫格切分放在「图片工具」菜单的「拆分与标记」里,提供 4/9/16/25 预设和 5×5 自定义点选;自定义面板留在菜单内,切分后的子图按网格排开。框选多张图后拖动,虚线选框跟随被选节点。
### 相关实现
- `web/src/components/canvas/canvas-node-angle-dialog.tsx`、`web/src/components/canvas/canvas-angle-scene.tsx`:多角度面板与 3D 预览。
- `web/src/components/canvas/canvas-grid-split-picker.tsx`、`web/src/lib/canvas/canvas-grid-split.ts`:宫格预设、自定义点选与切分子节点布局。
- `web/src/lib/canvas/canvas-selection.ts`、`web/src/lib/canvas/canvas-live-viewport.ts`:多选虚线框随拖动预览平移。
## 系统渠道排序与前台别名
后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。渠道行的“更多”菜单支持复制渠道,复制会生成独立的渠道、模型和价格档,并保留原连接配置与凭证。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。

View File

@@ -20,6 +20,18 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
- [ ] 更新后重启本地后端,用调试包中的已完成父轮次 `ag045cb29d5cebb44cb42cedb235d2fc5a` 续发“继续操作”:应创建当前 Capability 合同的新轮,不再返回“系统处理失败”;父轮次状态和冻结合同保持不变。
- [ ] 在真实审批和执行结果中确认:更新节点显示执行前名称、节点类型、修改字段和修改后名称;新增连线显示来源与目标标题/类型,点击可定位对应节点;普通 UI 不暴露完整内部 ID。
## 多角度编辑器与宫格切分交互
多角度改为贴在节点下方的 3D 轨道/天空盒预览(摄像头与天空盒切换、常用角度、方向箭头),宫格切分放在「图片工具」子菜单(预设 + 5×5 自定义点选),沿用当前 `--fg/--bg` 与 dock token,明暗主题共用同一套样式。
- [ ] 打开图片节点「多角度」:面板贴在节点下方;拖动节点或平移画布时编辑器跟着节点走,不和节点分离。
- [ ] 点「正面 / 左侧 / 右侧 / 背面 / 俯拍 / 仰拍」后滑条与底部「当前视角」同步;生成新角度仍创建子节点。
- [ ] 切换浅色/深色:选中的标签、常用角度、镜头分段和滑条都跟随前景/背景,不出现浅色皮肤残留。
- [ ] 「宫格切分」在节点工具栏「图片工具」里,不单独占一个工具栏按钮;与「裁剪 / 调整尺寸 / 标注」同一列表样式(图标 14px、标题/说明字号与左右对齐一致);点开后有 4/9/16/25 宫格预设,点「自定义」后 5×5 板留在菜单内不闪退,最少 2 格才会切分。
- [ ] 宫格列表左侧小宫格图标垂直居中,右侧箭头约 10px,不出现过大的 LayoutGrid。
- [ ] 切分后子节点按网格排在原图右侧,互不覆盖,并保留原图提示词。
- [ ] 框选多张图后拖动:虚线选框跟着被选节点一起移动,松手后仍贴合,不留空框或错位。
## Agent 增量联动与可点击操作留痕
- 服务端将草稿、提交和媒体成功 / 失败 / 取消的节点增量与运行检查点一起持久化;前端 40ms 合批投影,不按每条完成 / 失败事件全量刷新。缺口、断流或冲突才单请求校准,发起间隔至少 1 秒,期间重复通知仅保留一次尾随校准。

View 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);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View File

@@ -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",

View File

@@ -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({

View File

@@ -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>;
}

View File

@@ -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"

View File

@@ -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,
};

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import { clampGridSplitSize, isValidGridSplit, layoutGridSplitCells } from "./canvas-grid-split";
describe("canvas-grid-split", () => {
test("clamps size into 1..5", () => {
expect(clampGridSplitSize(0)).toBe(1);
expect(clampGridSplitSize(8)).toBe(5);
expect(clampGridSplitSize(2.6)).toBe(3);
});
test("rejects a single cell", () => {
expect(isValidGridSplit({ rows: 1, columns: 1 })).toBe(false);
expect(isValidGridSplit({ rows: 1, columns: 2 })).toBe(true);
expect(isValidGridSplit({ rows: 3, columns: 2 })).toBe(true);
});
test("lays out fitted cells without overlap", () => {
const cells = [
{ row: 0, column: 0, width: 592, height: 432 },
{ row: 0, column: 1, width: 592, height: 432 },
{ row: 1, column: 0, width: 592, height: 432 },
{ row: 1, column: 1, width: 592, height: 432 },
];
const positions = layoutGridSplitCells({ x: 100, y: 40 }, cells, 48);
expect(positions[1]).toEqual({ x: 100 + 592 + 48, y: 40 });
expect(positions[2]).toEqual({ x: 100, y: 40 + 432 + 48 });
expect(positions[3]).toEqual({ x: 100 + 592 + 48, y: 40 + 432 + 48 });
expect(positions[1]!.x).toBeGreaterThanOrEqual(positions[0]!.x + cells[0]!.width + 48);
expect(positions[2]!.y).toBeGreaterThanOrEqual(positions[0]!.y + cells[0]!.height + 48);
});
});

View File

@@ -0,0 +1,49 @@
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
export const CANVAS_GRID_SPLIT_MAX = 5;
export const CANVAS_GRID_SPLIT_GAP = 48;
export const CANVAS_GRID_SPLIT_PRESETS: Array<{ rows: number; columns: number; label: string }> = [
{ rows: 2, columns: 2, label: "4宫格 (2×2)" },
{ rows: 3, columns: 3, label: "9宫格 (3×3)" },
{ rows: 4, columns: 4, label: "16宫格 (4×4)" },
{ rows: 5, columns: 5, label: "25宫格 (5×5)" },
];
export type GridSplitCellSize = {
row: number;
column: number;
width: number;
height: number;
};
export function clampGridSplitSize(value: number) {
if (!Number.isFinite(value)) return 2;
return Math.max(1, Math.min(CANVAS_GRID_SPLIT_MAX, Math.round(value)));
}
export function isValidGridSplit(params: ImageSplitParams) {
const rows = clampGridSplitSize(params.rows);
const columns = clampGridSplitSize(params.columns);
return rows * columns >= 2;
}
export function layoutGridSplitCells(origin: { x: number; y: number }, cells: GridSplitCellSize[], gap = CANVAS_GRID_SPLIT_GAP) {
const columnCount = Math.max(0, ...cells.map((cell) => cell.column)) + 1;
const rowCount = Math.max(0, ...cells.map((cell) => cell.row)) + 1;
const columnWidth = Array.from({ length: columnCount }, (_, column) => Math.max(1, ...cells.filter((cell) => cell.column === column).map((cell) => cell.width)));
const rowHeight = Array.from({ length: rowCount }, (_, row) => Math.max(1, ...cells.filter((cell) => cell.row === row).map((cell) => cell.height)));
const columnX: number[] = [];
let x = origin.x;
for (let column = 0; column < columnCount; column += 1) {
columnX.push(x);
x += (columnWidth[column] || 1) + gap;
}
const rowY: number[] = [];
let y = origin.y;
for (let row = 0; row < rowCount; row += 1) {
rowY.push(y);
y += (rowHeight[row] || 1) + gap;
}
return cells.map((cell) => ({ x: columnX[cell.column] || origin.x, y: rowY[cell.row] || origin.y }));
}

View File

@@ -14,6 +14,7 @@ export type CanvasNodeDragPreview = {
type NodeDragPreviewDomState = {
elementsById: Map<string, HTMLElement>;
previousIds: Set<string>;
selectionBounds: HTMLElement | null;
};
type NodeSelectionPreviewDomState = {
@@ -83,13 +84,14 @@ export function applyCanvasNodeDragPreview(container: HTMLDivElement | null, pre
let state = nodeDragPreviewDomStates.get(container);
if (!state) {
state = { elementsById: new Map(), previousIds: new Set() };
state = { elementsById: new Map(), previousIds: new Set(), selectionBounds: null };
nodeDragPreviewDomStates.set(container, state);
}
for (const nodeId of state.previousIds) {
state.elementsById.get(nodeId)?.style.removeProperty("translate");
}
state.selectionBounds?.style.removeProperty("translate");
state.previousIds.clear();
if (preview) {
@@ -100,6 +102,7 @@ export function applyCanvasNodeDragPreview(container: HTMLDivElement | null, pre
const nodeId = element.dataset.nodeId;
if (nodeId) state?.elementsById.set(nodeId, element);
});
state.selectionBounds = container.querySelector<HTMLElement>("[data-canvas-selection-bounds]");
}
for (const nodeId of preview.nodeIds) {
const element = state.elementsById.get(nodeId);
@@ -107,8 +110,13 @@ export function applyCanvasNodeDragPreview(container: HTMLDivElement | null, pre
element.style.setProperty("translate", `${preview.x}px ${preview.y}px`);
state.previousIds.add(nodeId);
}
if (!state.selectionBounds?.isConnected) {
state.selectionBounds = container.querySelector<HTMLElement>("[data-canvas-selection-bounds]");
}
state.selectionBounds?.style.setProperty("translate", `${preview.x}px ${preview.y}px`);
} else {
state.elementsById.clear();
state.selectionBounds = null;
}
container.dispatchEvent(new CustomEvent<CanvasNodeDragPreview | null>(CANVAS_NODE_DRAG_PREVIEW_EVENT, { detail: preview }));

View File

@@ -131,3 +131,28 @@ export function resolveCanvasSelectionPreviewDelta(initialSelection: ReadonlySet
}
return { includeNodeIds, removeNodeIds };
}
export type SelectedNodeBounds = {
left: number;
top: number;
width: number;
height: number;
count: number;
};
export function selectedNodesWorldBounds(
nodes: Array<{ id: string; position: { x: number; y: number }; width: number; height: number }>,
preview?: { x: number; y: number; nodeIds: ReadonlySet<string> } | null,
): SelectedNodeBounds | null {
if (nodes.length < 2) return null;
const left = Math.min(...nodes.map((node) => node.position.x + (preview?.nodeIds.has(node.id) ? preview.x : 0)));
const top = Math.min(...nodes.map((node) => node.position.y + (preview?.nodeIds.has(node.id) ? preview.y : 0)));
const right = Math.max(...nodes.map((node) => node.position.x + (preview?.nodeIds.has(node.id) ? preview.x : 0) + node.width));
const bottom = Math.max(...nodes.map((node) => node.position.y + (preview?.nodeIds.has(node.id) ? preview.y : 0) + node.height));
return { left, top, width: right - left, height: bottom - top, count: nodes.length };
}
export function offsetSelectedNodeBounds(bounds: SelectedNodeBounds, preview: { x: number; y: number } | null | undefined): SelectedNodeBounds {
if (!preview || (preview.x === 0 && preview.y === 0)) return bounds;
return { ...bounds, left: bounds.left + preview.x, top: bounds.top + preview.y };
}

View File

@@ -1,7 +1,6 @@
import { CanvasNodeAnnotationDialog } from "@/components/canvas/canvas-node-annotation-dialog";
import { CanvasNodeCropDialog, type CanvasImageCropRect } from "@/components/canvas/canvas-node-crop-dialog";
import { CanvasNodeMaskEditDialog, type CanvasImageMaskEditPayload } from "@/components/canvas/canvas-node-mask-edit-dialog";
import { CanvasNodeSplitDialog, type CanvasImageSplitParams } from "@/components/canvas/canvas-node-split-dialog";
import { CanvasNodeUpscaleDialog, type CanvasImageUpscaleParams } from "@/components/canvas/canvas-node-upscale-dialog";
import type { CanvasNodeData } from "@/types/canvas";
import type { AiConfig } from "@/stores/use-config-store";
@@ -10,17 +9,14 @@ type CanvasProjectMediaDialogsProps = {
cropNode: CanvasNodeData | null;
annotationNode: CanvasNodeData | null;
maskEditNode: CanvasNodeData | null;
splitNode: CanvasNodeData | null;
upscaleNode: CanvasNodeData | null;
onCloseCrop: () => void;
onCloseAnnotation: () => void;
onCloseMaskEdit: () => void;
onCloseSplit: () => void;
onCloseUpscale: () => void;
onCrop: (node: CanvasNodeData, crop: CanvasImageCropRect) => void;
onAnnotate: (node: CanvasNodeData, dataUrl: string) => void;
onMaskEdit: (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => void;
onSplit: (node: CanvasNodeData, params: CanvasImageSplitParams) => void;
onUpscale: (node: CanvasNodeData, params: CanvasImageUpscaleParams) => void;
config: AiConfig;
};
@@ -29,17 +25,14 @@ export function CanvasProjectMediaDialogs({
cropNode,
annotationNode,
maskEditNode,
splitNode,
upscaleNode,
onCloseCrop,
onCloseAnnotation,
onCloseMaskEdit,
onCloseSplit,
onCloseUpscale,
onCrop,
onAnnotate,
onMaskEdit,
onSplit,
onUpscale,
config,
}: CanvasProjectMediaDialogsProps) {
@@ -48,7 +41,6 @@ export function CanvasProjectMediaDialogs({
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open onClose={onCloseCrop} onConfirm={(crop) => onCrop(cropNode, crop)} /> : null}
{annotationNode?.metadata?.content ? <CanvasNodeAnnotationDialog image={{ url: annotationNode.metadata.content, storageKey: annotationNode.metadata.storageKey }} open onClose={onCloseAnnotation} onConfirm={(dataUrl) => onAnnotate(annotationNode, dataUrl)} /> : null}
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} config={{ ...config, model: maskEditNode.metadata.model || config.model, imageModel: maskEditNode.metadata.model || config.imageModel, size: maskEditNode.metadata.size || config.size, quality: maskEditNode.metadata.quality || config.quality, count: String(maskEditNode.metadata.count || config.count) }} open onClose={onCloseMaskEdit} onConfirm={(payload) => onMaskEdit(maskEditNode, payload)} /> : null}
{splitNode?.metadata?.content ? <CanvasNodeSplitDialog dataUrl={splitNode.metadata.content} open onClose={onCloseSplit} onConfirm={(params) => onSplit(splitNode, params)} /> : null}
{upscaleNode?.metadata?.content ? <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open onClose={onCloseUpscale} onConfirm={(params) => onUpscale(upscaleNode, params)} /> : null}
</>
);

View File

@@ -200,9 +200,10 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
),
)}
{props.selectedNodeBounds && !props.selectionBox && !props.isNodeDragging ? (
{props.selectedNodeBounds && !props.selectionBox ? (
<div
ref={props.selectionBoundsElementRef}
data-canvas-selection-bounds
className="pointer-events-none absolute z-[var(--z-panel-floating)] rounded-xl"
style={{
left: props.selectedNodeBounds.left - 12 / viewportScale,

View File

@@ -823,10 +823,8 @@ function InfiniteCanvasPage() {
setAnnotationNodeId,
setCropNodeId,
setMaskEditNodeId,
setSplitNodeId,
setUpscaleNodeId,
splitImageNode,
splitNodeId,
openVideoFrameExtractor,
openVideoSegmentExtractor,
upscaleImageNode,
@@ -868,7 +866,6 @@ function InfiniteCanvasPage() {
setCropNodeId(clearDeletedId);
setMaskEditNodeId(clearDeletedId);
setAnnotationNodeId(clearDeletedId);
setSplitNodeId(clearDeletedId);
setUpscaleNodeId(clearDeletedId);
setAngleNodeId(clearDeletedId);
setLightingNodeId(clearDeletedId);
@@ -902,7 +899,6 @@ function InfiniteCanvasPage() {
setLightingNodeId,
setMaskEditNodeId,
setSegmentDialogNodeId,
setSplitNodeId,
setUpscaleNodeId,
setRunningNodeId,
],
@@ -1337,7 +1333,6 @@ function InfiniteCanvasPage() {
selectedNodeBounds,
selectedVideoNodes,
skillMentionReferences,
splitNode,
superResolveNode,
toolbarNode,
upscaleNode,
@@ -1360,7 +1355,7 @@ function InfiniteCanvasPage() {
cropNodeId,
maskEditNodeId,
annotationNodeId,
splitNodeId,
splitNodeId: null,
upscaleNodeId,
superResolveNodeId,
angleNodeId,
@@ -2528,7 +2523,16 @@ function InfiniteCanvasPage() {
</div>
{angleNode?.metadata?.content ? (
<AppModal flush open centered title="多角度编辑器" footer={null} width={620} onCancel={() => setAngleNodeId(null)}>
<CanvasNodePanelOverlay
node={angleNode}
viewport={viewport}
containerRef={containerRef}
panelWidth={640}
panelHeight={540}
allowOverflow
dragOffset={dragPreview?.nodeIds.has(angleNode.id) ? { x: dragPreview.x, y: dragPreview.y } : null}
isDragging={isNodeDragging && Boolean(dragPreview?.nodeIds.has(angleNode.id))}
>
<CanvasNodeAnglePanel
dataUrl={angleNode.metadata.content}
onClose={() => setAngleNodeId(null)}
@@ -2536,7 +2540,7 @@ function InfiniteCanvasPage() {
void generateAngleNode(angleNode, params);
}}
/>
</AppModal>
</CanvasNodePanelOverlay>
) : null}
{lightingNode?.metadata?.content ? (
@@ -2644,7 +2648,7 @@ function InfiniteCanvasPage() {
) : null}
<CanvasNodeToolbar
node={isCanvasNodeMoving || nodeImageSettingsOpen || emotionNodeId ? null : toolbarNode}
node={isCanvasNodeMoving || nodeImageSettingsOpen || emotionNodeId || angleNodeId ? null : toolbarNode}
workspaceMode={workspaceMode}
viewport={viewport}
containerRef={containerRef}
@@ -2667,7 +2671,7 @@ function InfiniteCanvasPage() {
}}
onPortraitTexture={openPortraitTextureEditor}
onCrop={(node) => setCropNodeId(node.id)}
onSplit={(node) => setSplitNodeId(node.id)}
onSplit={(node, params) => void splitImageNode(node, params)}
onUpscale={(node) => setUpscaleNodeId(node.id)}
onSuperResolve={(node) => setSuperResolveNodeId(node.id)}
onAngle={(node) => {
@@ -2981,17 +2985,14 @@ function InfiniteCanvasPage() {
cropNode={cropNode}
annotationNode={annotationNode}
maskEditNode={maskEditNode}
splitNode={splitNode}
upscaleNode={upscaleNode}
onCloseCrop={() => setCropNodeId(null)}
onCloseAnnotation={() => setAnnotationNodeId(null)}
onCloseMaskEdit={() => setMaskEditNodeId(null)}
onCloseSplit={() => setSplitNodeId(null)}
onCloseUpscale={() => setUpscaleNodeId(null)}
onCrop={(node, crop) => void cropImageNode(node, crop)}
onAnnotate={(node, dataUrl) => void saveAnnotatedImageNode(node, dataUrl)}
onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)}
onSplit={(node, params) => void splitImageNode(node, params)}
onUpscale={(node, params) => void upscaleImageNode(node, params)}
config={effectiveConfig}
/>

View File

@@ -14,6 +14,7 @@ import type { PanoramaGenerateConfig } from "@/components/canvas/canvas-panorama
import type { CanvasVideoFrameParams } from "@/components/canvas/canvas-video-frame-dialog";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
import { isValidGridSplit, layoutGridSplitCells } from "@/lib/canvas/canvas-grid-split";
import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
@@ -113,7 +114,6 @@ export function useCanvasMediaTools({
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
const [annotationNodeId, setAnnotationNodeId] = useState<string | null>(null);
const [maskEditNodeId, setMaskEditNodeId] = useState<string | null>(null);
const [splitNodeId, setSplitNodeId] = useState<string | null>(null);
const [upscaleNodeId, setUpscaleNodeId] = useState<string | null>(null);
const [angleNodeId, setAngleNodeId] = useState<string | null>(null);
const [lightingNodeId, setLightingNodeId] = useState<string | null>(null);
@@ -615,25 +615,25 @@ export function useCanvasMediaTools({
}, [connectionsRef, message, persistMediaNodes, setConnections, setNodes]);
const splitImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageSplitParams) => {
if (!node.metadata?.content) return;
setSplitNodeId(null);
if (!node.metadata?.content || !isValidGridSplit(params)) return;
const pieces = await splitDataUrl(node.metadata.content, params);
const gap = 16;
const cellWidth = node.width / params.columns;
const cellHeight = node.height / params.rows;
const startX = node.position.x + node.width + 96;
const childNodes = await Promise.all(pieces.map(async (piece) => {
const sizedPieces = await Promise.all(pieces.map(async (piece) => {
const image = await uploadImage(piece.dataUrl);
return {
id: nanoid(),
type: CanvasNodeType.Image,
title: `${node.title || "图片"} · 宫格 ${piece.row + 1}-${piece.column + 1}`,
position: { x: startX + piece.column * (cellWidth + gap), y: node.position.y + piece.row * (cellHeight + gap) },
width: cellWidth,
height: cellHeight,
metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt },
} satisfies CanvasNodeData;
return { piece, image, size: fitNodeSize(image.width, image.height) };
}));
const positions = layoutGridSplitCells(
{ x: node.position.x + node.width + 96, y: node.position.y },
sizedPieces.map(({ piece, size }) => ({ row: piece.row, column: piece.column, width: size.width, height: size.height })),
);
const childNodes = sizedPieces.map(({ piece, image, size }, index) => ({
id: nanoid(),
type: CanvasNodeType.Image,
title: `${node.title || "图片"} · 宫格 ${piece.row + 1}-${piece.column + 1}`,
position: positions[index] || { x: node.position.x + node.width + 96, y: node.position.y },
width: size.width,
height: size.height,
metadata: { ...imageMetadata(image), prompt: node.metadata?.prompt, manualSize: true },
} satisfies CanvasNodeData));
setNodes((current) => [...current, ...childNodes]);
setConnections((current) => [...current, ...childNodes.map((child) => ({ id: nanoid(), fromNodeId: node.id, toNodeId: child.id }))]);
setSelectedNodeIds(new Set(childNodes.map((child) => child.id)));
@@ -998,10 +998,8 @@ export function useCanvasMediaTools({
setAnnotationNodeId,
setCropNodeId,
setMaskEditNodeId,
setSplitNodeId,
setUpscaleNodeId,
splitImageNode,
splitNodeId,
openVideoFrameExtractor,
openVideoSegmentExtractor,
upscaleImageNode,

View File

@@ -3,16 +3,17 @@ import { describe, expect, test } from "bun:test";
const source = readFileSync(new URL("../src/pages/canvas/project.tsx", import.meta.url), "utf8");
describe("image angle editor (#432)", () => {
test("uses a centered dismissible modal instead of a node-anchored overlay", () => {
const dialog = source.slice(source.indexOf("{angleNode?.metadata?.content"), source.indexOf("{emotionNode?.metadata?.content"));
expect(dialog).toContain("<Modal");
expect(dialog).toContain("centered");
expect(dialog).toContain("onCancel={() => setAngleNodeId(null)}");
describe("image angle editor", () => {
test("anchors the 3D angle editor to the selected node and follows drag", () => {
const dialog = source.slice(source.indexOf("{angleNode?.metadata?.content"), source.indexOf("{lightingNode?.metadata?.content"));
expect(dialog).toContain("CanvasNodePanelOverlay");
expect(dialog).toContain("CanvasNodeAnglePanel");
expect(dialog).toContain("dragOffset={dragPreview");
expect(dialog).toContain("isDragging={isNodeDragging");
expect(dialog).toContain("onClose={() => setAngleNodeId(null)}");
expect(dialog).toContain("destroyOnHidden");
expect(dialog).not.toContain("CanvasNodePanelOverlay");
expect(dialog).not.toContain("isCanvasNodeMoving");
expect(dialog).toContain("generateAngleNode(angleNode, params)");
expect(dialog).not.toContain("<Modal");
expect(dialog).not.toContain("AppModal");
expect(dialog).not.toContain("destroyOnHidden");
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { applyCanvasSelectionStrategy, canvasSelectionHitsBounds, createCanvasSelectionBounds, createCanvasSelectionSpatialIndexCache, resolveCanvasPointerIntent, resolveCanvasSelectionHitMode, resolveCanvasSelectionPreviewDelta, resolveCanvasSelectionStrategy } from "@/lib/canvas/canvas-selection";
import { applyCanvasSelectionStrategy, canvasSelectionHitsBounds, createCanvasSelectionBounds, createCanvasSelectionSpatialIndexCache, offsetSelectedNodeBounds, resolveCanvasPointerIntent, resolveCanvasSelectionHitMode, resolveCanvasSelectionPreviewDelta, resolveCanvasSelectionStrategy, selectedNodesWorldBounds } from "@/lib/canvas/canvas-selection";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
describe("canvas selection semantics", () => {
@@ -95,6 +95,19 @@ describe("canvas selection semantics", () => {
expect(globalStyles).toContain(".canvas-cursor-select");
expect(globalStyles).not.toContain("filter='drop-shadow");
});
test("offsets multi-select bounds with the live drag preview", () => {
const bounds = selectedNodesWorldBounds([
node("a", 100, 40),
node("b", 300, 80),
]);
expect(bounds).toEqual({ left: 100, top: 40, width: 360, height: 130, count: 2 });
expect(offsetSelectedNodeBounds(bounds!, { x: 40, y: -20 })).toEqual({ left: 140, top: 20, width: 360, height: 130, count: 2 });
expect(selectedNodesWorldBounds([
node("a", 100, 40),
node("b", 300, 80),
], { x: 40, y: -20, nodeIds: new Set(["a", "b"]) })).toEqual({ left: 140, top: 20, width: 360, height: 130, count: 2 });
});
});
function node(id: string, x: number, y: number, metadata: CanvasNodeData["metadata"] = {}): CanvasNodeData {