mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-01 14:11:41 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51df099936 | |||
| 96cca4d97c | |||
| 75d5af5d8c | |||
| 8a51cc609f | |||
| 0adf547db9 | |||
| 163dc51203 | |||
| 68aca6fa16 | |||
| ceb7605a1e | |||
| 76a4e7c4a2 | |||
| c70e7af14d |
+5
-2
@@ -6,8 +6,9 @@ ADMIN_PASSWORD=infinite-canvas
|
|||||||
JWT_SECRET=infinite-canvas
|
JWT_SECRET=infinite-canvas
|
||||||
JWT_EXPIRE_HOURS=168
|
JWT_EXPIRE_HOURS=168
|
||||||
|
|
||||||
# 后端监听端口
|
# 后端默认监听 8080,如需本地开发修改端口再取消注释。
|
||||||
PORT=8080
|
# Docker 镜像内前端固定监听 3000,避免覆盖前端 PORT。
|
||||||
|
# PORT=8080
|
||||||
|
|
||||||
# 公开访问地址,用于把本地上传的 Seedance 参考图/视频暴露给火山方舟拉取。
|
# 公开访问地址,用于把本地上传的 Seedance 参考图/视频暴露给火山方舟拉取。
|
||||||
# 线上部署时填写站点根地址,例如:https://your-domain.example.com
|
# 线上部署时填写站点根地址,例如:https://your-domain.example.com
|
||||||
@@ -21,6 +22,8 @@ NEXT_PUBLIC_DOC_URL=https://docs.canvas.best
|
|||||||
STORAGE_DRIVER=sqlite
|
STORAGE_DRIVER=sqlite
|
||||||
# sqlite: DATABASE_DSN=data/infinite-canvas.db
|
# sqlite: DATABASE_DSN=data/infinite-canvas.db
|
||||||
# Docker 部署时建议使用绝对路径,避免工作目录变化后写入临时库:DATABASE_DSN=/app/data/infinite-canvas.db
|
# Docker 部署时建议使用绝对路径,避免工作目录变化后写入临时库:DATABASE_DSN=/app/data/infinite-canvas.db
|
||||||
|
# mysql 目标库不存在时会尝试自动创建,账号需有 CREATE 权限。
|
||||||
# mysql: DATABASE_DSN=user:password@tcp(127.0.0.1:3306)/infinite_canvas?parseTime=true
|
# mysql: DATABASE_DSN=user:password@tcp(127.0.0.1:3306)/infinite_canvas?parseTime=true
|
||||||
|
# postgres 目标库不存在时会尝试自动创建,账号需有 CREATEDB 权限。
|
||||||
# postgres: DATABASE_DSN=postgres://user:password@127.0.0.1:5432/infinite_canvas?sslmode=disable
|
# postgres: DATABASE_DSN=postgres://user:password@127.0.0.1:5432/infinite_canvas?sslmode=disable
|
||||||
DATABASE_DSN=data/infinite-canvas.db
|
DATABASE_DSN=data/infinite-canvas.db
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## v0.2.3 - 2026-06-04
|
||||||
|
|
||||||
|
+ [新增] 新增图片蒙版局部修改功能。
|
||||||
|
+ [优化] 优化配置节点@图片功能。
|
||||||
|
|
||||||
|
## v0.2.2 - 2026-06-04
|
||||||
|
|
||||||
|
+ [新增] 新增图片放大工具。
|
||||||
|
+ [优化] 优化图片工具条,增加自定义功能。
|
||||||
|
+ [修复] 修复端口冲突问题、pg/mysql未初始化问题。
|
||||||
|
|
||||||
## v0.2.1 - 2026-06-03
|
## v0.2.1 - 2026-06-03
|
||||||
|
|
||||||
+ [新增] 新增文档站点页面。
|
+ [新增] 新增文档站点页面。
|
||||||
|
|||||||
+1
-1
@@ -43,4 +43,4 @@ RUN mkdir -p /app/data/prompts
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
# 先启动内部 Go API,再由 Next.js 提供页面并代理 /api/*。
|
# 先启动内部 Go API,再由 Next.js 提供页面并代理 /api/*。
|
||||||
CMD ["sh", "-c", "PORT=8080 /app/server & cd /app/web && node server.js"]
|
CMD ["sh", "-c", "PORT=8080 /app/server & cd /app/web && PORT=3000 node server.js"]
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ description: 当前版本已实现但仍需人工验证的变更项
|
|||||||
|
|
||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
|
- 画布生成配置节点的“生成配置/组装提示词”改为在节点下方打开独立输入浮层;浮层支持输入 `@` 从已连接图片、文本、视频、音频中选择引用,引用会以图片缩略图或文本标记展示,图片引用可放大预览;生成时再按当前引用解析为实际素材编号,不再提供或读取输入排序。
|
||||||
|
- 图片节点悬浮工具栏新增“局部编辑”入口,打开后可在图片上用画笔/擦除工具绘制遮罩区域、填写局部修改要求,并通过图片编辑接口携带同尺寸 PNG mask 生成新图片节点;结果节点会放在原图右侧并自动连线,原图保持不变。
|
||||||
|
- 图片节点悬浮工具栏新增“复制提示词”、“放大”和“超分”入口,并在末尾增加 `...` 更多按钮;点击“复制提示词”会复制生成该图片的提示词,图片没有提示词时会提示暂无可复制内容;点击 `...` 后打开 Ant Design 风格的“自定义工具栏”弹窗,可在图片节点占位上预览悬浮工具栏,信息、删除、存素材、下载、编辑和图片工具都在同一个快捷工具列表中勾选配置,并可切换是否显示按钮文字,保存后写入本地配置;`...` 配置入口固定显示,预览工具栏下方提供常驻横向滚动控制条。“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。
|
||||||
|
- Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框和节点底部 prompt 面板输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。
|
||||||
|
- 文本节点连接到生成配置节点时,`@` 候选和实际生成输入应读取该生成配置节点的上游参考资源;生成配置输入统计区域应可拖动整个配置节点,预览按钮和设置控件仍保持可点击。
|
||||||
- 配置弹窗改为“配置与用户偏好”并放大为可滚动弹窗;本地直连支持配置生图、视频、文本、音频四类可选模型列表和默认模型,新建画布生图和配置节点会读取“画布默认生图张数”,需要验证远程渠道和本地直连模型列表都能正确显示。
|
- 配置弹窗改为“配置与用户偏好”并放大为可滚动弹窗;本地直连支持配置生图、视频、文本、音频四类可选模型列表和默认模型,新建画布生图和配置节点会读取“画布默认生图张数”,需要验证远程渠道和本地直连模型列表都能正确显示。
|
||||||
- 画布音频节点底部生成面板改为音频提示词、音频模型下拉和 OpenAI Speech 参数设置,支持 `voice`、`response_format`、`speed`、`instructions` 并通过 `/audio/speech` 生成音频节点;需要验证本地直连和云端渠道的生成、重试、下载和刷新恢复。
|
- 画布音频节点底部生成面板改为音频提示词、音频模型下拉和 OpenAI Speech 参数设置,支持 `voice`、`response_format`、`speed`、`instructions` 并通过 `/audio/speech` 生成音频节点;需要验证本地直连和云端渠道的生成、重试、下载和刷新恢复。
|
||||||
- 画布左上角菜单和右上角状态栏新增“文档”入口,会使用 `NEXT_PUBLIC_DOC_URL` 配置的地址并在新标签打开文档站;需要验证登录和未登录状态下顶部入口都可见。
|
- 画布左上角菜单和右上角状态栏新增“文档”入口,会使用 `NEXT_PUBLIC_DOC_URL` 配置的地址并在新标签打开文档站;需要验证登录和未登录状态下顶部入口都可见。
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
@@ -27,13 +29,11 @@ require (
|
|||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
github.com/google/uuid v1.3.0
|
github.com/google/uuid v1.3.0
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
|||||||
+113
-2
@@ -1,6 +1,9 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,7 +12,10 @@ import (
|
|||||||
"github.com/basketikun/infinite-canvas/config"
|
"github.com/basketikun/infinite-canvas/config"
|
||||||
"github.com/basketikun/infinite-canvas/model"
|
"github.com/basketikun/infinite-canvas/model"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/driver/mysql"
|
mysqldriver "github.com/go-sql-driver/mysql"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
gormmysql "gorm.io/driver/mysql"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -41,6 +47,18 @@ func DB() (*gorm.DB, error) {
|
|||||||
if driver == "sqlite" && dsn != ":memory:" {
|
if driver == "sqlite" && dsn != ":memory:" {
|
||||||
_ = os.MkdirAll(filepath.Dir(dsn), 0755)
|
_ = os.MkdirAll(filepath.Dir(dsn), 0755)
|
||||||
}
|
}
|
||||||
|
if isPostgresDriver(driver) {
|
||||||
|
dbErr = ensurePostgresDatabase(dsn)
|
||||||
|
if dbErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if driver == "mysql" {
|
||||||
|
dbErr = ensureMySQLDatabase(dsn)
|
||||||
|
if dbErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
db, dbErr = gorm.Open(dialector(driver, dsn), &gorm.Config{})
|
db, dbErr = gorm.Open(dialector(driver, dsn), &gorm.Config{})
|
||||||
if dbErr != nil {
|
if dbErr != nil {
|
||||||
return
|
return
|
||||||
@@ -59,10 +77,103 @@ func DB() (*gorm.DB, error) {
|
|||||||
func dialector(driver string, dsn string) gorm.Dialector {
|
func dialector(driver string, dsn string) gorm.Dialector {
|
||||||
switch driver {
|
switch driver {
|
||||||
case "mysql":
|
case "mysql":
|
||||||
return mysql.Open(dsn)
|
return gormmysql.Open(dsn)
|
||||||
case "postgres", "postgresql":
|
case "postgres", "postgresql":
|
||||||
return postgres.Open(dsn)
|
return postgres.Open(dsn)
|
||||||
default:
|
default:
|
||||||
return sqlite.Open(dsn)
|
return sqlite.Open(dsn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isPostgresDriver(driver string) bool {
|
||||||
|
return driver == "postgres" || driver == "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureMySQLDatabase(dsn string) error {
|
||||||
|
cfg, err := mysqldriver.ParseDSN(dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(cfg.DBName)
|
||||||
|
if target == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
targetDB, err := sql.Open("mysql", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = targetDB.PingContext(ctx)
|
||||||
|
_ = targetDB.Close()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isMySQLError(err, 1049) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
maintenance := cfg.Clone()
|
||||||
|
maintenance.DBName = ""
|
||||||
|
serverDB, err := sql.Open("mysql", maintenance.FormatDSN())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer serverDB.Close()
|
||||||
|
|
||||||
|
_, err = serverDB.ExecContext(ctx, "CREATE DATABASE "+quoteMySQLIdentifier(target)+" CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||||||
|
if isMySQLError(err, 1007) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensurePostgresDatabase(dsn string) error {
|
||||||
|
cfg, err := pgx.ParseConfig(dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(cfg.Database)
|
||||||
|
if target == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
conn, err := pgx.ConnectConfig(ctx, cfg)
|
||||||
|
if err == nil {
|
||||||
|
_ = conn.Close(ctx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isPostgresError(err, "3D000") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
maintenance := cfg.Copy()
|
||||||
|
maintenance.Database = "postgres"
|
||||||
|
if strings.EqualFold(target, "postgres") {
|
||||||
|
maintenance.Database = "template1"
|
||||||
|
}
|
||||||
|
conn, err = pgx.ConnectConfig(ctx, maintenance)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close(ctx)
|
||||||
|
|
||||||
|
_, err = conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{target}.Sanitize(), pgx.QueryExecModeExec)
|
||||||
|
if isPostgresError(err, "42P04") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMySQLError(err error, number uint16) bool {
|
||||||
|
var mysqlErr *mysqldriver.MySQLError
|
||||||
|
return errors.As(err, &mysqlErr) && mysqlErr.Number == number
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPostgresError(err error, code string) bool {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
return errors.As(err, &pgErr) && pgErr.Code == code
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteMySQLIdentifier(name string) string {
|
||||||
|
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,16 +19,19 @@ import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
|
|||||||
import { UserStatusActions } from "@/components/layout/user-status-actions";
|
import { UserStatusActions } from "@/components/layout/user-status-actions";
|
||||||
import { useAssetStore } from "@/stores/use-asset-store";
|
import { useAssetStore } from "@/stores/use-asset-store";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { cropDataUrl } from "../utils/canvas-image-data";
|
import { cropDataUrl, upscaleDataUrl } from "../utils/canvas-image-data";
|
||||||
import { fitNodeSize, nodeSizeFromRatio } from "../utils/canvas-node-size";
|
import { fitNodeSize, nodeSizeFromRatio } from "../utils/canvas-node-size";
|
||||||
import { App, Button, Dropdown, Modal } from "antd";
|
import { App, Button, Dropdown, Modal } from "antd";
|
||||||
import { NODE_DEFAULT_SIZE, getNodeSpec } from "../constants";
|
import { NODE_DEFAULT_SIZE, getNodeSpec } from "../constants";
|
||||||
import { ActiveConnectionPath, ConnectionPath } from "../components/canvas-connections";
|
import { ActiveConnectionPath, ConnectionPath } from "../components/canvas-connections";
|
||||||
|
import { CanvasConfigComposer } from "../components/canvas-config-composer";
|
||||||
import { CanvasConfigNodePanel } from "../components/canvas-config-node-panel";
|
import { CanvasConfigNodePanel } from "../components/canvas-config-node-panel";
|
||||||
import { CanvasAssistantPanel } from "../components/canvas-assistant-panel";
|
import { CanvasAssistantPanel } from "../components/canvas-assistant-panel";
|
||||||
import { CanvasNodeContextMenu } from "../components/canvas-context-menu";
|
import { CanvasNodeContextMenu } from "../components/canvas-context-menu";
|
||||||
import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "../components/canvas-node-angle-dialog";
|
import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "../components/canvas-node-angle-dialog";
|
||||||
import { CanvasNodeCropDialog, type CanvasImageCropRect } from "../components/canvas-node-crop-dialog";
|
import { CanvasNodeCropDialog, type CanvasImageCropRect } from "../components/canvas-node-crop-dialog";
|
||||||
|
import { CanvasNodeMaskEditDialog, type CanvasImageMaskEditPayload } from "../components/canvas-node-mask-edit-dialog";
|
||||||
|
import { CanvasNodeUpscaleDialog, type CanvasImageUpscaleParams } from "../components/canvas-node-upscale-dialog";
|
||||||
import { buildNodeChatMessages, buildNodeGenerationContext, buildNodeGenerationInputs, hydrateNodeGenerationContext, type NodeGenerationInput } from "../components/canvas-node-generation";
|
import { buildNodeChatMessages, buildNodeGenerationContext, buildNodeGenerationInputs, hydrateNodeGenerationContext, type NodeGenerationInput } from "../components/canvas-node-generation";
|
||||||
import { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "../components/canvas-node-hover-toolbar";
|
import { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "../components/canvas-node-hover-toolbar";
|
||||||
import { InfiniteCanvas } from "../components/infinite-canvas";
|
import { InfiniteCanvas } from "../components/infinite-canvas";
|
||||||
@@ -39,6 +42,7 @@ import { CanvasToolbar } from "../components/canvas-toolbar";
|
|||||||
import { AssetPickerModal, type AssetPickerTab, type InsertAssetPayload } from "../components/asset-picker-modal";
|
import { AssetPickerModal, type AssetPickerTab, type InsertAssetPayload } from "../components/asset-picker-modal";
|
||||||
import { CanvasZoomControls } from "../components/canvas-zoom-controls";
|
import { CanvasZoomControls } from "../components/canvas-zoom-controls";
|
||||||
import { useCanvasStore } from "../stores/use-canvas-store";
|
import { useCanvasStore } from "../stores/use-canvas-store";
|
||||||
|
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references";
|
||||||
import {
|
import {
|
||||||
CanvasNodeType,
|
CanvasNodeType,
|
||||||
type CanvasAssistantImage,
|
type CanvasAssistantImage,
|
||||||
@@ -271,6 +275,9 @@ function InfiniteCanvasPage() {
|
|||||||
const [editRequestNonce, setEditRequestNonce] = useState(0);
|
const [editRequestNonce, setEditRequestNonce] = useState(0);
|
||||||
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
|
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
|
||||||
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
|
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
|
||||||
|
const [maskEditNodeId, setMaskEditNodeId] = useState<string | null>(null);
|
||||||
|
const [upscaleNodeId, setUpscaleNodeId] = useState<string | null>(null);
|
||||||
|
const [superResolveNodeId, setSuperResolveNodeId] = useState<string | null>(null);
|
||||||
const [angleNodeId, setAngleNodeId] = useState<string | null>(null);
|
const [angleNodeId, setAngleNodeId] = useState<string | null>(null);
|
||||||
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
|
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
|
||||||
const [assistantCollapsed, setAssistantCollapsed] = useState(true);
|
const [assistantCollapsed, setAssistantCollapsed] = useState(true);
|
||||||
@@ -525,7 +532,8 @@ function InfiniteCanvasPage() {
|
|||||||
const padding = CONNECTION_NODE_HIT_PADDING / scale;
|
const padding = CONNECTION_NODE_HIT_PADDING / scale;
|
||||||
const handleRadius = CONNECTION_HANDLE_HIT_RADIUS / scale;
|
const handleRadius = CONNECTION_HANDLE_HIT_RADIUS / scale;
|
||||||
let isNearNode = false;
|
let isNearNode = false;
|
||||||
let best: { nodeId: string; priority: number } | null = null;
|
let bestNodeId: string | null = null;
|
||||||
|
let bestPriority = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
[...nodesRef.current]
|
[...nodesRef.current]
|
||||||
.filter((node) => !isHiddenBatchChild(node, nodesRef.current))
|
.filter((node) => !isHiddenBatchChild(node, nodesRef.current))
|
||||||
@@ -543,10 +551,13 @@ function InfiniteCanvasPage() {
|
|||||||
if (node.id === current.nodeId || !normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) return;
|
if (node.id === current.nodeId || !normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) return;
|
||||||
|
|
||||||
const priority = hitsInside ? 0 : hitsHandle ? 1 : 2;
|
const priority = hitsInside ? 0 : hitsHandle ? 1 : 2;
|
||||||
if (!best || priority < best.priority) best = { nodeId: node.id, priority };
|
if (priority < bestPriority) {
|
||||||
|
bestNodeId = node.id;
|
||||||
|
bestPriority = priority;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { nodeId: best?.nodeId || null, isNearNode };
|
return { nodeId: bestNodeId, isNearNode };
|
||||||
},
|
},
|
||||||
[screenToCanvas],
|
[screenToCanvas],
|
||||||
);
|
);
|
||||||
@@ -568,6 +579,9 @@ function InfiniteCanvasPage() {
|
|||||||
const toolbarNode = toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null;
|
const toolbarNode = toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null;
|
||||||
const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null;
|
const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null;
|
||||||
const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null;
|
const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null;
|
||||||
|
const maskEditNode = maskEditNodeId ? nodeById.get(maskEditNodeId) || null : null;
|
||||||
|
const upscaleNode = upscaleNodeId ? nodeById.get(upscaleNodeId) || null : null;
|
||||||
|
const superResolveNode = superResolveNodeId ? nodeById.get(superResolveNodeId) || null : null;
|
||||||
const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null;
|
const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null;
|
||||||
const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null;
|
const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null;
|
||||||
const hasMultipleSelectedNodes = selectedNodeIds.size > 1;
|
const hasMultipleSelectedNodes = selectedNodeIds.size > 1;
|
||||||
@@ -617,7 +631,14 @@ function InfiniteCanvasPage() {
|
|||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [connections, nodes]);
|
}, [connections, nodes]);
|
||||||
|
const resourceContextNodeId = dialogNodeId || activeNodeId;
|
||||||
|
const canvasResourceReferences = useMemo(() => buildCanvasResourceReferences(nodes, connections, resourceContextNodeId), [connections, nodes, resourceContextNodeId]);
|
||||||
|
const resourceReferenceByNodeId = useMemo(() => new Map(canvasResourceReferences.map((reference) => [reference.nodeId, reference])), [canvasResourceReferences]);
|
||||||
|
const mentionReferencesByNodeId = useMemo(() => {
|
||||||
|
const map = new Map<string, ReturnType<typeof buildNodeMentionReferences>>();
|
||||||
|
nodes.forEach((node) => map.set(node.id, buildNodeMentionReferences(node, nodes, connections)));
|
||||||
|
return map;
|
||||||
|
}, [connections, nodes]);
|
||||||
const createNode = useCallback(
|
const createNode = useCallback(
|
||||||
(type: CanvasNodeType, position?: Position) => {
|
(type: CanvasNodeType, position?: Position) => {
|
||||||
const targetPosition = position || getCanvasCenter();
|
const targetPosition = position || getCanvasCenter();
|
||||||
@@ -675,6 +696,7 @@ function InfiniteCanvasPage() {
|
|||||||
setEditingNodeId((current) => (current && allIds.has(current) ? null : current));
|
setEditingNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||||
setInfoNodeId((current) => (current && allIds.has(current) ? null : current));
|
setInfoNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||||
setCropNodeId((current) => (current && allIds.has(current) ? null : current));
|
setCropNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||||
|
setMaskEditNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||||
setAngleNodeId((current) => (current && allIds.has(current) ? null : current));
|
setAngleNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||||
setPreviewNodeId((current) => (current && allIds.has(current) ? null : current));
|
setPreviewNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||||
setRunningNodeId((current) => (current && allIds.has(current) ? null : current));
|
setRunningNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||||
@@ -707,6 +729,7 @@ function InfiniteCanvasPage() {
|
|||||||
setConnections([]);
|
setConnections([]);
|
||||||
setInfoNodeId(null);
|
setInfoNodeId(null);
|
||||||
setCropNodeId(null);
|
setCropNodeId(null);
|
||||||
|
setMaskEditNodeId(null);
|
||||||
setAngleNodeId(null);
|
setAngleNodeId(null);
|
||||||
setPreviewNodeId(null);
|
setPreviewNodeId(null);
|
||||||
setRunningNodeId(null);
|
setRunningNodeId(null);
|
||||||
@@ -1203,7 +1226,8 @@ function InfiniteCanvasPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) return;
|
const target = event.target instanceof Element ? event.target : null;
|
||||||
|
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || target?.closest("[contenteditable='true'],[data-canvas-no-zoom]")) return;
|
||||||
|
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
const isModifierShortcut = event.metaKey || event.ctrlKey;
|
const isModifierShortcut = event.metaKey || event.ctrlKey;
|
||||||
@@ -1262,6 +1286,7 @@ function InfiniteCanvasPage() {
|
|||||||
setEditingNodeId(null);
|
setEditingNodeId(null);
|
||||||
setInfoNodeId(null);
|
setInfoNodeId(null);
|
||||||
setCropNodeId(null);
|
setCropNodeId(null);
|
||||||
|
setMaskEditNodeId(null);
|
||||||
setPendingConnectionCreate(null);
|
setPendingConnectionCreate(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1441,6 +1466,78 @@ function InfiniteCanvasPage() {
|
|||||||
setCropNodeId(null);
|
setCropNodeId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const maskEditImageNode = useCallback(
|
||||||
|
async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => {
|
||||||
|
if (!node.metadata?.content) return;
|
||||||
|
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1", size: node.metadata?.size || "auto" };
|
||||||
|
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
|
||||||
|
openConfigDialog(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userPrompt = payload.prompt.trim();
|
||||||
|
const prompt = `只修改蒙版透明区域,其他区域保持不变。${userPrompt}`;
|
||||||
|
const childId = nanoid();
|
||||||
|
const source = { id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey };
|
||||||
|
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
|
||||||
|
setMaskEditNodeId(null);
|
||||||
|
setRunningNodeId(childId);
|
||||||
|
setNodes((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: childId,
|
||||||
|
type: CanvasNodeType.Image,
|
||||||
|
title: userPrompt.slice(0, 32) || "局部编辑结果",
|
||||||
|
position: { x: node.position.x + node.width + 96, y: node.position.y },
|
||||||
|
width: node.width,
|
||||||
|
height: node.height,
|
||||||
|
metadata: { prompt, status: NODE_STATUS_LOADING, ...generationMetadata },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
|
||||||
|
setSelectedNodeIds(new Set([childId]));
|
||||||
|
setSelectedConnectionId(null);
|
||||||
|
setDialogNodeId(childId);
|
||||||
|
try {
|
||||||
|
const image = await requestEdit(generationConfig, prompt, [source], { id: `${node.id}-mask`, name: "mask.png", type: "image/png", dataUrl: payload.maskDataUrl }).then((items) => items[0]);
|
||||||
|
const uploaded = await uploadImage(image.dataUrl);
|
||||||
|
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
|
||||||
|
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item)));
|
||||||
|
} catch (error) {
|
||||||
|
const errorDetails = error instanceof Error ? error.message : "局部修改失败";
|
||||||
|
message.error(errorDetails);
|
||||||
|
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item)));
|
||||||
|
} finally {
|
||||||
|
setRunningNodeId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[effectiveConfig, isAiConfigReady, message, openConfigDialog],
|
||||||
|
);
|
||||||
|
|
||||||
|
const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => {
|
||||||
|
if (!node.metadata?.content) return;
|
||||||
|
setUpscaleNodeId(null);
|
||||||
|
const upscaled = await upscaleDataUrl(node.metadata.content, params);
|
||||||
|
const image = await uploadImage(upscaled);
|
||||||
|
const size = fitNodeSize(image.width, image.height);
|
||||||
|
const childId = nanoid();
|
||||||
|
const child: CanvasNodeData = {
|
||||||
|
id: childId,
|
||||||
|
type: CanvasNodeType.Image,
|
||||||
|
title: "Upscaled Image",
|
||||||
|
position: { x: node.position.x + node.width + 96, y: node.position.y },
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
metadata: {
|
||||||
|
...imageMetadata(image),
|
||||||
|
prompt: node.metadata?.prompt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setNodes((prev) => [...prev, child]);
|
||||||
|
setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
|
||||||
|
setSelectedNodeIds(new Set([childId]));
|
||||||
|
setDialogNodeId(childId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const generateAngleNode = useCallback(
|
const generateAngleNode = useCallback(
|
||||||
async (node: CanvasNodeData, params: CanvasImageAngleParams) => {
|
async (node: CanvasNodeData, params: CanvasImageAngleParams) => {
|
||||||
if (!node.metadata?.content) return;
|
if (!node.metadata?.content) return;
|
||||||
@@ -1634,12 +1731,13 @@ function InfiniteCanvasPage() {
|
|||||||
);
|
);
|
||||||
const effectivePrompt = generationContext.prompt.trim();
|
const effectivePrompt = generationContext.prompt.trim();
|
||||||
const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode;
|
const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode;
|
||||||
|
const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt;
|
||||||
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
|
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
|
||||||
setRunningNodeId(null);
|
setRunningNodeId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let pendingChildIds: string[] = [];
|
let pendingChildIds: string[] = [];
|
||||||
if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
|
if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mode === "image") {
|
if (mode === "image") {
|
||||||
@@ -1703,7 +1801,7 @@ function InfiniteCanvasPage() {
|
|||||||
? isConfigNode
|
? isConfigNode
|
||||||
? {
|
? {
|
||||||
...node,
|
...node,
|
||||||
metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined },
|
metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined },
|
||||||
}
|
}
|
||||||
: isEmptyImageNode
|
: isEmptyImageNode
|
||||||
? {
|
? {
|
||||||
@@ -1854,16 +1952,16 @@ function InfiniteCanvasPage() {
|
|||||||
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
|
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
|
||||||
id,
|
id,
|
||||||
type: CanvasNodeType.Text,
|
type: CanvasNodeType.Text,
|
||||||
title: prompt.slice(0, 32) || "Generated Text",
|
title: effectivePrompt.slice(0, 32) || "Generated Text",
|
||||||
position: {
|
position: {
|
||||||
x: parentPosition.x + parentConfig.width + 96,
|
x: parentPosition.x + parentConfig.width + 96,
|
||||||
y: parentPosition.y + parentConfig.height / 2 - textConfig.height / 2 + (index - (textCount - 1) / 2) * (textConfig.height + 36),
|
y: parentPosition.y + parentConfig.height / 2 - textConfig.height / 2 + (index - (textCount - 1) / 2) * (textConfig.height + 36),
|
||||||
},
|
},
|
||||||
width: textConfig.width,
|
width: textConfig.width,
|
||||||
height: textConfig.height,
|
height: textConfig.height,
|
||||||
metadata: { prompt, status: NODE_STATUS_LOADING, fontSize: 14 },
|
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14 },
|
||||||
}));
|
}));
|
||||||
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
|
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
|
||||||
setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]);
|
setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2190,30 +2288,41 @@ function InfiniteCanvasPage() {
|
|||||||
batchRecovering={collapsingBatchIds.has(node.id)}
|
batchRecovering={collapsingBatchIds.has(node.id)}
|
||||||
batchMotion={batchMotionById.get(node.id)}
|
batchMotion={batchMotionById.get(node.id)}
|
||||||
showImageInfo={showImageInfo}
|
showImageInfo={showImageInfo}
|
||||||
renderPanel={(panelNode) => (
|
resourceLabel={resourceReferenceByNodeId.get(node.id)}
|
||||||
<CanvasNodePromptPanel
|
mentionReferences={mentionReferencesByNodeId.get(node.id) || []}
|
||||||
node={panelNode}
|
renderPanel={(panelNode) =>
|
||||||
isRunning={runningNodeId === panelNode.id}
|
panelNode.type === CanvasNodeType.Config ? (
|
||||||
onPromptChange={handleNodePromptChange}
|
<CanvasConfigComposer
|
||||||
onConfigChange={handleConfigNodeChange}
|
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
|
||||||
onGenerate={handleGenerateNode}
|
inputs={configInputsById.get(panelNode.id) || []}
|
||||||
onImageSettingsOpenChange={(open) => {
|
onChange={(composerContent) => handleConfigNodeChange(panelNode.id, { composerContent })}
|
||||||
setNodeImageSettingsOpen(open);
|
onClose={() => setDialogNodeId(null)}
|
||||||
if (open) setToolbarNodeId(null);
|
/>
|
||||||
}}
|
) : (
|
||||||
/>
|
<CanvasNodePromptPanel
|
||||||
)}
|
node={panelNode}
|
||||||
|
isRunning={runningNodeId === panelNode.id}
|
||||||
|
mentionReferences={mentionReferencesByNodeId.get(panelNode.id) || []}
|
||||||
|
onPromptChange={handleNodePromptChange}
|
||||||
|
onConfigChange={handleConfigNodeChange}
|
||||||
|
onGenerate={handleGenerateNode}
|
||||||
|
onImageSettingsOpenChange={(open) => {
|
||||||
|
setNodeImageSettingsOpen(open);
|
||||||
|
if (open) setToolbarNodeId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
renderNodeContent={(contentNode) => (
|
renderNodeContent={(contentNode) => (
|
||||||
<CanvasConfigNodePanel
|
<CanvasConfigNodePanel
|
||||||
node={contentNode}
|
node={contentNode}
|
||||||
isRunning={runningNodeId === contentNode.id}
|
isRunning={runningNodeId === contentNode.id}
|
||||||
inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])}
|
inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])}
|
||||||
inputs={configInputsById.get(contentNode.id) || []}
|
|
||||||
onConfigChange={handleConfigNodeChange}
|
onConfigChange={handleConfigNodeChange}
|
||||||
onTextInputChange={handleNodeContentChange}
|
onComposerToggle={() => setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))}
|
||||||
onGenerate={(nodeId) => {
|
onGenerate={(nodeId) => {
|
||||||
const target = nodesRef.current.find((item) => item.id === nodeId);
|
const target = nodesRef.current.find((item) => item.id === nodeId);
|
||||||
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.prompt || "");
|
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -2272,7 +2381,10 @@ function InfiniteCanvasPage() {
|
|||||||
onUpload={(node) => handleUploadRequest(node.id)}
|
onUpload={(node) => handleUploadRequest(node.id)}
|
||||||
onDownload={downloadNodeImage}
|
onDownload={downloadNodeImage}
|
||||||
onSaveAsset={(node) => void saveNodeAsset(node)}
|
onSaveAsset={(node) => void saveNodeAsset(node)}
|
||||||
|
onMaskEdit={(node) => setMaskEditNodeId(node.id)}
|
||||||
onCrop={(node) => setCropNodeId(node.id)}
|
onCrop={(node) => setCropNodeId(node.id)}
|
||||||
|
onUpscale={(node) => setUpscaleNodeId(node.id)}
|
||||||
|
onSuperResolve={(node) => setSuperResolveNodeId(node.id)}
|
||||||
onAngle={(node) => setAngleNodeId(node.id)}
|
onAngle={(node) => setAngleNodeId(node.id)}
|
||||||
onViewImage={(node) => setPreviewNodeId(node.id)}
|
onViewImage={(node) => setPreviewNodeId(node.id)}
|
||||||
onRetry={(node) => void handleRetryNode(node)}
|
onRetry={(node) => void handleRetryNode(node)}
|
||||||
@@ -2339,6 +2451,14 @@ function InfiniteCanvasPage() {
|
|||||||
|
|
||||||
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open={Boolean(cropNode)} onClose={() => setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null}
|
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open={Boolean(cropNode)} onClose={() => setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null}
|
||||||
|
|
||||||
|
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} open={Boolean(maskEditNode)} onClose={() => setMaskEditNodeId(null)} onConfirm={(payload) => void maskEditImageNode(maskEditNode!, payload)} /> : null}
|
||||||
|
|
||||||
|
{upscaleNode?.metadata?.content ? <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open={Boolean(upscaleNode)} onClose={() => setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} /> : null}
|
||||||
|
|
||||||
|
<Modal title="AI 超分" open={Boolean(superResolveNode?.metadata?.content)} centered footer={null} onCancel={() => setSuperResolveNodeId(null)}>
|
||||||
|
<div className="py-8 text-center text-base font-medium">暂未实现</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{angleNode?.metadata?.content ? <CanvasNodeAngleDialog dataUrl={angleNode.metadata.content} open={Boolean(angleNode)} onClose={() => setAngleNodeId(null)} onConfirm={(params) => void generateAngleNode(angleNode!, params)} /> : null}
|
{angleNode?.metadata?.content ? <CanvasNodeAngleDialog dataUrl={angleNode.metadata.content} open={Boolean(angleNode)} onClose={() => setAngleNodeId(null)} onConfirm={(params) => void generateAngleNode(angleNode!, params)} /> : null}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
|
||||||
|
import { Button, Image } from "antd";
|
||||||
|
import { FileText, Image as ImageIcon, Music2, Video, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
|
import type { NodeGenerationInput } from "./canvas-node-generation";
|
||||||
|
|
||||||
|
type CanvasConfigComposerProps = {
|
||||||
|
value: string;
|
||||||
|
inputs: NodeGenerationInput[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Token =
|
||||||
|
| { type: "text"; value: string }
|
||||||
|
| { type: "reference"; nodeId: string };
|
||||||
|
|
||||||
|
type MentionState = {
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]/g;
|
||||||
|
|
||||||
|
export function CanvasConfigComposer({ value, inputs, onChange, onClose }: CanvasConfigComposerProps) {
|
||||||
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const composingRef = useRef(false);
|
||||||
|
const [mention, setMention] = useState<MentionState | null>(null);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const tokens = useMemo(() => parseComposerTokens(value), [value]);
|
||||||
|
const referenceById = useMemo(() => new Map(inputs.map((input) => [input.nodeId, input])), [inputs]);
|
||||||
|
const candidates = useMemo(() => {
|
||||||
|
if (!mention) return [];
|
||||||
|
const query = (mention.query || "").trim().toLowerCase();
|
||||||
|
if (!query) return inputs;
|
||||||
|
return inputs.filter((input) => `${resourceLabel(input, inputs)} ${input.title} ${input.text || ""}`.toLowerCase().includes(query));
|
||||||
|
}, [inputs, mention]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (document.activeElement === editorRef.current) return;
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor) return;
|
||||||
|
editor.textContent = "";
|
||||||
|
tokens.forEach((token) => {
|
||||||
|
if (token.type === "text") {
|
||||||
|
editor.append(document.createTextNode(token.value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = referenceById.get(token.nodeId);
|
||||||
|
if (input) editor.append(createReferenceChip(input, inputs, theme, setImagePreview));
|
||||||
|
});
|
||||||
|
}, [inputs, referenceById, theme, tokens]);
|
||||||
|
|
||||||
|
const syncFromEditor = () => {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor) return;
|
||||||
|
const next = serializeEditor(editor);
|
||||||
|
onChange(next);
|
||||||
|
syncMention();
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncMention = () => {
|
||||||
|
const text = textBeforeCaret();
|
||||||
|
const match = /@([^\s@]*)$/.exec(text);
|
||||||
|
if (!match || !inputs.length) {
|
||||||
|
closeMention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMention({ query: match[1] || "" });
|
||||||
|
setActiveIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMention = () => {
|
||||||
|
setMention(null);
|
||||||
|
setActiveIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertReference = (input: NodeGenerationInput) => {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor) return;
|
||||||
|
removeActiveMention();
|
||||||
|
const chip = createReferenceChip(input, inputs, theme, setImagePreview);
|
||||||
|
const space = document.createTextNode(" ");
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||||||
|
if (range) {
|
||||||
|
range.insertNode(space);
|
||||||
|
range.insertNode(chip);
|
||||||
|
range.setStartAfter(space);
|
||||||
|
range.collapse(true);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
} else {
|
||||||
|
editor.append(chip, space);
|
||||||
|
placeCaretAtEnd(editor);
|
||||||
|
}
|
||||||
|
closeMention();
|
||||||
|
onChange(serializeEditor(editor));
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-canvas-no-zoom
|
||||||
|
className="rounded-2xl border p-3 shadow-2xl backdrop-blur"
|
||||||
|
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||||
|
onMouseDown={stopCanvasInteraction}
|
||||||
|
onPointerDown={stopCanvasInteraction}
|
||||||
|
onWheel={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-baseline gap-2">
|
||||||
|
<div className="shrink-0 text-xs font-semibold">组装提示词</div>
|
||||||
|
<div className="truncate text-[11px] opacity-55">@ 引用已连接素材,发送前按当前连接重新编号</div>
|
||||||
|
</div>
|
||||||
|
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
|
||||||
|
</div>
|
||||||
|
<div className="relative rounded-xl border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||||
|
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}>输入提示词,按 @ 引用连接的图片或文本</div> : null}
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
className="thin-scrollbar min-h-28 w-full overflow-y-auto whitespace-pre-wrap break-words px-3 py-2 text-sm leading-7 outline-none"
|
||||||
|
style={{ color: theme.node.text }}
|
||||||
|
onInput={() => {
|
||||||
|
if (!composingRef.current) syncFromEditor();
|
||||||
|
}}
|
||||||
|
onCompositionStart={() => {
|
||||||
|
composingRef.current = true;
|
||||||
|
}}
|
||||||
|
onCompositionEnd={() => {
|
||||||
|
composingRef.current = false;
|
||||||
|
syncFromEditor();
|
||||||
|
}}
|
||||||
|
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (mention && candidates.length) {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((index) => (index + 1) % candidates.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeMention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((event.key === "Backspace" || event.key === "Delete") && deleteAdjacentReference(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
requestAnimationFrame(syncFromEditor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(syncMention);
|
||||||
|
}}
|
||||||
|
onBlur={() => window.setTimeout(closeMention, 120)}
|
||||||
|
/>
|
||||||
|
{mention && candidates.length ? <MentionMenu inputs={candidates} allInputs={inputs} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null}
|
||||||
|
</div>
|
||||||
|
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function MentionMenu({ inputs, allInputs, activeIndex, theme, onSelect }: { inputs: NodeGenerationInput[]; allInputs: NodeGenerationInput[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (input: NodeGenerationInput) => void }) {
|
||||||
|
const selectedRef = useRef(false);
|
||||||
|
const selectInput = (input: NodeGenerationInput) => {
|
||||||
|
if (selectedRef.current) return;
|
||||||
|
selectedRef.current = true;
|
||||||
|
onSelect(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute left-2 top-[calc(100%+6px)] z-[90] max-h-56 w-64 overflow-y-auto rounded-xl border p-1 shadow-2xl" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}>
|
||||||
|
{inputs.map((input, index) => (
|
||||||
|
<button
|
||||||
|
key={input.nodeId}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition"
|
||||||
|
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
selectInput(input);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ResourcePreview input={input} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block font-medium">{resourceLabel(input, allInputs)}</span>
|
||||||
|
<span className="block truncate opacity-65">{input.text || input.title}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourcePreview({ input }: { input: NodeGenerationInput }) {
|
||||||
|
if (input.type === "image" && input.image) return <img src={input.image.dataUrl} alt="" className="size-9 rounded-md object-cover" />;
|
||||||
|
if (input.type === "video" && input.video) return <video src={input.video.url} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
|
||||||
|
const Icon = input.type === "audio" ? Music2 : input.type === "video" ? Video : input.type === "image" ? ImageIcon : FileText;
|
||||||
|
return (
|
||||||
|
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReferenceChip(input: NodeGenerationInput, inputs: NodeGenerationInput[], theme: (typeof canvasThemes)[keyof typeof canvasThemes], onImagePreview: (url: string) => void) {
|
||||||
|
const wrapper = document.createElement("span");
|
||||||
|
wrapper.contentEditable = "false";
|
||||||
|
wrapper.dataset.referenceNodeId = input.nodeId;
|
||||||
|
wrapper.className = "mx-px inline-flex h-7 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle";
|
||||||
|
Object.assign(wrapper.style, chipStyle(theme));
|
||||||
|
if (input.type === "image" && input.image) {
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.src = input.image.dataUrl;
|
||||||
|
image.alt = input.title;
|
||||||
|
image.className = "size-6 rounded object-cover";
|
||||||
|
wrapper.className = "mx-px inline-flex size-6 items-center justify-center overflow-hidden rounded align-middle";
|
||||||
|
wrapper.appendChild(image);
|
||||||
|
wrapper.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onImagePreview(input.image?.dataUrl || "");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
wrapper.title = input.text || input.title;
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.className = "block truncate";
|
||||||
|
text.textContent = input.type === "text" ? input.text || input.title : input.title;
|
||||||
|
wrapper.appendChild(text);
|
||||||
|
}
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeEditor(editor: HTMLElement) {
|
||||||
|
return serializeNodes(editor.childNodes).replace(/\uFEFF/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeNodes(nodes: NodeListOf<ChildNode>) {
|
||||||
|
let result = "";
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) result += node.textContent || "";
|
||||||
|
if (!(node instanceof HTMLElement)) return;
|
||||||
|
const nodeId = node.dataset.referenceNodeId;
|
||||||
|
if (nodeId) result += `@[node:${nodeId}]`;
|
||||||
|
else if (node.tagName === "BR") result += "\n";
|
||||||
|
else result += serializeNodes(node.childNodes);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeActiveMention() {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection?.rangeCount) return;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const text = textBeforeCaret();
|
||||||
|
const match = /@([^\s@]*)$/.exec(text);
|
||||||
|
if (!match) return;
|
||||||
|
range.setStart(range.startContainer, Math.max(0, range.startOffset - (match[1] || "").length - 1));
|
||||||
|
range.deleteContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAdjacentReference(key: string) {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection?.rangeCount || !selection.isCollapsed) return false;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const target = adjacentReferenceNode(range, key);
|
||||||
|
if (!target) return false;
|
||||||
|
const nextCaretNode = document.createTextNode("");
|
||||||
|
target.replaceWith(nextCaretNode);
|
||||||
|
range.setStart(nextCaretNode, 0);
|
||||||
|
range.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjacentReferenceNode(range: Range, key: string) {
|
||||||
|
const container = range.startContainer;
|
||||||
|
const offset = range.startOffset;
|
||||||
|
const previous = key === "Backspace";
|
||||||
|
if (container.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = container.textContent || "";
|
||||||
|
if ((previous && offset > 0) || (!previous && offset < text.length)) return null;
|
||||||
|
return findReferenceSibling(container, previous);
|
||||||
|
}
|
||||||
|
const children = Array.from(container.childNodes);
|
||||||
|
return findReferenceSibling(children[previous ? offset - 1 : offset] || container, previous, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findReferenceSibling(node: Node, previous: boolean, includeSelf = false): HTMLElement | null {
|
||||||
|
let current: Node | null = includeSelf ? node : previous ? node.previousSibling : node.nextSibling;
|
||||||
|
while (current && current.nodeType === Node.TEXT_NODE && !(current.textContent || "").trim()) current = previous ? current.previousSibling : current.nextSibling;
|
||||||
|
return current instanceof HTMLElement && current.dataset.referenceNodeId ? current : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textBeforeCaret() {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection?.rangeCount) return "";
|
||||||
|
const range = selection.getRangeAt(0).cloneRange();
|
||||||
|
const editor = closestEditor(range.startContainer);
|
||||||
|
if (!editor) return "";
|
||||||
|
range.setStart(editor, 0);
|
||||||
|
return range.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closestEditor(node: Node) {
|
||||||
|
const element = node instanceof Element ? node : node.parentElement;
|
||||||
|
return element?.closest("[contenteditable='true']") || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeCaretAtEnd(element: HTMLElement) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(element);
|
||||||
|
range.collapse(false);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseComposerTokens(value: string): Token[] {
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
for (const match of value.matchAll(CONFIG_REFERENCE_PATTERN)) {
|
||||||
|
if (match.index === undefined) continue;
|
||||||
|
if (match.index > lastIndex) tokens.push({ type: "text", value: value.slice(lastIndex, match.index) });
|
||||||
|
tokens.push({ type: "reference", nodeId: match[1] });
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
if (lastIndex < value.length) tokens.push({ type: "text", value: value.slice(lastIndex) });
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) {
|
||||||
|
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
|
||||||
|
const index = Math.max(0, sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId));
|
||||||
|
if (input.type === "image") return `图片${index + 1}`;
|
||||||
|
if (input.type === "video") return `视频${index + 1}`;
|
||||||
|
if (input.type === "audio") return `音频${index + 1}`;
|
||||||
|
return `文本${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipStyle(theme: (typeof canvasThemes)[keyof typeof canvasThemes]): CSSProperties {
|
||||||
|
return { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text };
|
||||||
|
}
|
||||||
@@ -1,38 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { useState } from "react";
|
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Video } from "lucide-react";
|
||||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Video } from "lucide-react";
|
import { Button, Segmented } from "antd";
|
||||||
import { App, Button, Empty, Input, Modal, Segmented } from "antd";
|
|
||||||
|
|
||||||
import { ModelPicker } from "@/components/model-picker";
|
import { ModelPicker } from "@/components/model-picker";
|
||||||
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
|
||||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||||
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
||||||
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
||||||
import type { NodeGenerationInput } from "./canvas-node-generation";
|
|
||||||
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
|
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
|
||||||
|
|
||||||
type CanvasConfigNodePanelProps = {
|
type CanvasConfigNodePanelProps = {
|
||||||
node: CanvasNodeData;
|
node: CanvasNodeData;
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number };
|
inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number };
|
||||||
inputs: NodeGenerationInput[];
|
|
||||||
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
|
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
|
||||||
onTextInputChange: (nodeId: string, content: string) => void;
|
|
||||||
onGenerate: (nodeId: string) => void;
|
onGenerate: (nodeId: string) => void;
|
||||||
|
onComposerToggle: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, onConfigChange, onTextInputChange, onGenerate }: CanvasConfigNodePanelProps) {
|
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle }: CanvasConfigNodePanelProps) {
|
||||||
const { message } = App.useApp();
|
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
|
||||||
const [editingTextId, setEditingTextId] = useState<string | null>(null);
|
|
||||||
const [editingText, setEditingText] = useState("");
|
|
||||||
const globalConfig = useEffectiveConfig();
|
const globalConfig = useEffectiveConfig();
|
||||||
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
|
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
|
||||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||||
@@ -42,36 +33,9 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
|||||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||||
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: config.model, count: mode === "image" ? count : 1 });
|
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: config.model, count: mode === "image" ? count : 1 });
|
||||||
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
|
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
|
||||||
const textInputs = inputs.filter((input) => input.type === "text");
|
|
||||||
const imageInputs = inputs.filter((input) => input.type === "image");
|
|
||||||
const videoInputs = inputs.filter((input) => input.type === "video");
|
|
||||||
const audioInputs = inputs.filter((input) => input.type === "audio");
|
|
||||||
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
|
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
|
||||||
const canGenerate = mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput;
|
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
|
||||||
|
const canGenerate = hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput);
|
||||||
const moveInput = (input: NodeGenerationInput, offset: number) => {
|
|
||||||
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
|
|
||||||
const sameTypeIndex = sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId);
|
|
||||||
const targetInput = sameTypeInputs[sameTypeIndex + offset];
|
|
||||||
if (!targetInput) return;
|
|
||||||
const index = inputs.findIndex((item) => item.nodeId === input.nodeId);
|
|
||||||
const targetIndex = inputs.findIndex((item) => item.nodeId === targetInput.nodeId);
|
|
||||||
const next = [...inputs];
|
|
||||||
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
|
|
||||||
onConfigChange(node.id, { inputOrder: next.map((input) => input.nodeId) });
|
|
||||||
message.success("已调整输入顺序");
|
|
||||||
};
|
|
||||||
const startTextEdit = (input: NodeGenerationInput) => {
|
|
||||||
setEditingTextId(input.nodeId);
|
|
||||||
setEditingText(input.text || "");
|
|
||||||
};
|
|
||||||
const saveTextEdit = () => {
|
|
||||||
if (!editingTextId) return;
|
|
||||||
onTextInputChange(editingTextId, editingText);
|
|
||||||
setEditingText("");
|
|
||||||
setEditingTextId(null);
|
|
||||||
message.success("已保存文本提示词");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
||||||
@@ -125,14 +89,14 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
|
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||||
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
|
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
|
||||||
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
||||||
<InputChip label="参考视频" value={`${inputSummary.videoCount} 个`} style={chipStyle} />
|
<InputChip label="参考视频" value={`${inputSummary.videoCount} 个`} style={chipStyle} />
|
||||||
<InputChip label="参考音频" value={`${inputSummary.audioCount} 个`} style={chipStyle} />
|
<InputChip label="参考音频" value={`${inputSummary.audioCount} 个`} style={chipStyle} />
|
||||||
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onClick={() => setPreviewOpen(true)}>
|
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onMouseDown={(event) => event.stopPropagation()} onClick={onComposerToggle}>
|
||||||
<Eye className="size-3.5" />
|
<Settings2 className="size-3.5" />
|
||||||
预览
|
组装提示词
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,238 +127,6 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
|||||||
<span>开始生成</span>
|
<span>开始生成</span>
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Modal
|
|
||||||
title="输入预览"
|
|
||||||
open={previewOpen}
|
|
||||||
onCancel={() => setPreviewOpen(false)}
|
|
||||||
footer={null}
|
|
||||||
centered
|
|
||||||
width={860}
|
|
||||||
mask={{ closable: true }}
|
|
||||||
keyboard
|
|
||||||
destroyOnHidden
|
|
||||||
modalRender={(modal) => (
|
|
||||||
<div onClick={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
|
||||||
{modal}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()}>
|
|
||||||
{inputs.length ? (
|
|
||||||
<div className="flex h-[min(66vh,580px)] flex-col gap-3 overflow-hidden">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词">
|
|
||||||
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
|
|
||||||
{imageInputs.map((input, index) => (
|
|
||||||
<ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PreviewSection>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0">
|
|
||||||
<PreviewSection title="参考视频" count={videoInputs.length} empty="暂无参考视频">
|
|
||||||
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
|
|
||||||
{videoInputs.map((input, index) => (
|
|
||||||
<VideoSortCard key={input.nodeId} input={input} videoIndex={index} videoTotal={videoInputs.length} theme={theme} onMove={moveInput} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PreviewSection>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0">
|
|
||||||
<PreviewSection title="参考音频" count={audioInputs.length} empty="暂无参考音频">
|
|
||||||
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
|
|
||||||
{audioInputs.map((input, index) => (
|
|
||||||
<AudioSortCard key={input.nodeId} input={input} audioIndex={index} audioTotal={audioInputs.length} theme={theme} onMove={moveInput} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PreviewSection>
|
|
||||||
</div>
|
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden">
|
|
||||||
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
|
|
||||||
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{textInputs.map((input, index) => (
|
|
||||||
<TextSortCard key={input.nodeId} input={input} textIndex={index} textTotal={textInputs.length} inputs={inputs} theme={theme} onMove={moveInput} onEdit={startTextEdit} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PreviewSection>
|
|
||||||
</div>
|
|
||||||
<div className="flex min-h-0 flex-col rounded-xl border p-2.5" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
|
||||||
{editingTextId ? (
|
|
||||||
<>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<div className="text-sm font-semibold">编辑文本提示词</div>
|
|
||||||
<Button size="small" type="text" onClick={() => setEditingTextId(null)}>
|
|
||||||
收起
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Input.TextArea className="thin-scrollbar !flex-1 !resize-none !text-xs !leading-5" value={editingText} onChange={(event) => setEditingText(event.target.value)} />
|
|
||||||
<div className="mt-2 flex justify-end gap-2">
|
|
||||||
<Button size="small" onClick={() => setEditingTextId(null)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button size="small" type="primary" onClick={saveTextEdit}>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full flex-col justify-center rounded-xl border border-dashed px-4 text-center text-xs leading-5 opacity-45" style={{ borderColor: theme.node.stroke }}>
|
|
||||||
<Edit3 className="mx-auto mb-2 size-5" />
|
|
||||||
选择一条文本后在这里编辑
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词或参考图" className="py-8" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PreviewSection({ title, count, empty, children }: { title: string; count: number; empty: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between px-0.5 py-0.5 backdrop-blur-sm">
|
|
||||||
<div className="text-xs font-semibold">{title}</div>
|
|
||||||
<div className="text-[11px] opacity-50">{count} 个</div>
|
|
||||||
</div>
|
|
||||||
{count ? children : <div className="rounded-xl border border-dashed px-3 py-5 text-center text-xs opacity-45">{empty}</div>}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextSortCard({
|
|
||||||
input,
|
|
||||||
textIndex,
|
|
||||||
textTotal,
|
|
||||||
inputs,
|
|
||||||
theme,
|
|
||||||
onMove,
|
|
||||||
onEdit,
|
|
||||||
}: {
|
|
||||||
input: NodeGenerationInput;
|
|
||||||
textIndex: number;
|
|
||||||
textTotal: number;
|
|
||||||
inputs: NodeGenerationInput[];
|
|
||||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
|
||||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
|
||||||
onEdit: (input: NodeGenerationInput) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_72px] items-center gap-1.5 rounded-md border px-2 py-1" style={{ background: `${theme.node.fill}99`, borderColor: theme.node.stroke }}>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-[10px] font-medium opacity-50">文本 {textIndex + 1}</div>
|
|
||||||
<div className="line-clamp-1 whitespace-pre-wrap break-words text-[11px] leading-4 opacity-80">{input.text}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-1">
|
|
||||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<Edit3 className="size-3" />} onClick={() => onEdit(input)} />
|
|
||||||
<VerticalOrderButtons index={textIndex} total={textTotal} onMove={(offset) => onMove(input, offset)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImageSortCard({
|
|
||||||
input,
|
|
||||||
imageIndex,
|
|
||||||
imageTotal,
|
|
||||||
inputs,
|
|
||||||
theme,
|
|
||||||
onMove,
|
|
||||||
}: {
|
|
||||||
input: NodeGenerationInput;
|
|
||||||
imageIndex: number;
|
|
||||||
imageTotal: number;
|
|
||||||
inputs: NodeGenerationInput[];
|
|
||||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
|
||||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
|
||||||
}) {
|
|
||||||
if (!input.image) return null;
|
|
||||||
return (
|
|
||||||
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
|
||||||
<div className="relative">
|
|
||||||
<img src={input.image.dataUrl} alt={input.title} className="aspect-square w-full object-cover" />
|
|
||||||
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{imageReferenceLabel(imageIndex)}</span>
|
|
||||||
<HorizontalOrderButtons index={imageIndex} total={imageTotal} onMove={(offset) => onMove(input, offset)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function VideoSortCard({
|
|
||||||
input,
|
|
||||||
videoIndex,
|
|
||||||
videoTotal,
|
|
||||||
theme,
|
|
||||||
onMove,
|
|
||||||
}: {
|
|
||||||
input: NodeGenerationInput;
|
|
||||||
videoIndex: number;
|
|
||||||
videoTotal: number;
|
|
||||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
|
||||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
|
||||||
}) {
|
|
||||||
if (!input.video) return null;
|
|
||||||
return (
|
|
||||||
<div className="w-32 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
|
||||||
<div className="relative">
|
|
||||||
<video src={input.video.url} className="aspect-video w-full bg-black object-cover" muted preload="metadata" />
|
|
||||||
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{seedanceReferenceLabel("video", videoIndex)}</span>
|
|
||||||
<HorizontalOrderButtons index={videoIndex} total={videoTotal} onMove={(offset) => onMove(input, offset)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AudioSortCard({
|
|
||||||
input,
|
|
||||||
audioIndex,
|
|
||||||
audioTotal,
|
|
||||||
theme,
|
|
||||||
onMove,
|
|
||||||
}: {
|
|
||||||
input: NodeGenerationInput;
|
|
||||||
audioIndex: number;
|
|
||||||
audioTotal: number;
|
|
||||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
|
||||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
|
||||||
}) {
|
|
||||||
if (!input.audio) return null;
|
|
||||||
return (
|
|
||||||
<div className="w-48 shrink-0 rounded-lg border p-2" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
|
||||||
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 text-[11px] opacity-70">
|
|
||||||
<Music2 className="size-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{input.title}</span>
|
|
||||||
</div>
|
|
||||||
<audio src={input.audio.url} controls className="h-8 w-full" preload="metadata" />
|
|
||||||
<div className="mt-1 flex justify-between">
|
|
||||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !p-0" icon={<ArrowLeft className="size-3" />} disabled={audioIndex <= 0} onClick={() => onMove(input, -1)} />
|
|
||||||
<span className="text-[10px] opacity-45">{seedanceReferenceLabel("audio", audioIndex)}</span>
|
|
||||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !p-0" icon={<ArrowRight className="size-3" />} disabled={audioIndex >= audioTotal - 1} onClick={() => onMove(input, 1)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowUp className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
|
|
||||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowDown className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HorizontalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-x-1 bottom-1 flex justify-between">
|
|
||||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowLeft className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
|
|
||||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowRight className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||||
|
import { Button, Card, Checkbox, Form, Modal, Space, Switch, Tag, Tooltip, Typography, theme as antdTheme } from "antd";
|
||||||
|
import { Ellipsis, Image as ImageIcon, Settings2 } from "lucide-react";
|
||||||
|
|
||||||
|
import type { ImageQuickToolId } from "./canvas-image-toolbar-tools";
|
||||||
|
|
||||||
|
export type ImageToolbarSettingsTool = {
|
||||||
|
id: ImageQuickToolId;
|
||||||
|
title: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
active?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreviewTool = ImageToolbarSettingsTool | {
|
||||||
|
id: "more";
|
||||||
|
title: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
active?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreviewScroll = {
|
||||||
|
left: number;
|
||||||
|
max: number;
|
||||||
|
viewport: number;
|
||||||
|
content: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImageToolSettingsModal({
|
||||||
|
open,
|
||||||
|
tools,
|
||||||
|
selectedIds,
|
||||||
|
showLabels,
|
||||||
|
onToggle,
|
||||||
|
onShowLabelsChange,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
tools: ImageToolbarSettingsTool[];
|
||||||
|
selectedIds: ImageQuickToolId[];
|
||||||
|
showLabels: boolean;
|
||||||
|
onToggle: (id: ImageQuickToolId, visible: boolean) => void;
|
||||||
|
onShowLabelsChange: (value: boolean) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}) {
|
||||||
|
const { token } = antdTheme.useToken();
|
||||||
|
const previewToolbarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollbarTrackRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [previewScroll, setPreviewScroll] = useState<PreviewScroll>({ left: 0, max: 0, viewport: 1, content: 1 });
|
||||||
|
const selected = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||||
|
const selectedTools = tools.filter((tool) => selected.has(tool.id));
|
||||||
|
const previewTools: PreviewTool[] = [
|
||||||
|
...selectedTools,
|
||||||
|
{ id: "more", title: "配置快捷工具", label: "更多", icon: <Ellipsis className="size-4" />, active: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const syncPreviewScroll = useCallback(() => {
|
||||||
|
const toolbar = previewToolbarRef.current;
|
||||||
|
if (!toolbar) return;
|
||||||
|
setPreviewScroll({
|
||||||
|
left: toolbar.scrollLeft,
|
||||||
|
max: Math.max(0, toolbar.scrollWidth - toolbar.clientWidth),
|
||||||
|
viewport: Math.max(1, toolbar.clientWidth),
|
||||||
|
content: Math.max(1, toolbar.scrollWidth),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPreviewScrollLeft = useCallback(
|
||||||
|
(left: number) => {
|
||||||
|
const toolbar = previewToolbarRef.current;
|
||||||
|
if (!toolbar) return;
|
||||||
|
toolbar.scrollLeft = left;
|
||||||
|
syncPreviewScroll();
|
||||||
|
},
|
||||||
|
[syncPreviewScroll],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSelectedTools = (values: ImageQuickToolId[]) => {
|
||||||
|
const next = new Set(values);
|
||||||
|
tools.forEach((tool) => {
|
||||||
|
const visible = next.has(tool.id);
|
||||||
|
if (selected.has(tool.id) !== visible) onToggle(tool.id, visible);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const toolbar = previewToolbarRef.current;
|
||||||
|
const sync = () => syncPreviewScroll();
|
||||||
|
const frames: number[] = [];
|
||||||
|
const firstFrame = window.requestAnimationFrame(() => {
|
||||||
|
sync();
|
||||||
|
frames.push(window.requestAnimationFrame(sync));
|
||||||
|
});
|
||||||
|
frames.push(firstFrame);
|
||||||
|
const timer = window.setTimeout(sync, 120);
|
||||||
|
const resizeObserver = typeof ResizeObserver !== "undefined" && toolbar ? new ResizeObserver(sync) : null;
|
||||||
|
resizeObserver?.observe(toolbar);
|
||||||
|
toolbar?.childNodes.forEach((child) => {
|
||||||
|
if (child instanceof Element) resizeObserver?.observe(child);
|
||||||
|
});
|
||||||
|
sync();
|
||||||
|
window.addEventListener("resize", syncPreviewScroll);
|
||||||
|
return () => {
|
||||||
|
frames.forEach((frame) => window.cancelAnimationFrame(frame));
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
window.removeEventListener("resize", syncPreviewScroll);
|
||||||
|
};
|
||||||
|
}, [open, selectedIds, showLabels, previewTools.length, syncPreviewScroll]);
|
||||||
|
|
||||||
|
const scrollbarWidth = scrollbarTrackRef.current?.clientWidth || previewScroll.viewport;
|
||||||
|
const scrollbarThumbWidth = previewScroll.max > 0 ? Math.min(scrollbarWidth, Math.max(64, (previewScroll.viewport / previewScroll.content) * scrollbarWidth)) : scrollbarWidth;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="自定义工具栏"
|
||||||
|
open={open}
|
||||||
|
centered
|
||||||
|
width={760}
|
||||||
|
onCancel={onCancel}
|
||||||
|
destroyOnHidden
|
||||||
|
footer={
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>显示按钮文字</span>
|
||||||
|
<Switch checked={showLabels} onChange={onShowLabelsChange} />
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onCancel}>取消</Button>
|
||||||
|
<Button type="primary" onClick={onSave}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary" className="!mb-4">
|
||||||
|
选择你想在图片节点编辑栏中使用的快捷工具。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<Space size={6}>
|
||||||
|
<Settings2 className="size-4" />
|
||||||
|
节点预览
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<div className="relative flex min-h-[300px] w-full justify-center pt-20 pb-9">
|
||||||
|
<div
|
||||||
|
ref={previewToolbarRef}
|
||||||
|
className="hide-scrollbar absolute left-2 right-2 top-3 z-10 flex h-12 items-center overflow-x-auto rounded-[18px] border px-1 text-[13px]"
|
||||||
|
style={{ background: token.colorBgElevated, borderColor: token.colorBorderSecondary, boxShadow: token.boxShadowSecondary, color: token.colorText }}
|
||||||
|
onScroll={syncPreviewScroll}
|
||||||
|
>
|
||||||
|
{previewTools.map((tool) => (
|
||||||
|
<PreviewToolbarItem key={tool.id} tool={tool} showLabels={showLabels} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex h-48 w-full max-w-[360px] flex-col items-center justify-center rounded-xl border"
|
||||||
|
style={{ background: token.colorFillAlter, borderColor: token.colorBorderSecondary, color: token.colorTextSecondary }}
|
||||||
|
>
|
||||||
|
<ImageIcon className="mb-2 size-8" />
|
||||||
|
<Typography.Text type="secondary">图片节点</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={scrollbarTrackRef}
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={Math.max(previewScroll.max, 1)}
|
||||||
|
value={Math.min(previewScroll.left, Math.max(previewScroll.max, 1))}
|
||||||
|
disabled={previewScroll.max <= 0}
|
||||||
|
className="absolute bottom-4 left-10 right-10 h-2.5 cursor-pointer appearance-none bg-transparent disabled:cursor-default [&::-moz-range-thumb]:h-2.5 [&::-moz-range-thumb]:w-[var(--preview-scrollbar-thumb-width)] [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-[#8d9498] [&::-moz-range-track]:h-2.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-[#bdc4c8] [&::-webkit-slider-runnable-track]:h-2.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-[#bdc4c8] [&::-webkit-slider-thumb]:h-2.5 [&::-webkit-slider-thumb]:w-[var(--preview-scrollbar-thumb-width)] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[#8d9498]"
|
||||||
|
style={{ "--preview-scrollbar-thumb-width": `${scrollbarThumbWidth}px` } as CSSProperties}
|
||||||
|
onInput={(event) => setPreviewScrollLeft(Number(event.currentTarget.value))}
|
||||||
|
onChange={(event) => setPreviewScrollLeft(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Form layout="vertical" className="!mb-0">
|
||||||
|
<Form.Item
|
||||||
|
className="!mb-4"
|
||||||
|
label={
|
||||||
|
<Space size={8}>
|
||||||
|
<span>快捷工具</span>
|
||||||
|
<Tag className="m-0">
|
||||||
|
{selectedTools.length}/{tools.length}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Checkbox.Group value={selectedIds} className="grid w-full gap-3 md:grid-cols-3" onChange={(values) => updateSelectedTools(values as ImageQuickToolId[])}>
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<Checkbox key={tool.id} value={tool.id} className="m-0">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
{tool.icon}
|
||||||
|
{tool.label}
|
||||||
|
</span>
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Checkbox.Group>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewToolbarItem({ tool, showLabels }: { tool: PreviewTool; showLabels: boolean }) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={tool.title}>
|
||||||
|
<span className="flex h-12 shrink-0 items-center px-1.5" style={{ color: tool.danger ? "#ef4444" : undefined }}>
|
||||||
|
<span className={`flex h-9 items-center rounded-lg px-2 ${showLabels ? "gap-2" : "justify-center"}`}>
|
||||||
|
{tool.icon}
|
||||||
|
{showLabels ? <span className="whitespace-nowrap">{tool.label}</span> : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Brush, Camera, Copy, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react";
|
||||||
|
|
||||||
|
import type { CanvasNodeData } from "../types";
|
||||||
|
|
||||||
|
export type ImageNodeActionToolId = "copyPrompt" | "replace" | "resize" | "maskEdit" | "crop" | "upscale" | "superResolve" | "angle" | "view";
|
||||||
|
export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId;
|
||||||
|
|
||||||
|
export type ImageToolHandlers = {
|
||||||
|
onUpload: (node: CanvasNodeData) => void;
|
||||||
|
onToggleFreeResize: (node: CanvasNodeData) => void;
|
||||||
|
onMaskEdit: (node: CanvasNodeData) => void;
|
||||||
|
onCrop: (node: CanvasNodeData) => void;
|
||||||
|
onUpscale: (node: CanvasNodeData) => void;
|
||||||
|
onSuperResolve: (node: CanvasNodeData) => void;
|
||||||
|
onAngle: (node: CanvasNodeData) => void;
|
||||||
|
onViewImage: (node: CanvasNodeData) => void;
|
||||||
|
onCopyPrompt: (node: CanvasNodeData) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageToolDefinition = {
|
||||||
|
id: ImageNodeActionToolId;
|
||||||
|
defaultVisible: boolean;
|
||||||
|
panelLabel: string;
|
||||||
|
label: string | ((node: CanvasNodeData) => string);
|
||||||
|
title: string | ((node: CanvasNodeData) => string);
|
||||||
|
icon: (node: CanvasNodeData) => ReactNode;
|
||||||
|
active?: (node: CanvasNodeData) => boolean;
|
||||||
|
run: (node: CanvasNodeData, handlers: ImageToolHandlers) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageQuickToolsConfig = {
|
||||||
|
ids: ImageQuickToolId[];
|
||||||
|
showLabels: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v4";
|
||||||
|
|
||||||
|
const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"];
|
||||||
|
|
||||||
|
export const imageToolDefinitions: ImageToolDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "copyPrompt",
|
||||||
|
defaultVisible: true,
|
||||||
|
panelLabel: "复制提示词",
|
||||||
|
label: "复制提示词",
|
||||||
|
title: "复制生成该图片的提示词",
|
||||||
|
icon: () => <Copy className="size-4" />,
|
||||||
|
run: (node, handlers) => handlers.onCopyPrompt(node),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "replace",
|
||||||
|
defaultVisible: true,
|
||||||
|
panelLabel: "替换图片",
|
||||||
|
label: "替换图片",
|
||||||
|
title: "替换图片",
|
||||||
|
icon: () => <Upload className="size-4" />,
|
||||||
|
run: (node, handlers) => handlers.onUpload(node),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resize",
|
||||||
|
defaultVisible: false,
|
||||||
|
panelLabel: "锁比例",
|
||||||
|
label: (node) => (node.metadata?.freeResize ? "自由比例" : "锁比例"),
|
||||||
|
title: (node) => (node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"),
|
||||||
|
icon: (node) => (node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />),
|
||||||
|
active: (node) => Boolean(node.metadata?.freeResize),
|
||||||
|
run: (node, handlers) => handlers.onToggleFreeResize(node),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maskEdit",
|
||||||
|
defaultVisible: true,
|
||||||
|
panelLabel: "局部编辑",
|
||||||
|
label: "局部编辑",
|
||||||
|
title: "添加蒙版遮罩后局部修改",
|
||||||
|
icon: () => <Brush className="size-4" />,
|
||||||
|
run: (node, handlers) => handlers.onMaskEdit(node),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "crop",
|
||||||
|
defaultVisible: true,
|
||||||
|
panelLabel: "裁剪",
|
||||||
|
label: "裁剪",
|
||||||
|
title: "裁剪并生成新节点",
|
||||||
|
icon: () => <Scissors className="size-4" />,
|
||||||
|
run: (node, handlers) => handlers.onCrop(node),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "upscale",
|
||||||
|
defaultVisible: true,
|
||||||
|
panelLabel: "放大",
|
||||||
|
label: "放大",
|
||||||
|
title: "放大图片分辨率",
|
||||||
|
icon: () => <ZoomIn className="size-4" />,
|
||||||
|
run: (node, handlers) => handlers.onUpscale(node),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "superResolve",
|
||||||
|
defaultVisible: false,
|
||||||
|
panelLabel: "超分",
|
||||||
|
label: "超分",
|
||||||
|
title: "AI 超分",
|
||||||
|
icon: () => <Sparkles className="size-4" />,
|
||||||
|
run: (node, handlers) => handlers.onSuperResolve(node),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "angle",
|
||||||
|
defaultVisible: false,
|
||||||
|
panelLabel: "多角度",
|
||||||
|
label: "多角度",
|
||||||
|
title: "生成角度",
|
||||||
|
icon: () => <Camera className="size-4" />,
|
||||||
|
run: (node, handlers) => handlers.onAngle(node),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "view",
|
||||||
|
defaultVisible: true,
|
||||||
|
panelLabel: "查看大图",
|
||||||
|
label: "查看大图",
|
||||||
|
title: "查看图片详情",
|
||||||
|
icon: () => <Maximize2 className="size-4" />,
|
||||||
|
run: (node, handlers) => handlers.onViewImage(node),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const defaultImageQuickToolIds: ImageQuickToolId[] = [...defaultBaseToolIds, ...imageToolDefinitions.filter((tool) => tool.defaultVisible).map((tool) => tool.id)];
|
||||||
|
|
||||||
|
export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageToolHandlers) {
|
||||||
|
return imageToolDefinitions.map((tool) => ({
|
||||||
|
id: tool.id,
|
||||||
|
label: resolveToolText(tool.label, node),
|
||||||
|
title: resolveToolText(tool.title, node),
|
||||||
|
icon: tool.icon(node),
|
||||||
|
active: tool.active?.(node),
|
||||||
|
onClick: () => tool.run(node, handlers),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeImageQuickToolIds(value: unknown[]) {
|
||||||
|
const allIds: ImageQuickToolId[] = [...defaultBaseToolIds, ...imageToolDefinitions.map((tool) => tool.id)];
|
||||||
|
const ids = new Set(allIds);
|
||||||
|
return allIds.filter((id) => value.includes(id) && ids.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readImageQuickToolsConfig(value: unknown): ImageQuickToolsConfig {
|
||||||
|
if (Array.isArray(value)) return { ids: normalizeImageQuickToolIds(value), showLabels: true };
|
||||||
|
if (!value || typeof value !== "object") return { ids: defaultImageQuickToolIds, showLabels: true };
|
||||||
|
const data = value as Partial<ImageQuickToolsConfig>;
|
||||||
|
return {
|
||||||
|
ids: Array.isArray(data.ids) ? normalizeImageQuickToolIds(data.ids) : defaultImageQuickToolIds,
|
||||||
|
showLabels: data.showLabels !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveToolText(value: string | ((node: CanvasNodeData) => string), node: CanvasNodeData) {
|
||||||
|
return typeof value === "function" ? value(node) : value;
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { ChatCompletionMessage } from "@/services/api/image";
|
import type { ChatCompletionMessage } from "@/services/api/image";
|
||||||
|
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||||
|
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||||
import type { ReferenceImage } from "@/types/image";
|
import type { ReferenceImage } from "@/types/image";
|
||||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
|
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
|
||||||
|
import { getGenerationResourceNodes } from "../utils/canvas-resource-references";
|
||||||
|
|
||||||
export type NodeGenerationContext = {
|
export type NodeGenerationContext = {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -26,6 +29,11 @@ export type NodeGenerationInput = {
|
|||||||
|
|
||||||
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext {
|
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext {
|
||||||
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
|
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
|
||||||
|
const sourceNode = nodes.find((node) => node.id === nodeId);
|
||||||
|
if (sourceNode?.type === CanvasNodeType.Config && Boolean(sourceNode.metadata?.composerContent?.trim())) {
|
||||||
|
return buildComposerGenerationContext(inputs, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
const upstreamText = inputs
|
const upstreamText = inputs
|
||||||
.map((input) => input.text)
|
.map((input) => input.text)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -46,8 +54,67 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildComposerGenerationContext(inputs: NodeGenerationInput[], prompt: string): NodeGenerationContext {
|
||||||
|
const inputByNodeId = new Map(inputs.map((input) => [input.nodeId, input]));
|
||||||
|
const selectedInputs: NodeGenerationInput[] = [];
|
||||||
|
const labelByNodeId = new Map<string, string>();
|
||||||
|
const textBlocks: string[] = [];
|
||||||
|
const counts = { image: 0, video: 0, audio: 0, text: 0 };
|
||||||
|
let hasToken = false;
|
||||||
|
let lastIndex = 0;
|
||||||
|
let nextPrompt = "";
|
||||||
|
|
||||||
|
for (const match of prompt.matchAll(/@\[node:([^\]]+)\]/g)) {
|
||||||
|
if (match.index === undefined) continue;
|
||||||
|
hasToken = true;
|
||||||
|
nextPrompt += prompt.slice(lastIndex, match.index);
|
||||||
|
const input = inputByNodeId.get(match[1]);
|
||||||
|
if (input) {
|
||||||
|
let label = labelByNodeId.get(input.nodeId);
|
||||||
|
if (!label) {
|
||||||
|
label = generationLabel(input.type, counts[input.type]++);
|
||||||
|
labelByNodeId.set(input.nodeId, label);
|
||||||
|
if (input.type === "text") textBlocks.push(`【${label}】\n${input.text || ""}`);
|
||||||
|
else selectedInputs.push(input);
|
||||||
|
}
|
||||||
|
nextPrompt += input.type === "text" ? `【${label}】` : label;
|
||||||
|
}
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPrompt += prompt.slice(lastIndex);
|
||||||
|
if (textBlocks.length) nextPrompt = `${nextPrompt.trim()}\n\n${textBlocks.join("\n\n")}`;
|
||||||
|
const referenceImages = selectedInputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
|
||||||
|
const referenceVideos = selectedInputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
|
||||||
|
const referenceAudios = selectedInputs.map((input) => input.audio).filter((audio): audio is ReferenceAudio => Boolean(audio));
|
||||||
|
|
||||||
|
if (!hasToken) {
|
||||||
|
return {
|
||||||
|
prompt,
|
||||||
|
referenceImages: [],
|
||||||
|
referenceVideos: [],
|
||||||
|
referenceAudios: [],
|
||||||
|
textCount: 0,
|
||||||
|
imageCount: 0,
|
||||||
|
videoCount: 0,
|
||||||
|
audioCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt: nextPrompt,
|
||||||
|
referenceImages,
|
||||||
|
referenceVideos,
|
||||||
|
referenceAudios,
|
||||||
|
textCount: counts.text,
|
||||||
|
imageCount: referenceImages.length,
|
||||||
|
videoCount: referenceVideos.length,
|
||||||
|
audioCount: referenceAudios.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
|
export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
|
||||||
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
|
return getGenerationResourceNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
|
||||||
const image = readReferenceImage(node);
|
const image = readReferenceImage(node);
|
||||||
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
|
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
|
||||||
const video = readReferenceVideo(node);
|
const video = readReferenceVideo(node);
|
||||||
@@ -83,6 +150,13 @@ function readNodeTextInput(node: CanvasNodeData) {
|
|||||||
return node.metadata?.prompt || "";
|
return node.metadata?.prompt || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generationLabel(type: NodeGenerationInput["type"], index: number) {
|
||||||
|
if (type === "image") return imageReferenceLabel(index);
|
||||||
|
if (type === "video") return seedanceReferenceLabel("video", index);
|
||||||
|
if (type === "audio") return seedanceReferenceLabel("audio", index);
|
||||||
|
return `文本${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
|
function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
|
||||||
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return null;
|
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return null;
|
||||||
return {
|
return {
|
||||||
@@ -120,13 +194,3 @@ function readReferenceAudio(node: CanvasNodeData): ReferenceAudio | null {
|
|||||||
durationMs: node.metadata.durationMs,
|
durationMs: node.metadata.durationMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
|
||||||
const target = nodes.find((node) => node.id === nodeId);
|
|
||||||
const upstreamNodes = connections
|
|
||||||
.filter((connection) => connection.toNodeId === nodeId)
|
|
||||||
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
|
|
||||||
.filter((node): node is CanvasNodeData => Boolean(node));
|
|
||||||
const order = target?.metadata?.inputOrder || [];
|
|
||||||
return [...order.map((id) => upstreamNodes.find((node) => node.id === id)).filter((node): node is CanvasNodeData => Boolean(node)), ...upstreamNodes.filter((node) => !order.includes(node.id))];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { Modal, Segmented, Tooltip } from "antd";
|
import { App, Modal, Segmented, Tooltip } from "antd";
|
||||||
import { Camera, Download, FolderPlus, Image as ImageIcon, Info, Lock, LockOpen, Maximize2, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Scissors, Settings2, Trash2, Upload, Video } from "lucide-react";
|
import { Download, Ellipsis, FolderPlus, Image as ImageIcon, Info, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Settings2, Trash2, Upload, Video } from "lucide-react";
|
||||||
|
|
||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||||
|
import { useCopyText } from "@/hooks/use-copy-text";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
|
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
|
||||||
|
import { ImageToolSettingsModal, type ImageToolbarSettingsTool } from "./canvas-image-toolbar-settings-modal";
|
||||||
|
import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools";
|
||||||
|
|
||||||
type CanvasNodeHoverToolbarProps = {
|
type CanvasNodeHoverToolbarProps = {
|
||||||
node: CanvasNodeData | null;
|
node: CanvasNodeData | null;
|
||||||
@@ -23,7 +26,10 @@ type CanvasNodeHoverToolbarProps = {
|
|||||||
onUpload: (node: CanvasNodeData) => void;
|
onUpload: (node: CanvasNodeData) => void;
|
||||||
onDownload: (node: CanvasNodeData) => void;
|
onDownload: (node: CanvasNodeData) => void;
|
||||||
onSaveAsset: (node: CanvasNodeData) => void;
|
onSaveAsset: (node: CanvasNodeData) => void;
|
||||||
|
onMaskEdit: (node: CanvasNodeData) => void;
|
||||||
onCrop: (node: CanvasNodeData) => void;
|
onCrop: (node: CanvasNodeData) => void;
|
||||||
|
onUpscale: (node: CanvasNodeData) => void;
|
||||||
|
onSuperResolve: (node: CanvasNodeData) => void;
|
||||||
onAngle: (node: CanvasNodeData) => void;
|
onAngle: (node: CanvasNodeData) => void;
|
||||||
onViewImage: (node: CanvasNodeData) => void;
|
onViewImage: (node: CanvasNodeData) => void;
|
||||||
onRetry: (node: CanvasNodeData) => void;
|
onRetry: (node: CanvasNodeData) => void;
|
||||||
@@ -31,6 +37,16 @@ type CanvasNodeHoverToolbarProps = {
|
|||||||
onDelete: (node: CanvasNodeData) => void;
|
onDelete: (node: CanvasNodeData) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ToolbarTool = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function CanvasNodeHoverToolbar({
|
export function CanvasNodeHoverToolbar({
|
||||||
node,
|
node,
|
||||||
viewport,
|
viewport,
|
||||||
@@ -45,13 +61,41 @@ export function CanvasNodeHoverToolbar({
|
|||||||
onUpload,
|
onUpload,
|
||||||
onDownload,
|
onDownload,
|
||||||
onSaveAsset,
|
onSaveAsset,
|
||||||
|
onMaskEdit,
|
||||||
onCrop,
|
onCrop,
|
||||||
|
onUpscale,
|
||||||
|
onSuperResolve,
|
||||||
onAngle,
|
onAngle,
|
||||||
onViewImage,
|
onViewImage,
|
||||||
onRetry,
|
onRetry,
|
||||||
onToggleFreeResize,
|
onToggleFreeResize,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: CanvasNodeHoverToolbarProps) {
|
}: CanvasNodeHoverToolbarProps) {
|
||||||
|
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
|
||||||
|
const [showImageToolLabels, setShowImageToolLabels] = useState(true);
|
||||||
|
const [draftImageToolIds, setDraftImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
|
||||||
|
const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(true);
|
||||||
|
const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false);
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const copyText = useCopyText();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(IMAGE_QUICK_TOOLS_STORAGE_KEY);
|
||||||
|
if (!stored) return;
|
||||||
|
const parsed = JSON.parse(stored) as unknown;
|
||||||
|
const config = readImageQuickToolsConfig(parsed);
|
||||||
|
setQuickImageToolIds(config.ids);
|
||||||
|
setShowImageToolLabels(config.showLabels);
|
||||||
|
} catch {
|
||||||
|
window.localStorage.removeItem(IMAGE_QUICK_TOOLS_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImageToolSettingsOpen(false);
|
||||||
|
}, [node?.id]);
|
||||||
|
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
|
||||||
const left = viewport.x + (node.position.x + node.width / 2) * viewport.k;
|
const left = viewport.x + (node.position.x + node.width / 2) * viewport.k;
|
||||||
@@ -66,45 +110,98 @@ export function CanvasNodeHoverToolbar({
|
|||||||
const isConfig = node.type === CanvasNodeType.Config;
|
const isConfig = node.type === CanvasNodeType.Config;
|
||||||
const canOpenDialog = isText || hasImage || isVideo;
|
const canOpenDialog = isText || hasImage || isVideo;
|
||||||
const canRetry = node.metadata?.status === "error";
|
const canRetry = node.metadata?.status === "error";
|
||||||
const hasSpecificTools = canRetry || isText || isImage || isVideo || isAudio || isConfig;
|
const quickImageToolIdSet = new Set(quickImageToolIds);
|
||||||
|
const copyImagePrompt = (target: CanvasNodeData) => {
|
||||||
|
const prompt = target.metadata?.prompt?.trim();
|
||||||
|
if (!prompt) {
|
||||||
|
message.warning("暂无可复制的提示词");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyText(prompt, "提示词已复制");
|
||||||
|
};
|
||||||
|
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onMaskEdit, onCrop, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt });
|
||||||
|
|
||||||
|
function openImageToolSettings() {
|
||||||
|
onKeep(node.id);
|
||||||
|
setDraftImageToolIds(quickImageToolIds);
|
||||||
|
setDraftShowImageToolLabels(showImageToolLabels);
|
||||||
|
setImageToolSettingsOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseToolbarTools: ToolbarTool[] = [
|
||||||
|
{ id: "info", title: "查看节点信息", label: "信息", icon: <Info className="size-4" />, onClick: () => onInfo(node) },
|
||||||
|
{ id: "delete", title: "移除节点", label: "删除", icon: <Trash2 className="size-4" />, onClick: () => onDelete(node), danger: true },
|
||||||
|
];
|
||||||
|
const nodeToolbarTools: ToolbarTool[] = [
|
||||||
|
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
|
||||||
|
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的素材", label: "存素材", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
|
||||||
|
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
|
||||||
|
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||||
|
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
|
||||||
|
...(isText ? [{ id: "generateImage", title: "用文本生图", label: "生图", icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []),
|
||||||
|
...(isConfig ? [{ id: "config", title: "生成配置", label: "生成配置", icon: <Settings2 className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||||
|
...(isText ? [{ id: "decreaseFont", title: "减小字号", label: "缩小", icon: <Minus className="size-4" />, onClick: () => onDecreaseFont(node) }] : []),
|
||||||
|
...(isText ? [{ id: "increaseFont", title: "增大字号", label: "放大", icon: <Plus className="size-4" />, onClick: () => onIncreaseFont(node) }] : []),
|
||||||
|
...(isImage && !hasImage ? [{ id: "uploadImage", title: "上传图片", label: "上传图片", icon: <Upload className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||||
|
...(isVideo ? [{ id: "uploadVideo", title: hasVideo ? "替换视频" : "上传视频", label: hasVideo ? "替换视频" : "上传视频", icon: <Video className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||||
|
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||||
|
...(hasImage ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
|
||||||
|
];
|
||||||
|
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools];
|
||||||
|
const selectableImageToolbarTools = [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => tool.id !== "retry") as ImageToolbarSettingsTool[];
|
||||||
|
|
||||||
|
const closeImageToolSettings = () => {
|
||||||
|
setImageToolSettingsOpen(false);
|
||||||
|
onLeave();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDraftImageToolVisible = (id: ImageQuickToolId, visible: boolean) => {
|
||||||
|
setDraftImageToolIds((current) => {
|
||||||
|
const selected = new Set(current);
|
||||||
|
if (visible) selected.add(id);
|
||||||
|
else selected.delete(id);
|
||||||
|
return selectableImageToolbarTools.filter((tool) => selected.has(tool.id)).map((tool) => tool.id);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveImageToolSettings = () => {
|
||||||
|
const config = { ids: draftImageToolIds, showLabels: draftShowImageToolLabels };
|
||||||
|
setQuickImageToolIds(config.ids);
|
||||||
|
setShowImageToolLabels(config.showLabels);
|
||||||
|
window.localStorage.setItem(IMAGE_QUICK_TOOLS_STORAGE_KEY, JSON.stringify(config));
|
||||||
|
closeImageToolSettings();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]"
|
<div
|
||||||
style={{ left, top }}
|
className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]"
|
||||||
onMouseEnter={() => onKeep(node.id)}
|
style={{ left, top }}
|
||||||
onMouseLeave={onLeave}
|
onMouseEnter={() => onKeep(node.id)}
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
onMouseLeave={() => {
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
if (!imageToolSettingsOpen) onLeave();
|
||||||
>
|
}}
|
||||||
<ToolbarAction title="查看节点信息" label="信息" icon={<Info className="size-4" />} onClick={() => onInfo(node)} />
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
<ToolbarAction title="移除节点" label="删除" icon={<Trash2 className="size-4" />} onClick={() => onDelete(node)} danger />
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
{hasSpecificTools ? <ToolbarDivider /> : null}
|
>
|
||||||
{canRetry ? <ToolbarAction title="重新生成" label="重试" icon={<RefreshCw className="size-4" />} onClick={() => onRetry(node)} /> : null}
|
{toolbarTools.map((tool) => (
|
||||||
{hasImage || hasVideo || isText ? <ToolbarAction title="加入我的素材" label="存素材" icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(node)} /> : null}
|
<ToolbarAction key={tool.id} {...tool} showLabel={showImageToolLabels} />
|
||||||
{hasImage || hasVideo || hasAudio ? <IconAction title={hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片"} icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null}
|
))}
|
||||||
{canOpenDialog ? <ToolbarAction title="编辑" label="编辑" icon={<MessageSquare className="size-4" />} onClick={() => onToggleDialog(node)} /> : null}
|
{hasImage ? <ToolbarAction id="more" title="配置快捷工具" label="更多" icon={<Ellipsis className="size-4" />} active={imageToolSettingsOpen} onClick={openImageToolSettings} showLabel={showImageToolLabels} /> : null}
|
||||||
{isText ? <ToolbarAction title="编辑文本" label="编辑文字" icon={<Pencil className="size-4" />} onClick={() => onEditText(node)} /> : null}
|
</div>
|
||||||
{isText ? <ToolbarAction title="用文本生图" label="生图" icon={<ImageIcon className="size-4" />} onClick={() => onGenerateImage(node)} /> : null}
|
|
||||||
{isConfig ? <ToolbarAction title="生成配置" label="生成配置" icon={<Settings2 className="size-4" />} onClick={() => onInfo(node)} /> : null}
|
|
||||||
{isText ? <ToolbarAction title="减小字号" label="缩小" icon={<Minus className="size-4" />} onClick={() => onDecreaseFont(node)} /> : null}
|
|
||||||
{isText ? <ToolbarAction title="增大字号" label="放大" icon={<Plus className="size-4" />} onClick={() => onIncreaseFont(node)} /> : null}
|
|
||||||
{isImage ? <ToolbarAction title={hasImage ? "替换图片" : "上传图片"} label={hasImage ? "替换图片" : "上传图片"} icon={<Upload className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
|
||||||
{isVideo ? <ToolbarAction title={hasVideo ? "替换视频" : "上传视频"} label={hasVideo ? "替换视频" : "上传视频"} icon={<Video className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
|
||||||
{isAudio ? <ToolbarAction title={hasAudio ? "替换音频" : "上传音频"} label={hasAudio ? "替换音频" : "上传音频"} icon={<Music2 className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
|
||||||
{hasImage ? (
|
{hasImage ? (
|
||||||
<ToolbarAction
|
<ImageToolSettingsModal
|
||||||
title={node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"}
|
open={imageToolSettingsOpen}
|
||||||
label={node.metadata?.freeResize ? "自由比例" : "锁比例"}
|
tools={selectableImageToolbarTools}
|
||||||
icon={node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />}
|
selectedIds={draftImageToolIds}
|
||||||
onClick={() => onToggleFreeResize(node)}
|
showLabels={draftShowImageToolLabels}
|
||||||
active={node.metadata?.freeResize}
|
onToggle={setDraftImageToolVisible}
|
||||||
|
onShowLabelsChange={setDraftShowImageToolLabels}
|
||||||
|
onCancel={closeImageToolSettings}
|
||||||
|
onSave={saveImageToolSettings}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{hasImage ? <ToolbarAction title="裁剪并生成新节点" label="裁剪" icon={<Scissors className="size-4" />} onClick={() => onCrop(node)} /> : null}
|
</>
|
||||||
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null}
|
|
||||||
{hasImage ? <ToolbarAction title="查看图片详情" label="查看大图" icon={<Maximize2 className="size-4" />} onClick={() => onViewImage(node)} /> : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,34 +275,20 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolbarAction({ title, label, icon, onClick, hint, active = false, danger = false }: { title: string; label: string; icon: ReactNode; onClick?: () => void; hint?: string; active?: boolean; danger?: boolean }) {
|
function ToolbarAction({ title, label, icon, onClick, showLabel, active = false, danger = false }: ToolbarTool & { showLabel: boolean }) {
|
||||||
|
const hasText = showLabel && Boolean(label);
|
||||||
return (
|
return (
|
||||||
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
|
<Tooltip title={title} placement="top" mouseEnterDelay={0.2} color="#ffffff" styles={{ body: { color: "#242529", boxShadow: "0 8px 24px rgba(15,23,42,.16)", fontSize: 13, fontWeight: 500 } }}>
|
||||||
<button type="button" className={`group relative flex h-12 items-center whitespace-nowrap px-1.5 ${danger ? "text-[#ef4444]" : ""}`} onClick={onClick} aria-label={title}>
|
<button type="button" className={`group relative flex h-12 items-center whitespace-nowrap px-1.5 ${danger ? "text-[#ef4444]" : ""}`} onClick={onClick} aria-label={title}>
|
||||||
<span className={`flex h-9 items-center gap-2 rounded-lg px-2.5 transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
|
<span className={`flex h-9 items-center ${hasText ? "gap-2 px-2.5" : "justify-center px-2"} rounded-lg transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
|
||||||
{icon}
|
{icon}
|
||||||
<span>{label}</span>
|
{hasText ? <span>{label}</span> : null}
|
||||||
{hint ? <span className="text-[#a3a3a3]">{hint}</span> : null}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IconAction({ title, icon, onClick }: { title: string; icon: ReactNode; onClick: () => void }) {
|
|
||||||
return (
|
|
||||||
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
|
|
||||||
<button type="button" className="group relative grid h-12 w-12 place-items-center px-1.5" onClick={onClick} aria-label={title}>
|
|
||||||
<span className="grid size-9 place-items-center rounded-lg transition group-hover:bg-[#f0f0f1]">{icon}</span>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarDivider() {
|
|
||||||
return <span className="mx-1 h-7 w-px scale-x-50 bg-[#dedee2]" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: ReactNode }) {
|
function InfoRow({ label, value }: { label: string; value: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-3">
|
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
|
import { Button, Input, Modal, Slider } from "antd";
|
||||||
|
import { Brush, Eraser, RotateCcw, WandSparkles, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { readImageMeta } from "@/lib/image-utils";
|
||||||
|
|
||||||
|
export type CanvasImageMaskEditPayload = {
|
||||||
|
prompt: string;
|
||||||
|
maskDataUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DrawMode = "paint" | "erase";
|
||||||
|
|
||||||
|
const defaultBrushSize = 100;
|
||||||
|
const maskFillColor = "rgba(37, 99, 235, .38)";
|
||||||
|
const maskBorderColor = "rgba(255, 255, 255, .72)";
|
||||||
|
|
||||||
|
export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) {
|
||||||
|
const maskCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const drawingRef = useRef<{ active: boolean; last: { x: number; y: number } | null }>({ active: false, last: null });
|
||||||
|
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [brushSize, setBrushSize] = useState(defaultBrushSize);
|
||||||
|
const [mode, setMode] = useState<DrawMode>("paint");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setPrompt("");
|
||||||
|
setBrushSize(defaultBrushSize);
|
||||||
|
setMode("paint");
|
||||||
|
setError("");
|
||||||
|
void readImageMeta(dataUrl).then(setImage);
|
||||||
|
}, [dataUrl, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearCanvas(maskCanvasRef.current);
|
||||||
|
clearCanvas(previewCanvasRef.current);
|
||||||
|
}, [image]);
|
||||||
|
|
||||||
|
const draw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const point = readCanvasPoint(event.currentTarget, event.clientX, event.clientY);
|
||||||
|
const maskCanvas = maskCanvasRef.current;
|
||||||
|
const context = maskCanvas?.getContext("2d");
|
||||||
|
if (!context) return;
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.lineWidth = brushSize;
|
||||||
|
context.globalCompositeOperation = mode === "paint" ? "source-over" : "destination-out";
|
||||||
|
context.strokeStyle = "#000";
|
||||||
|
context.fillStyle = "#000";
|
||||||
|
if (!drawingRef.current.last) {
|
||||||
|
drawMaskStroke(context, point, point, brushSize);
|
||||||
|
} else {
|
||||||
|
drawMaskStroke(context, drawingRef.current.last, point, brushSize);
|
||||||
|
}
|
||||||
|
renderMaskPreview(maskCanvas, previewCanvasRef.current);
|
||||||
|
drawingRef.current.last = point;
|
||||||
|
if (mode === "paint") {
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
drawingRef.current = { active: true, last: null };
|
||||||
|
if (maskCanvasRef.current) renderMaskPreview(maskCanvasRef.current, previewCanvasRef.current);
|
||||||
|
draw(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!drawingRef.current.active) return;
|
||||||
|
event.preventDefault();
|
||||||
|
draw(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDraw = () => {
|
||||||
|
drawingRef.current = { active: false, last: null };
|
||||||
|
const maskCanvas = maskCanvasRef.current;
|
||||||
|
if (maskCanvas) renderMaskPreview(maskCanvas, previewCanvasRef.current, canvasHasPaint(maskCanvas));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetMask = () => {
|
||||||
|
clearCanvas(maskCanvasRef.current);
|
||||||
|
clearCanvas(previewCanvasRef.current);
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const nextPrompt = prompt.trim();
|
||||||
|
const canvas = maskCanvasRef.current;
|
||||||
|
if (!nextPrompt) return setError("请输入修改要求");
|
||||||
|
if (!canvas) return;
|
||||||
|
if (!canvasHasPaint(canvas)) return setError("请先涂抹局部区域");
|
||||||
|
onConfirm({ prompt: nextPrompt, maskDataUrl: buildEditMask(canvas) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={980} centered destroyOnHidden>
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]">
|
||||||
|
<div className="flex min-h-[360px] items-center justify-center rounded-xl border border-black/10 bg-transparent p-0 dark:border-white/10">
|
||||||
|
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
|
||||||
|
<img src={dataUrl} alt="" className="block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
|
||||||
|
{image ? (
|
||||||
|
<>
|
||||||
|
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
|
||||||
|
<canvas
|
||||||
|
ref={previewCanvasRef}
|
||||||
|
width={image.width}
|
||||||
|
height={image.height}
|
||||||
|
className="absolute inset-0 h-full w-full cursor-crosshair touch-none"
|
||||||
|
onPointerDown={startDraw}
|
||||||
|
onPointerMove={moveDraw}
|
||||||
|
onPointerUp={stopDraw}
|
||||||
|
onPointerCancel={stopDraw}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-[360px] flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">局部遮罩编辑</h2>
|
||||||
|
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button type={mode === "paint" ? "primary" : "default"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
|
||||||
|
画笔
|
||||||
|
</Button>
|
||||||
|
<Button type={mode === "erase" ? "primary" : "default"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
|
||||||
|
擦除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium opacity-75">笔刷大小</span>
|
||||||
|
<span className="font-semibold">{brushSize}px</span>
|
||||||
|
</div>
|
||||||
|
<Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium opacity-75">修改要求</div>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={6}
|
||||||
|
value={prompt}
|
||||||
|
status={error && !prompt.trim() ? "error" : undefined}
|
||||||
|
placeholder="例如:把选中区域改成金属材质,保持原图光影"
|
||||||
|
onChange={(event) => {
|
||||||
|
setPrompt(event.target.value);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{error ? <div className="text-xs font-medium text-[#ef4444]">{error}</div> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-center justify-between gap-2">
|
||||||
|
<Button icon={<RotateCcw className="size-4" />} onClick={resetMask}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button icon={<X className="size-4" />} onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<WandSparkles className="size-4" />} onClick={submit}>
|
||||||
|
AI 修改
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCanvasPoint(canvas: HTMLCanvasElement, clientX: number, clientY: number) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: ((clientX - rect.left) / Math.max(1, rect.width)) * canvas.width,
|
||||||
|
y: ((clientY - rect.top) / Math.max(1, rect.height)) * canvas.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCanvas(canvas: HTMLCanvasElement | null) {
|
||||||
|
const context = canvas?.getContext("2d");
|
||||||
|
if (!canvas || !context) return;
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMaskStroke(context: CanvasRenderingContext2D, from: { x: number; y: number }, to: { x: number; y: number }, size: number) {
|
||||||
|
if (from.x === to.x && from.y === to.y) {
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(to.x, to.y, size / 2, 0, Math.PI * 2);
|
||||||
|
context.fill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(from.x, from.y);
|
||||||
|
context.lineTo(to.x, to.y);
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasHasPaint(canvas: HTMLCanvasElement) {
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return false;
|
||||||
|
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||||
|
for (let index = 3; index < data.length; index += 4) {
|
||||||
|
if (data[index] > 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMaskPreview(maskCanvas: HTMLCanvasElement, previewCanvas: HTMLCanvasElement | null, withBorder = false) {
|
||||||
|
const context = previewCanvas?.getContext("2d");
|
||||||
|
if (!previewCanvas || !context) return;
|
||||||
|
context.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
|
||||||
|
context.fillStyle = maskFillColor;
|
||||||
|
context.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
|
||||||
|
context.globalCompositeOperation = "destination-in";
|
||||||
|
context.drawImage(maskCanvas, 0, 0);
|
||||||
|
context.globalCompositeOperation = "source-over";
|
||||||
|
if (withBorder) drawDashedMaskBorder(context, maskCanvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawDashedMaskBorder(context: CanvasRenderingContext2D, maskCanvas: HTMLCanvasElement) {
|
||||||
|
const maskContext = maskCanvas.getContext("2d");
|
||||||
|
if (!maskContext) return;
|
||||||
|
const { width, height } = maskCanvas;
|
||||||
|
const data = maskContext.getImageData(0, 0, width, height).data;
|
||||||
|
const step = Math.max(1, Math.round(Math.max(width, height) / 1200));
|
||||||
|
const dash = step * 8;
|
||||||
|
const gap = step * 5;
|
||||||
|
const period = dash + gap;
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.fillStyle = maskBorderColor;
|
||||||
|
context.shadowColor = "rgba(0, 0, 0, .24)";
|
||||||
|
context.shadowBlur = step * 1.5;
|
||||||
|
for (let y = step; y < height - step; y += step) {
|
||||||
|
for (let x = step; x < width - step; x += step) {
|
||||||
|
const offset = (y * width + x) * 4 + 3;
|
||||||
|
if (data[offset] === 0 || !isMaskEdge(data, width, x, y, step)) continue;
|
||||||
|
if ((x + y) % period > dash) continue;
|
||||||
|
context.fillRect(x - step / 2, y - step / 2, Math.max(1.5, step), Math.max(1.5, step));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMaskEdge(data: Uint8ClampedArray, width: number, x: number, y: number, step: number) {
|
||||||
|
return data[((y - step) * width + x) * 4 + 3] === 0 || data[((y + step) * width + x) * 4 + 3] === 0 || data[(y * width + x - step) * 4 + 3] === 0 || data[(y * width + x + step) * 4 + 3] === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEditMask(selectionCanvas: HTMLCanvasElement) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = selectionCanvas.width;
|
||||||
|
canvas.height = selectionCanvas.height;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return selectionCanvas.toDataURL("image/png");
|
||||||
|
const selectionContext = selectionCanvas.getContext("2d");
|
||||||
|
context.fillStyle = "#fff";
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
if (!selectionContext) return canvas.toDataURL("image/png");
|
||||||
|
const selection = selectionContext.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
const mask = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
for (let index = 3; index < mask.data.length; index += 4) {
|
||||||
|
if (selection.data[index] > 0) mask.data[index] = 0;
|
||||||
|
}
|
||||||
|
context.putImageData(mask, 0, 0);
|
||||||
|
return canvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
@@ -12,8 +12,10 @@ import { useThemeStore } from "@/stores/use-theme-store";
|
|||||||
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||||
import { CanvasPromptLibrary } from "./canvas-prompt-library";
|
import { CanvasPromptLibrary } from "./canvas-prompt-library";
|
||||||
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
||||||
|
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||||
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
||||||
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types";
|
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types";
|
||||||
|
import type { CanvasResourceReference } from "../utils/canvas-resource-references";
|
||||||
|
|
||||||
export type CanvasNodeGenerationMode = CanvasGenerationMode;
|
export type CanvasNodeGenerationMode = CanvasGenerationMode;
|
||||||
|
|
||||||
@@ -23,10 +25,11 @@ type CanvasNodePromptPanelProps = {
|
|||||||
onPromptChange: (nodeId: string, prompt: string) => void;
|
onPromptChange: (nodeId: string, prompt: string) => void;
|
||||||
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
|
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
|
||||||
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
|
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
|
||||||
|
mentionReferences?: CanvasResourceReference[];
|
||||||
onImageSettingsOpenChange?: (open: boolean) => void;
|
onImageSettingsOpenChange?: (open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
|
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
|
||||||
const globalConfig = useEffectiveConfig();
|
const globalConfig = useEffectiveConfig();
|
||||||
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
|
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
|
||||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||||
@@ -63,14 +66,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
|||||||
onPointerDown={(event) => event.stopPropagation()}
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
onWheel={(event) => event.stopPropagation()}
|
onWheel={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<textarea
|
<CanvasResourceMentionTextarea
|
||||||
value={prompt}
|
value={prompt}
|
||||||
onChange={(event) => updatePrompt(event.target.value)}
|
references={mentionReferences}
|
||||||
onKeyDown={(event) => {
|
onChange={updatePrompt}
|
||||||
if (event.key !== "Enter" || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
onSubmit={submit}
|
||||||
event.preventDefault();
|
|
||||||
submit();
|
|
||||||
}}
|
|
||||||
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
|
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
|
||||||
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
|
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||||
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
|
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button, Modal, Segmented } from "antd";
|
||||||
|
import { ImagePlus } from "lucide-react";
|
||||||
|
|
||||||
|
import { readImageMeta } from "@/lib/image-utils";
|
||||||
|
import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "../utils/canvas-image-data";
|
||||||
|
|
||||||
|
export type CanvasImageUpscaleParams = ImageUpscaleParams;
|
||||||
|
|
||||||
|
const algorithms: Array<{ value: ImageUpscaleAlgorithm; title: string; description: string }> = [
|
||||||
|
{ value: "high", title: "高清插值", description: "适合照片和细节图" },
|
||||||
|
{ value: "bilinear", title: "双线性", description: "平滑、速度快" },
|
||||||
|
{ value: "nearest", title: "最近邻", description: "适合像素风格" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const targetOptions = [
|
||||||
|
{ label: "1K", value: 1024 },
|
||||||
|
{ label: "2K", value: 2048 },
|
||||||
|
{ label: "4K", value: MAX_UPSCALE_LONG_EDGE },
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultParams: CanvasImageUpscaleParams = {
|
||||||
|
targetLongEdge: 2048,
|
||||||
|
algorithm: "high",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageUpscaleParams) => void }) {
|
||||||
|
const [params, setParams] = useState<CanvasImageUpscaleParams>(defaultParams);
|
||||||
|
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
|
||||||
|
const sourceLongEdge = image ? Math.max(image.width, image.height) : 0;
|
||||||
|
const outputSize = useMemo(() => (image ? resolveUpscaleSize(image.width, image.height, params.targetLongEdge) : null), [image, params.targetLongEdge]);
|
||||||
|
const canUpscale = Boolean(image && sourceLongEdge < params.targetLongEdge && params.targetLongEdge <= MAX_UPSCALE_LONG_EDGE);
|
||||||
|
const reachedMax = Boolean(image && sourceLongEdge >= MAX_UPSCALE_LONG_EDGE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setParams(defaultParams);
|
||||||
|
setImage(null);
|
||||||
|
}, [dataUrl, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
void readImageMeta(dataUrl).then(setImage);
|
||||||
|
}, [dataUrl, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!image) return;
|
||||||
|
const nextTarget = targetOptions.find((option) => sourceLongEdge < option.value)?.value || MAX_UPSCALE_LONG_EDGE;
|
||||||
|
setParams((current) => ({ ...current, targetLongEdge: nextTarget }));
|
||||||
|
}, [image, sourceLongEdge]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={820} centered destroyOnHidden>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">图片放大</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
|
||||||
|
<div className="rounded-xl border p-4">
|
||||||
|
<div className="grid min-h-[280px] place-items-center rounded-lg bg-black/5">
|
||||||
|
<img src={dataUrl} alt="" className="max-h-[320px] max-w-full rounded-lg object-contain shadow-xl" draggable={false} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between text-sm">
|
||||||
|
<span className="opacity-60">源图</span>
|
||||||
|
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium opacity-75">目标像素</div>
|
||||||
|
<Segmented
|
||||||
|
block
|
||||||
|
value={params.targetLongEdge}
|
||||||
|
options={targetOptions.map((option) => ({ label: `${option.label} · ${option.value}px`, value: option.value, disabled: Boolean(image && sourceLongEdge >= option.value) }))}
|
||||||
|
onChange={(value) => setParams((current) => ({ ...current, targetLongEdge: Number(value) }))}
|
||||||
|
/>
|
||||||
|
{image && !canUpscale ? <div className="text-xs font-medium text-[#ef4444]">{reachedMax ? "图片已达到 4K,无需放大" : "图片已达到当前目标像素,无需放大"}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium opacity-75">放大算法</div>
|
||||||
|
<Segmented
|
||||||
|
block
|
||||||
|
value={params.algorithm}
|
||||||
|
options={algorithms.map((item) => ({
|
||||||
|
value: item.value,
|
||||||
|
label: (
|
||||||
|
<span className="flex min-h-12 flex-col justify-center text-left leading-5">
|
||||||
|
<span className="font-medium">{item.title}</span>
|
||||||
|
<span className="text-xs opacity-55">{item.description}</span>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
onChange={(value) => setParams((current) => ({ ...current, algorithm: value as ImageUpscaleAlgorithm }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border px-4 py-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="opacity-60">输出尺寸</span>
|
||||||
|
<span className="font-semibold">{outputSize ? `${outputSize.width} x ${outputSize.height} px` : "未知"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="primary" size="large" icon={<ImagePlus className="size-4" />} disabled={!canUpscale} onClick={() => onConfirm(params)}>
|
||||||
|
生成放大图
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import { ChevronRight, Image as ImageIcon, Music2, RefreshCw, Star, Video } from
|
|||||||
import { canvasThemes } from "@/lib/canvas-theme";
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
import { formatBytes } from "@/lib/image-utils";
|
import { formatBytes } from "@/lib/image-utils";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
|
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||||
import { CanvasNodeType, type CanvasNodeData, type Position } from "../types";
|
import { CanvasNodeType, type CanvasNodeData, type Position } from "../types";
|
||||||
|
import type { CanvasResourceReference } from "../utils/canvas-resource-references";
|
||||||
|
|
||||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||||
const selectionBlue = "#2f80ff";
|
const selectionBlue = "#2f80ff";
|
||||||
@@ -23,6 +25,8 @@ type CanvasNodeProps = {
|
|||||||
editRequestNonce?: number;
|
editRequestNonce?: number;
|
||||||
showPanel: boolean;
|
showPanel: boolean;
|
||||||
showImageInfo: boolean;
|
showImageInfo: boolean;
|
||||||
|
resourceLabel?: CanvasResourceReference;
|
||||||
|
mentionReferences?: CanvasResourceReference[];
|
||||||
renderPanel?: (node: CanvasNodeData) => ReactNode;
|
renderPanel?: (node: CanvasNodeData) => ReactNode;
|
||||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||||
batchCount?: number;
|
batchCount?: number;
|
||||||
@@ -57,6 +61,7 @@ type NodeContentRendererProps = {
|
|||||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||||
onContentChange: (nodeId: string, content: string) => void;
|
onContentChange: (nodeId: string, content: string) => void;
|
||||||
onStopEditing: () => void;
|
onStopEditing: () => void;
|
||||||
|
mentionReferences: CanvasResourceReference[];
|
||||||
onRetry?: (node: CanvasNodeData) => void;
|
onRetry?: (node: CanvasNodeData) => void;
|
||||||
onGenerateImage?: (node: CanvasNodeData) => void;
|
onGenerateImage?: (node: CanvasNodeData) => void;
|
||||||
onToggleBatch?: () => void;
|
onToggleBatch?: () => void;
|
||||||
@@ -74,6 +79,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
editRequestNonce = 0,
|
editRequestNonce = 0,
|
||||||
showPanel,
|
showPanel,
|
||||||
showImageInfo,
|
showImageInfo,
|
||||||
|
resourceLabel,
|
||||||
|
mentionReferences = [],
|
||||||
renderPanel,
|
renderPanel,
|
||||||
renderNodeContent,
|
renderNodeContent,
|
||||||
batchCount = 0,
|
batchCount = 0,
|
||||||
@@ -291,6 +298,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
batchOpening={batchOpening}
|
batchOpening={batchOpening}
|
||||||
batchRecovering={batchRecovering}
|
batchRecovering={batchRecovering}
|
||||||
renderNodeContent={renderNodeContent}
|
renderNodeContent={renderNodeContent}
|
||||||
|
mentionReferences={mentionReferences}
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
onStopEditing={() => setIsEditingContent(false)}
|
onStopEditing={() => setIsEditingContent(false)}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
@@ -301,6 +309,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
|
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
|
||||||
|
{resourceLabel ? <ResourceLabelBadge reference={resourceLabel} /> : null}
|
||||||
|
|
||||||
{!hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
|
{!hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
|
||||||
|
|
||||||
@@ -313,7 +322,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
|||||||
<ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} />
|
<ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} />
|
||||||
<ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} />
|
<ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} />
|
||||||
|
|
||||||
{showPanel && renderPanel && data.type !== CanvasNodeType.Config ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
{showPanel && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -366,7 +375,7 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextContent({ node, theme, isEditingContent, textareaRef, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
|
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden pt-8">
|
<div className="flex h-full w-full flex-col overflow-hidden pt-8">
|
||||||
<button
|
<button
|
||||||
@@ -386,12 +395,13 @@ function TextContent({ node, theme, isEditingContent, textareaRef, onContentChan
|
|||||||
生图
|
生图
|
||||||
</button>
|
</button>
|
||||||
{isEditingContent ? (
|
{isEditingContent ? (
|
||||||
<textarea
|
<CanvasResourceMentionTextarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
className="thin-scrollbar block h-full w-full resize-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent pl-4 pr-14 pt-0 pb-4 m-0 font-mono leading-relaxed outline-none select-text appearance-none"
|
className="thin-scrollbar block h-full w-full resize-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent pl-4 pr-14 pt-0 pb-4 m-0 font-mono leading-relaxed outline-none select-text appearance-none"
|
||||||
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
|
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
|
||||||
value={node.metadata?.content || ""}
|
value={node.metadata?.content || ""}
|
||||||
onChange={(event) => onContentChange(node.id, event.target.value)}
|
references={mentionReferences}
|
||||||
|
onChange={(value) => onContentChange(node.id, value)}
|
||||||
onBlur={onStopEditing}
|
onBlur={onStopEditing}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Escape") onStopEditing();
|
if (event.key === "Escape") onStopEditing();
|
||||||
@@ -413,6 +423,14 @@ function TextContent({ node, theme, isEditingContent, textareaRef, onContentChan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ResourceLabelBadge({ reference }: { reference: CanvasResourceReference }) {
|
||||||
|
return (
|
||||||
|
<span className={`pointer-events-none absolute right-2 top-2 z-30 rounded-md px-1.5 py-0.5 text-[10px] font-medium ${reference.active ? "bg-[#2f80ff] text-white shadow-sm" : "bg-black/35 text-white/75"}`}>
|
||||||
|
{reference.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ImageNodeContent(props: NodeContentRendererProps) {
|
function ImageNodeContent(props: NodeContentRendererProps) {
|
||||||
if (!props.node.metadata?.content && props.isBatchRoot) {
|
if (!props.node.metadata?.content && props.isBatchRoot) {
|
||||||
const content =
|
const content =
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { forwardRef, useMemo, useRef, useState } from "react";
|
||||||
|
import type { CSSProperties, MouseEvent, PointerEvent, TextareaHTMLAttributes } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { FileText, Image as ImageIcon, Music2, Video } from "lucide-react";
|
||||||
|
|
||||||
|
import { canvasThemes } from "@/lib/canvas-theme";
|
||||||
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
|
import type { CanvasResourceReference } from "../utils/canvas-resource-references";
|
||||||
|
|
||||||
|
type MentionState = {
|
||||||
|
start: number;
|
||||||
|
query: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "value"> & {
|
||||||
|
value: string;
|
||||||
|
references: CanvasResourceReference[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
containerClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Props>(function CanvasResourceMentionTextarea({ value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, ...props }, forwardedRef) {
|
||||||
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [mention, setMention] = useState<MentionState | null>(null);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const candidates = useMemo(() => {
|
||||||
|
if (!mention) return [];
|
||||||
|
const query = mention.query.trim().toLowerCase();
|
||||||
|
const activeReferences = references.filter((item) => item.active);
|
||||||
|
if (!query) return activeReferences;
|
||||||
|
return activeReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query));
|
||||||
|
}, [mention, references]);
|
||||||
|
const activeLabels = useMemo(() => Array.from(new Set(references.filter((item) => item.active).map((item) => item.label))).sort((a, b) => b.length - a.length), [references]);
|
||||||
|
|
||||||
|
const updateValue = (next: string, selectionStart?: number) => {
|
||||||
|
onChange(next);
|
||||||
|
if (typeof selectionStart !== "number") return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
textareaRef.current?.setSelectionRange(selectionStart, selectionStart);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMention = () => {
|
||||||
|
setMention(null);
|
||||||
|
setActiveIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncMention = (nextValue: string, cursor: number) => {
|
||||||
|
const prefix = nextValue.slice(0, cursor);
|
||||||
|
const match = /(^|\s)@([^\s@]*)$/.exec(prefix);
|
||||||
|
if (!match || !references.some((item) => item.active)) {
|
||||||
|
closeMention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMention({ start: cursor - match[2].length - 1, query: match[2] });
|
||||||
|
setActiveIndex(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertReference = (reference: CanvasResourceReference) => {
|
||||||
|
if (!mention) return;
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
const end = textarea?.selectionStart ?? value.length;
|
||||||
|
const insertText = `${reference.label} `;
|
||||||
|
const next = `${value.slice(0, mention.start)}${insertText}${value.slice(end)}`;
|
||||||
|
closeMention();
|
||||||
|
updateValue(next, mention.start + insertText.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncOverlayScroll = () => {
|
||||||
|
if (!overlayRef.current || !textareaRef.current) return;
|
||||||
|
overlayRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||||
|
overlayRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergedStyle = {
|
||||||
|
...(style || {}),
|
||||||
|
color: activeLabels.length ? "transparent" : style?.color,
|
||||||
|
caretColor: style?.color || theme.node.text,
|
||||||
|
...(activeLabels.length ? { background: "transparent", backgroundColor: "transparent" } : {}),
|
||||||
|
} as CSSProperties;
|
||||||
|
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative h-full w-full ${containerClassName || ""}`}>
|
||||||
|
{activeLabels.length ? (
|
||||||
|
<div ref={overlayRef} className={`${className || ""} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`} style={{ ...style, color: theme.node.text }}>
|
||||||
|
<MentionHighlightText value={value || props.placeholder?.toString() || ""} labels={activeLabels} placeholder={!value} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<textarea
|
||||||
|
{...props}
|
||||||
|
ref={(node) => {
|
||||||
|
textareaRef.current = node;
|
||||||
|
if (typeof forwardedRef === "function") forwardedRef(node);
|
||||||
|
else if (forwardedRef) forwardedRef.current = node;
|
||||||
|
}}
|
||||||
|
value={value}
|
||||||
|
className={className}
|
||||||
|
style={mergedStyle}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value;
|
||||||
|
onChange(next);
|
||||||
|
syncMention(next, event.target.selectionStart);
|
||||||
|
requestAnimationFrame(syncOverlayScroll);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (mention && candidates.length) {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((index) => (index + 1) % candidates.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeMention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && onSubmit && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onKeyDown?.(event);
|
||||||
|
}}
|
||||||
|
onScroll={(event) => {
|
||||||
|
syncOverlayScroll();
|
||||||
|
props.onScroll?.(event);
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
window.setTimeout(closeMention, 120);
|
||||||
|
props.onBlur?.(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{menu}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function MentionHighlightText({ value, labels, placeholder }: { value: string; labels: string[]; placeholder: boolean }) {
|
||||||
|
if (placeholder) return <span className="opacity-45">{value}</span>;
|
||||||
|
if (!labels.length) return <>{value}</>;
|
||||||
|
const pattern = new RegExp(`(${labels.map(escapeRegExp).join("|")})`, "g");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{value.split(pattern).map((part, index) =>
|
||||||
|
labels.includes(part) ? (
|
||||||
|
<span key={`${part}-${index}`} className="rounded-md bg-[#2f80ff]/16 px-1 py-0.5 font-medium text-[#2f80ff] ring-1 ring-[#2f80ff]/24">
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={`${part}-${index}`}>{part}</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MentionMenu({ textarea, references, activeIndex, theme, onSelect }: { textarea: HTMLTextAreaElement; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
|
||||||
|
const selectedRef = useRef(false);
|
||||||
|
const rect = textarea.getBoundingClientRect();
|
||||||
|
const boundary = textarea.closest(".ant-modal-content")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 };
|
||||||
|
const menuWidth = 256;
|
||||||
|
const maxMenuHeight = 224;
|
||||||
|
const gap = 6;
|
||||||
|
const left = clamp(rect.left, boundary.left + 8, boundary.right - menuWidth - 8);
|
||||||
|
const showAbove = rect.bottom + gap + maxMenuHeight > boundary.bottom && rect.top - gap - maxMenuHeight >= boundary.top;
|
||||||
|
const top = clamp(showAbove ? rect.top - gap - maxMenuHeight : rect.bottom + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
|
||||||
|
|
||||||
|
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
const selectReference = (reference: CanvasResourceReference) => {
|
||||||
|
if (selectedRef.current) return;
|
||||||
|
selectedRef.current = true;
|
||||||
|
onSelect(reference);
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
data-canvas-resource-mention-menu="true"
|
||||||
|
className="fixed z-[120] max-h-56 w-64 overflow-y-auto rounded-xl border p-1 shadow-2xl backdrop-blur-md"
|
||||||
|
style={{ left, top, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||||
|
onPointerDown={stopCanvasInteraction}
|
||||||
|
onMouseDown={stopCanvasInteraction}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{references.map((reference, index) => (
|
||||||
|
<button
|
||||||
|
key={reference.id}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition"
|
||||||
|
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
selectReference(reference);
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
selectReference(reference);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReferencePreview reference={reference} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block font-medium">{reference.label}</span>
|
||||||
|
<span className="block truncate opacity-65">{reference.text || reference.title}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
|
||||||
|
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-9 rounded-md object-cover" />;
|
||||||
|
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
|
||||||
|
const Icon = reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
|
||||||
|
return (
|
||||||
|
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
if (max < min) return min;
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export type CanvasImageGenerationType = "generation" | "edit";
|
|||||||
|
|
||||||
export type CanvasNodeMetadata = {
|
export type CanvasNodeMetadata = {
|
||||||
content?: string;
|
content?: string;
|
||||||
|
composerContent?: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
status?: CanvasNodeStatus;
|
status?: CanvasNodeStatus;
|
||||||
errorDetails?: string;
|
errorDetails?: string;
|
||||||
@@ -51,7 +52,6 @@ export type CanvasNodeMetadata = {
|
|||||||
batchUsesReferenceImages?: boolean;
|
batchUsesReferenceImages?: boolean;
|
||||||
primaryImageId?: string;
|
primaryImageId?: string;
|
||||||
imageBatchExpanded?: boolean;
|
imageBatchExpanded?: boolean;
|
||||||
inputOrder?: string[];
|
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
bytes?: number;
|
bytes?: number;
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ export type ImageAngleTransform = {
|
|||||||
wideAngle: boolean;
|
wideAngle: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ImageUpscaleAlgorithm = "nearest" | "bilinear" | "high";
|
||||||
|
|
||||||
|
export const MAX_UPSCALE_LONG_EDGE = 4096;
|
||||||
|
|
||||||
|
export type ImageUpscaleParams = {
|
||||||
|
targetLongEdge: number;
|
||||||
|
algorithm: ImageUpscaleAlgorithm;
|
||||||
|
};
|
||||||
|
|
||||||
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
|
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
|
||||||
const image = await loadImage(dataUrl);
|
const image = await loadImage(dataUrl);
|
||||||
if (crop) {
|
if (crop) {
|
||||||
@@ -65,6 +74,19 @@ export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleT
|
|||||||
return canvas.toDataURL("image/png");
|
return canvas.toDataURL("image/png");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function upscaleDataUrl(dataUrl: string, params: ImageUpscaleParams) {
|
||||||
|
const image = await loadImage(dataUrl);
|
||||||
|
const { width, height } = resolveUpscaleSize(image.width, image.height, params.targetLongEdge);
|
||||||
|
return params.algorithm === "high" ? drawStepUpscale(image, width, height) : drawResize(image, image.width, image.height, width, height, params.algorithm);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUpscaleSize(width: number, height: number, targetLongEdge: number) {
|
||||||
|
const longEdge = Math.max(1, width, height);
|
||||||
|
const target = Math.min(MAX_UPSCALE_LONG_EDGE, Math.max(1, Math.round(targetLongEdge)));
|
||||||
|
const scale = target / longEdge;
|
||||||
|
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
|
||||||
|
}
|
||||||
|
|
||||||
function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) {
|
function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = Math.max(1, sw);
|
canvas.width = Math.max(1, sw);
|
||||||
@@ -75,6 +97,39 @@ function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, s
|
|||||||
return canvas.toDataURL("image/png");
|
return canvas.toDataURL("image/png");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawStepUpscale(image: HTMLImageElement, width: number, height: number) {
|
||||||
|
let source: CanvasImageSource = image;
|
||||||
|
let sourceWidth = image.width;
|
||||||
|
let sourceHeight = image.height;
|
||||||
|
|
||||||
|
while (sourceWidth * 2 < width && sourceHeight * 2 < height) {
|
||||||
|
const nextWidth = sourceWidth * 2;
|
||||||
|
const nextHeight = sourceHeight * 2;
|
||||||
|
const next = drawResizeCanvas(source, sourceWidth, sourceHeight, nextWidth, nextHeight, "high");
|
||||||
|
source = next;
|
||||||
|
sourceWidth = nextWidth;
|
||||||
|
sourceHeight = nextHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return drawResize(source, sourceWidth, sourceHeight, width, height, "high");
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawResize(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
|
||||||
|
return drawResizeCanvas(source, sourceWidth, sourceHeight, width, height, algorithm).toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawResizeCanvas(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return canvas;
|
||||||
|
context.imageSmoothingEnabled = algorithm !== "nearest";
|
||||||
|
context.imageSmoothingQuality = algorithm === "bilinear" ? "medium" : "high";
|
||||||
|
context.drawImage(source, 0, 0, sourceWidth, sourceHeight, 0, 0, width, height);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
function loadImage(dataUrl: string) {
|
function loadImage(dataUrl: string) {
|
||||||
return new Promise<HTMLImageElement>((resolve) => {
|
return new Promise<HTMLImageElement>((resolve) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||||
|
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||||
|
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
|
||||||
|
|
||||||
|
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
|
||||||
|
|
||||||
|
export type CanvasResourceReference = {
|
||||||
|
id: string;
|
||||||
|
nodeId: string;
|
||||||
|
kind: CanvasResourceKind;
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
previewUrl?: string;
|
||||||
|
text?: string;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildCanvasResourceReferences(nodes: CanvasNodeData[], connections: CanvasConnection[], contextNodeId?: string | null) {
|
||||||
|
const contextNodes = contextNodeId ? getMentionResourceNodes(contextNodeId, nodes, connections) : [];
|
||||||
|
const globalReferences = labelResourceNodes(nodes.filter(isResourceNode), false);
|
||||||
|
const activeByNodeId = new Map(labelResourceNodes(contextNodes, true).map((reference) => [reference.nodeId, reference]));
|
||||||
|
return globalReferences.map((reference) => activeByNodeId.get(reference.nodeId) || reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||||
|
return labelResourceNodes(getMentionResourceNodes(node.id, nodes, connections), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||||
|
const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections);
|
||||||
|
if (configInputs.length) return configInputs;
|
||||||
|
const ownInputs = getContextResourceNodes(nodeId, nodes, connections);
|
||||||
|
if (ownInputs.length) return ownInputs;
|
||||||
|
const node = nodes.find((item) => item.id === nodeId);
|
||||||
|
return node && isResourceNode(node) ? [node] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGenerationResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||||
|
const configInputs = getConnectedConfigResourceNodes(nodeId, nodes, connections);
|
||||||
|
if (configInputs.length) return configInputs;
|
||||||
|
const ownInputs = getContextResourceNodes(nodeId, nodes, connections);
|
||||||
|
if (ownInputs.length) return ownInputs;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContextResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||||
|
return connections
|
||||||
|
.filter((connection) => connection.toNodeId === nodeId)
|
||||||
|
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
|
||||||
|
.filter((node): node is CanvasNodeData => Boolean(node && isResourceNode(node)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConnectedConfigResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||||
|
const configConnection = connections.find((connection) => connection.fromNodeId === nodeId && nodes.find((node) => node.id === connection.toNodeId)?.type === CanvasNodeType.Config);
|
||||||
|
if (!configConnection) return [];
|
||||||
|
return getContextResourceNodes(configConnection.toNodeId, nodes, connections).filter((node) => node.id !== nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelResourceNodes(nodes: CanvasNodeData[], active: boolean) {
|
||||||
|
const counts: Record<CanvasResourceKind, number> = { image: 0, video: 0, audio: 0, text: 0 };
|
||||||
|
return nodes.flatMap((node): CanvasResourceReference[] => {
|
||||||
|
const kind = resourceKind(node);
|
||||||
|
if (!kind) return [];
|
||||||
|
const index = counts[kind]++;
|
||||||
|
const label = labelForKind(kind, index);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: node.id,
|
||||||
|
nodeId: node.id,
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
title: node.title || label,
|
||||||
|
previewUrl: node.metadata?.content,
|
||||||
|
text: node.type === CanvasNodeType.Text ? node.metadata?.content || node.metadata?.prompt : undefined,
|
||||||
|
active,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelForKind(kind: CanvasResourceKind, index: number) {
|
||||||
|
if (kind === "image") return imageReferenceLabel(index);
|
||||||
|
if (kind === "video") return seedanceReferenceLabel("video", index);
|
||||||
|
if (kind === "audio") return seedanceReferenceLabel("audio", index);
|
||||||
|
return `文本${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isResourceNode(node: CanvasNodeData) {
|
||||||
|
return Boolean(resourceKind(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceKind(node: CanvasNodeData): CanvasResourceKind | null {
|
||||||
|
if (node.type === CanvasNodeType.Image && node.metadata?.content) return "image";
|
||||||
|
if (node.type === CanvasNodeType.Video && node.metadata?.content) return "video";
|
||||||
|
if (node.type === CanvasNodeType.Audio && node.metadata?.content) return "audio";
|
||||||
|
if (node.type === CanvasNodeType.Text && (node.metadata?.content || node.metadata?.prompt)) return "text";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -222,7 +222,7 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[]) {
|
export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[], mask?: ReferenceImage) {
|
||||||
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||||
const quality = normalizeQuality(config.quality);
|
const quality = normalizeQuality(config.quality);
|
||||||
const requestSize = resolveRequestSize(quality, config.size);
|
const requestSize = resolveRequestSize(quality, config.size);
|
||||||
@@ -241,6 +241,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
|||||||
}
|
}
|
||||||
const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
|
const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
|
||||||
files.forEach((file) => formData.append("image", file));
|
files.forEach((file) => formData.append("image", file));
|
||||||
|
if (mask) formData.set("mask", dataUrlToFile(mask));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<ImageApiResponse>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
|
const response = await axios.post<ImageApiResponse>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
|
||||||
|
|||||||
Reference in New Issue
Block a user