feat(canvas): 多角度贴节点、宫格切分菜单与多选虚线框跟随
把多角度改成贴在图片节点下方的 3D 编辑器并跟随拖动;宫格切分并入图片工具,自定义面板不关菜单;切分后子图按网格排开;多选虚线框随拖动预览平移。
This commit is contained in:
BIN
.github/pr-screenshots/canvas-image-tools/01-angle-skybox.png
vendored
Normal file
BIN
.github/pr-screenshots/canvas-image-tools/01-angle-skybox.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
BIN
.github/pr-screenshots/canvas-image-tools/02-angle-camera.png
vendored
Normal file
BIN
.github/pr-screenshots/canvas-image-tools/02-angle-camera.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
BIN
.github/pr-screenshots/canvas-image-tools/03-grid-split.png
vendored
Normal file
BIN
.github/pr-screenshots/canvas-image-tools/03-grid-split.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
BIN
.github/pr-screenshots/canvas-image-tools/04-selection-bounds.png
vendored
Normal file
BIN
.github/pr-screenshots/canvas-image-tools/04-selection-bounds.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 402 KiB |
@@ -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` |
|
||||
|
||||
@@ -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`:多选虚线框随拖动预览平移。
|
||||
|
||||
## 系统渠道排序与前台别名
|
||||
|
||||
后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。
|
||||
|
||||
@@ -5,6 +5,18 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 多角度编辑器与宫格切分交互
|
||||
|
||||
多角度改为贴在节点下方的 3D 轨道/天空盒预览(摄像头与天空盒切换、常用角度、方向箭头),宫格切分放在「图片工具」子菜单(预设 + 5×5 自定义点选),沿用当前 `--fg/--bg` 与 dock token,明暗主题共用同一套样式。
|
||||
|
||||
- [ ] 打开图片节点「多角度」:面板贴在节点下方;拖动节点或平移画布时编辑器跟着节点走,不和节点分离。
|
||||
- [ ] 点「正面 / 左侧 / 右侧 / 背面 / 俯拍 / 仰拍」后滑条与底部「当前视角」同步;生成新角度仍创建子节点。
|
||||
- [ ] 切换浅色/深色:选中的标签、常用角度、镜头分段和滑条都跟随前景/背景,不出现浅色皮肤残留。
|
||||
- [ ] 「宫格切分」在节点工具栏「图片工具」里,不单独占一个工具栏按钮;与「裁剪 / 调整尺寸 / 标注」同一列表样式(图标 14px、标题/说明字号与左右对齐一致);点开后有 4/9/16/25 宫格预设,点「自定义」后 5×5 板留在菜单内不闪退,最少 2 格才会切分。
|
||||
- [ ] 宫格列表左侧小宫格图标垂直居中,右侧箭头约 10px,不出现过大的 LayoutGrid。
|
||||
- [ ] 切分后子节点按网格排在原图右侧,互不覆盖,并保留原图提示词。
|
||||
- [ ] 框选多张图后拖动:虚线选框跟着被选节点一起移动,松手后仍贴合,不留空框或错位。
|
||||
|
||||
## Agent 增量联动与可点击操作留痕
|
||||
|
||||
- 服务端将草稿、提交和媒体成功 / 失败 / 取消的节点增量与运行检查点一起持久化;前端 40ms 合批投影,不按每条完成 / 失败事件全量刷新。缺口、断流或冲突才单请求校准,发起间隔至少 1 秒,期间重复通知仅保留一次尾随校准。
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
32
web/src/lib/canvas/canvas-grid-split.test.ts
Normal file
32
web/src/lib/canvas/canvas-grid-split.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
web/src/lib/canvas/canvas-grid-split.ts
Normal file
49
web/src/lib/canvas/canvas-grid-split.ts
Normal 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 }));
|
||||
}
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user