diff --git a/.github/pr-screenshots/canvas-image-tools/01-angle-skybox.png b/.github/pr-screenshots/canvas-image-tools/01-angle-skybox.png new file mode 100644 index 000000000..4b16e9bb1 Binary files /dev/null and b/.github/pr-screenshots/canvas-image-tools/01-angle-skybox.png differ diff --git a/.github/pr-screenshots/canvas-image-tools/02-angle-camera.png b/.github/pr-screenshots/canvas-image-tools/02-angle-camera.png new file mode 100644 index 000000000..f1f929bfe Binary files /dev/null and b/.github/pr-screenshots/canvas-image-tools/02-angle-camera.png differ diff --git a/.github/pr-screenshots/canvas-image-tools/03-grid-split.png b/.github/pr-screenshots/canvas-image-tools/03-grid-split.png new file mode 100644 index 000000000..ea3d5ef9a Binary files /dev/null and b/.github/pr-screenshots/canvas-image-tools/03-grid-split.png differ diff --git a/.github/pr-screenshots/canvas-image-tools/04-selection-bounds.png b/.github/pr-screenshots/canvas-image-tools/04-selection-bounds.png new file mode 100644 index 000000000..fb1ecca25 Binary files /dev/null and b/.github/pr-screenshots/canvas-image-tools/04-selection-bounds.png differ diff --git a/docs/content/docs/backend/code-map.mdx b/docs/content/docs/backend/code-map.mdx index 84d70f10d..09f35d587 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -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` | diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 36cdd5774..a0d153ee1 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -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`:多选虚线框随拖动预览平移。 + ## 系统渠道排序与前台别名 后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。渠道行的“更多”菜单支持复制渠道,复制会生成独立的渠道、模型和价格档,并保留原连接配置与凭证。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 656dfe3fa..cd8895e7b 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -20,6 +20,18 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [ ] 更新后重启本地后端,用调试包中的已完成父轮次 `ag045cb29d5cebb44cb42cedb235d2fc5a` 续发“继续操作”:应创建当前 Capability 合同的新轮,不再返回“系统处理失败”;父轮次状态和冻结合同保持不变。 - [ ] 在真实审批和执行结果中确认:更新节点显示执行前名称、节点类型、修改字段和修改后名称;新增连线显示来源与目标标题/类型,点击可定位对应节点;普通 UI 不暴露完整内部 ID。 +## 多角度编辑器与宫格切分交互 + +多角度改为贴在节点下方的 3D 轨道/天空盒预览(摄像头与天空盒切换、常用角度、方向箭头),宫格切分放在「图片工具」子菜单(预设 + 5×5 自定义点选),沿用当前 `--fg/--bg` 与 dock token,明暗主题共用同一套样式。 + +- [ ] 打开图片节点「多角度」:面板贴在节点下方;拖动节点或平移画布时编辑器跟着节点走,不和节点分离。 +- [ ] 点「正面 / 左侧 / 右侧 / 背面 / 俯拍 / 仰拍」后滑条与底部「当前视角」同步;生成新角度仍创建子节点。 +- [ ] 切换浅色/深色:选中的标签、常用角度、镜头分段和滑条都跟随前景/背景,不出现浅色皮肤残留。 +- [ ] 「宫格切分」在节点工具栏「图片工具」里,不单独占一个工具栏按钮;与「裁剪 / 调整尺寸 / 标注」同一列表样式(图标 14px、标题/说明字号与左右对齐一致);点开后有 4/9/16/25 宫格预设,点「自定义」后 5×5 板留在菜单内不闪退,最少 2 格才会切分。 +- [ ] 宫格列表左侧小宫格图标垂直居中,右侧箭头约 10px,不出现过大的 LayoutGrid。 +- [ ] 切分后子节点按网格排在原图右侧,互不覆盖,并保留原图提示词。 +- [ ] 框选多张图后拖动:虚线选框跟着被选节点一起移动,松手后仍贴合,不留空框或错位。 + ## Agent 增量联动与可点击操作留痕 - 服务端将草稿、提交和媒体成功 / 失败 / 取消的节点增量与运行检查点一起持久化;前端 40ms 合批投影,不按每条完成 / 失败事件全量刷新。缺口、断流或冲突才单请求校准,发起间隔至少 1 秒,期间重复通知仅保留一次尾随校准。 diff --git a/web/src/components/canvas/canvas-angle-scene.css b/web/src/components/canvas/canvas-angle-scene.css new file mode 100644 index 000000000..4f5f35e3a --- /dev/null +++ b/web/src/components/canvas/canvas-angle-scene.css @@ -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); +} diff --git a/web/src/components/canvas/canvas-angle-scene.tsx b/web/src/components/canvas/canvas-angle-scene.tsx new file mode 100644 index 000000000..5d10b97b5 --- /dev/null +++ b/web/src/components/canvas/canvas-angle-scene.tsx @@ -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(null); + const dragPointer = useRef(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(() => ({ + 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) => { + 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 = ( + <> + + + + + + ); + + if (mode === "skybox") { + return ( +
+
+
+
+
+
+
+
{faceLabels.back}
+
{faceLabels.right}
+
{faceLabels.left}
+
{faceLabels.top}
+
{faceLabels.bottom}
+
+
+
+
+
+
+ ); + } + + return ( +
+
+
+
+ +
+
+
+
+ {orbitRingY.map((deg) =>
)} + {orbitRingX.map((deg) =>
)} +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
{dirButtons}
+
+
+
+ ); +} diff --git a/web/src/components/canvas/canvas-grid-split-picker.css b/web/src/components/canvas/canvas-grid-split-picker.css new file mode 100644 index 000000000..cd9a145fd --- /dev/null +++ b/web/src/components/canvas/canvas-grid-split-picker.css @@ -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; +} diff --git a/web/src/components/canvas/canvas-grid-split-picker.tsx b/web/src/components/canvas/canvas-grid-split-picker.tsx new file mode 100644 index 000000000..2173d1323 --- /dev/null +++ b/web/src/components/canvas/canvas-grid-split-picker.tsx @@ -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 ( + + {Array.from({ length: n * n }, (_, index) => )} + + ); +} + +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 ( +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + > +
+ {CANVAS_GRID_SPLIT_PRESETS.map((preset) => ( + + ))} + +
+ {customOpen ? ( +
+
+ 自定义宫格 + {hoverCols} × {hoverRows} +
+
{ + 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 ( +
+
+ ) : null} +
+ ); +} diff --git a/web/src/components/canvas/canvas-image-toolbar-tools.tsx b/web/src/components/canvas/canvas-image-toolbar-tools.tsx index 82ebbe550..7368f1c73 100644 --- a/web/src/components/canvas/canvas-image-toolbar-tools.tsx +++ b/web/src/components/canvas/canvas-image-toolbar-tools.tsx @@ -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: () => , group: "process", - order: 30, - run: (node, handlers) => handlers.onSplit(node), + order: 45, + run: () => undefined, }, { id: "upscale", diff --git a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx index 169d04781..11be0f4d9 100644 --- a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx +++ b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx @@ -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, 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, viewportScale: number) { const padding = 12 / Math.max(viewportScale, 0.05); rect.set({ diff --git a/web/src/components/canvas/canvas-node-angle-dialog.tsx b/web/src/components/canvas/canvas-node-angle-dialog.tsx index f60838844..90f94c6eb 100644 --- a/web/src/components/canvas/canvas-node-angle-dialog.tsx +++ b/web/src/components/canvas/canvas-node-angle-dialog.tsx @@ -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: Key, value: CanvasImageAngleParams[Key]) => setParams((current) => ({ ...current, [key]: value })); - - const startCameraDrag = (event: ReactPointerEvent) => { - 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) => { - 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 ( event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} @@ -68,67 +63,92 @@ export function CanvasNodeAnglePanel({ dataUrl, onClose, onConfirm }: { dataUrl:
- - {presets.map((preset) => setParams((current) => ({ ...current, horizontalAngle: preset.horizontalAngle, pitchAngle: preset.pitchAngle }))} />)} + + {presets.map((preset) => ( + setParams((current) => ({ ...current, horizontalAngle: preset.horizontalAngle, pitchAngle: preset.pitchAngle }))} + /> + ))}
-
-
{ dragRef.current = null; event.currentTarget.releasePointerCapture(event.pointerId); }} onPointerCancel={() => { dragRef.current = null; }}> - - 角度参考 -
- 拖动调整摄影机 +
+
+ update("horizontalAngle", value)} + onTiltChange={(value) => update("pitchAngle", value)} + />
+ setMode(value as "camera" | "skybox")} /> + 常用角度 +
+ {presets.map((preset) => ( + + ))} +
update("horizontalAngle", value)} /> - update("pitchAngle", value)} /> + update("pitchAngle", value)} /> update("cameraDistance", value)} />
- 镜头 - update("wideAngle", value === "wide")} /> + 镜头 + update("wideAngle", value === "wide")} />
- 当前视角 - {activePreset?.label || "自定义"} - {params.horizontalAngle}° / {params.pitchAngle}° · {params.cameraDistance.toFixed(1)} {distanceLabel(params.cameraDistance)} + 当前视角 + {viewLabel} + {params.horizontalAngle}° / {params.pitchAngle}° · {params.cameraDistance.toFixed(1)} {distanceLabel(params.cameraDistance)} - onConfirm(params)}>生成新角度 + onConfirm(params)}>生成新角度
); } +function AngleSeg({ value, options, onChange }: { value: string; options: { label: string; value: string }[]; onChange: (value: string) => void }) { + return
{options.map((option) => )}
; +} + 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
{label}{Number.isInteger(value) ? value : value.toFixed(1)}{suffix.startsWith("°") ? suffix : ""}{!suffix.startsWith("°") ? ` ${suffix}` : ""}
; + return ( +
+ {label} + + {Number.isInteger(value) ? value : value.toFixed(1)}{suffix.startsWith("°") ? suffix : ` ${suffix}`} +
+ ); } -function AnglePresetButton({ active, label, theme, onClick }: { active: boolean; label: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onClick?: () => void }) { - return ; -} - -function GlobeGrid({ color }: { color: string }) { - return ; -} - -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 ; } diff --git a/web/src/components/canvas/canvas-node-toolbar.tsx b/web/src/components/canvas/canvas-node-toolbar.tsx index 27a8758e0..43d7c1dc4 100644 --- a/web/src/components/canvas/canvas-node-toolbar.tsx +++ b/web/src/components/canvas/canvas-node-toolbar.tsx @@ -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 as ToolbarHandlers; @@ -317,7 +319,7 @@ export function CanvasNodeToolbar({ {panoramaTools.map((tool) => )} {portraitTools.length ? } tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null} {viewpointLightingTools.length ? } tools={viewpointLightingTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null} - {processTools.length ? : } tools={processTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null} + {processTools.length ? : } tools={processTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} split={hasImage && !simpleMode ? { node, onSplit } : undefined} /> : null} {workspaceTools.length ? : null} {workspaceTools.map((tool) => )} {utilityTools.length || moreTools.length ? : 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(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(); 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:
{tool.label}{tool.active ? : null}{tool.description ?
{tool.description}
: null}
, 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: ( +
{ keepSplitMenuOpenRef.current = true; } : undefined} + > +
+ {tool.label}{tool.active ? : null} + {tool.description ?
{tool.description}
: null} +
+ {isSplit ? : null} +
+ ), + 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 ( - onOpenChange(menuId, nextOpen)} menu={{ items }} autoFocus popupRender={(menu) =>
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}
}> + { + 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) => ( +
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} +
+ {splitPanelOpen && split ? ( + { + setSplitPanelOpen(false); + onOpenChange(menuId, false); + split.onSplit(split.node, params); + }} + /> + ) : null} +
+ )} + >