From 242860e2d34e263873492d944151910246cda4b8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 13 Jun 2026 22:09:45 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=9B=BE=E7=89=87=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E8=B7=AF=E7=94=B1=E5=92=8C=E7=94=BB=E5=B8=83=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将图片画布入口改为 /editor/canvas 新增 editor_canvas 表并关联 editor_project 默认画布 更新 project API 响应中的 canvas 快照兼容层 统一图片画布侧栏列表项和图标按钮组件 同步前端测试、SpacetimeDB bindings、技术文档和 TRACKING 记录 --- TRACKING.md | 19 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 17 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 9 +- .../crates/api-server/src/editor_project.rs | 35 +- server-rs/crates/spacetime-client/src/lib.rs | 6 +- .../crates/spacetime-client/src/mapper.rs | 4 +- .../src/mapper/editor_project.rs | 36 +- .../spacetime-client/src/module_bindings.rs | 36 +- .../editor_canvas_snapshot_type.rs | 23 + .../module_bindings/editor_canvas_table.rs | 159 ++++ .../src/module_bindings/editor_canvas_type.rs | 80 ++ .../editor_project_snapshot_type.rs | 5 +- .../src/editor_project_storage.rs | 144 ++- .../crates/spacetime-module/src/migration.rs | 1 + .../ImageCanvasEditorView.test.tsx | 139 ++- .../image-editor/ImageCanvasEditorView.tsx | 885 +++++++++++++----- src/index.css | 291 +++++- src/routing/appPageRoutes.test.ts | 10 +- src/routing/appPageRoutes.ts | 2 +- .../image-editor/editorProjectClient.test.ts | 32 + .../image-editor/editorProjectClient.ts | 11 + 21 files changed, 1649 insertions(+), 295 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_type.rs diff --git a/TRACKING.md b/TRACKING.md index 5aae7418..16907ae7 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -1,11 +1,11 @@ # 图片画布编辑器 Lovart 化执行跟踪 -更新时间:`2026-06-12` +更新时间:`2026-06-13` ## 目标 - 先落文档、测试用例和进度跟踪文件。 -- 再实施 `/editor` 的 Lovart 风格画布交互、缩放二级菜单、吸附线、元数据窗口、真实生成 / 修改入口和工程持久化。 +- 再实施 `/editor/canvas` 的 Lovart 风格画布交互、缩放二级菜单、吸附线、元数据窗口、真实生成 / 修改入口和工程 / 画布持久化。 ## 进度 @@ -20,10 +20,10 @@ ## 待办清单 - [x] 更新图片画布编辑器技术方案,明确 v2 范围。 -- [x] 补充 `/editor` 领域词,区分图片画布工程和单图资产编辑。 +- [x] 补充 `/editor/canvas` 领域词,区分图片画布工程和单图资产编辑。 - [x] 添加前端组件交互测试。 - [x] 添加 editor project API client 测试。 -- [x] 新增 `editor_project` 与 `editor_project_resource` 表。 +- [x] 新增 `editor_project`、`editor_canvas` 与 `editor_project_resource` 表。 - [x] 同步 SpacetimeDB migration 表清单、生成绑定和后端架构表目录。 - [x] 新增 api-server `/api/editor/projects*` BFF。 - [x] 接入前端自动保存与资源创建 API。 @@ -35,8 +35,8 @@ ## 决策记录 -- `/editor` 的长期领域对象命名为“图片画布工程”。 -- 资源表保存 OSS 引用和上传 / 生成元数据;图层位置、缩放、层级保存在 project 布局 JSON。 +- `/editor/canvas` 的长期领域对象命名为“图片画布工程的画布入口”。 +- project 保存工程元数据;canvas 保存 viewport 与图层布局;资源表保存 OSS 引用和上传 / 生成元数据。 - 本期工程与资源持久化真实落库;图片生成 / 修改已接入 api-server VectorEngine BFF,暂不接入计费和队列进度。 - `适合视图` 的语义为显示画布所有可见元素。 - 吸附范围为核心对齐:左右 / 上下边缘和水平 / 垂直中心线。 @@ -64,3 +64,10 @@ - 2026-06-13 真实生图修正:`/api/editor/images/generations` 和 `/api/editor/images/edits` 统一走 api-server VectorEngine `gpt-image-2` BFF;前端不再创建 mock 成功图,生成 / 修改失败会留在对话框内显示错误;生成图右上角 `{}` 元数据按钮可直接点击打开元数据窗口。 - 2026-06-13 素材库修正:素材栏按文件夹分组,文件夹支持折叠和新建;上传入口可定向到当前文件夹,上传素材进入素材库并支持删除,内置素材只保留添加和重命名。 - 2026-06-13 生图鉴权修正:编辑器工程和真实生图请求不再使用禁止 refresh 的局部鉴权策略,可通过 refresh cookie 静默补 access token;真实生图遇到 401 / 403 时弹窗显示“请先登录后再生成图片”,不再暴露后端 requestId 主文案。 +- 2026-06-13 Lovart 生图交互修正:纯文本生图不再使用居中弹窗,点击底部生成工具后在画布中心显示 `Image Generator` 占位框,并在占位框下方显示跟随式生成输入框、参考图入口、比例和模型占位按钮;生成成功后真实图片落在占位框位置。 +- 2026-06-13 Lovart 生图 smoke:`http://127.0.0.1:10003/editor` 点击底部生成工具后已显示画布内占位框和底部浮动输入框,截图留存于 `output/playwright/editor-lovart-generation-composer.png`。 +- 2026-06-13 生图占位交互修正:`Image Generator` 占位框支持拖拽移动,生成输入框跟随占位框;生成成功图层沿用拖拽后的占位位置,生成输入框继续锚定在新生成图片下方,点击其它图片或画布空白不会自动关闭。 +- 2026-06-13 生图锚定 smoke:`http://127.0.0.1:10003/editor` 中占位框拖拽前后坐标从 `(616,217)` 到 `(712,289)`,生成输入框同步从 `(516,571)` 到 `(612,643)`,保持锚定在生成对象下方;截图留存于 `output/playwright/editor-generation-composer-anchored.png`。 +- 2026-06-13 Lovart 小地图与背景色修正:画布左下角补回背景色圆点、小地图开关和小地图预览;小地图展示图层缩略分布与当前视口框,点击执行显示所有元素,背景色菜单可切换工作区底色且不恢复网格 / 棋盘底纹。 +- 2026-06-13 小地图与背景色 smoke:`http://127.0.0.1:10003/editor` 可见左下角小地图、背景色按钮和小地图开关;切换暖灰后画布工作区 `background-color` 为 `rgb(243, 240, 234)`,`background-image` 仍为 `none`;截图留存于 `output/playwright/editor-minimap-background-warm.png`。 +- 2026-06-13 路由与数据归属修正:图片画布页面路由改为 `/editor/canvas`;新增 `editor_canvas` 表作为 project 下的画布数据表,当前工程创建时同步创建默认画布,保存 layout 时写入默认画布,API 响应同时返回 `project.canvas` 和兼容顶层 `viewport/layers`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index f2348490..a5a5e550 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -2,37 +2,40 @@ ## 背景 -网站需要新增一个面向图片素材的独立 `/editor` 画布入口。第一阶段已经提供纯前端画布体验,本轮把它升级为 Lovart 风格的 AI 图片画布:支持更接近专业画布的拖拽、缩放、吸附、工具模式、元数据查看和图片修改结果并排展示,同时补上工程与资源持久化。 +网站需要新增一个面向图片素材的独立 `/editor/canvas` 画布入口。第一阶段已经提供纯前端画布体验,本轮把它升级为 Lovart 风格的 AI 图片画布:支持更接近专业画布的拖拽、缩放、吸附、工具模式、元数据查看和图片修改结果并排展示,同时补上工程、画布与资源持久化。 ## V2 边界 -- 主站新增 `/editor` 路由,进入独立图片画布编辑器阶段。 +- 主站新增 `/editor/canvas` 路由,进入独立图片画布编辑器阶段。 - 创作 Tab 顶部提供编辑器入口,入口只负责跳转,不参与玩法创作链路。 - 编辑器左侧为图片素材栏,可展开 / 收起;移动端优先保持素材栏可折叠。 - 中央画布支持背景拖拽平移、滚轮缩放、缩放百分比菜单、显示所有元素和固定比例缩放。 +- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。 - 画布中的图片可展示、悬浮显示图片尺寸与边框,点击后在图片上方显示浮动工具栏。 - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成、局部修改 / 蒙版、文字、形状 / 标注、导出。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。 - 对生成资源执行修改时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图。 -- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine `gpt-image-2`:纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits`。前端不持有 provider 密钥;上游失败或配置缺失时只在对话框展示失败,不创建 mock 成功图。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine `gpt-image-2`:纯文本生成走 `/api/editor/images/generations`,基于当前生成图的修改走 `/api/editor/images/edits`。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成工具后先在画布中心创建选中的灰色占位框,输入框跟随占位框显示;提交成功后真实生成图落在占位框位置,输入框继续跟随新生成图;基于已有生成图的修改仍通过轻量弹窗承载。前端不持有 provider 密钥;上游失败或配置缺失时只在当前生成输入框展示失败,不创建 mock 成功图。 ## 交互规则 - `适合视图` 的正式语义为“显示画布所有可见元素”,不再回到固定 `x/y/scale`。 - 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 50%、缩放至 100%、缩放至 200%。 - 缩放菜单支持 `Ctrl/Cmd +`、`Ctrl/Cmd -` 和 `Shift + 1`;快捷键只改变 viewport,不修改工程资源。 +- 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。 - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;拖拽结束后只保存最终图层布局,不保存临时参考线。 - 画布自动保存使用防抖策略:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照。 - 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。 ## 数据与持久化 -- 新增 `editor_project` 表保存图片画布工程:`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。 +- 新增 `editor_project` 表保存图片画布工程:`projectId`、`ownerUserId`、标题、创建时间和更新时间;历史 layout 字段暂保留为兼容列,不再作为权威画布数据。 +- 新增 `editor_canvas` 表保存工程下的画布:`canvasId`、`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。当前编辑器使用项目默认画布,后续可扩展为一个 project 下多个 canvas。 - 新增 `editor_project_resource` 表保存画布资源:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、创建时间和更新时间。 - 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。 -- 资源表只保存资源元数据;图层位置、尺寸、缩放、层级和选中所需 ID 保存在 `editor_project` 的布局 JSON。 +- 资源表只保存资源元数据;图层位置、尺寸、缩放、层级和选中所需 ID 保存在 `editor_canvas` 的布局 JSON。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 @@ -54,14 +57,16 @@ - 示例素材可继续复用 `public/creation-type-references/` 下的站内图片;用户上传和后续生成资源必须通过资源记录表达。 - 不把 hover、dragging、临时吸附线、Space 临时抓手等瞬时 UI 状态写入后端。 - 不在 UI 中加入大段功能说明,编辑器界面只展示必要的工具、素材和状态信息。 -- 不复用或改写 `CreativeImageInputPanel` 的单图资产编辑语义;`/editor` 是独立图片画布工程。 +- 不复用或改写 `CreativeImageInputPanel` 的单图资产编辑语义;`/editor/canvas` 是独立图片画布工程的画布入口。 ## 验收用例 - 缩放百分比按钮能打开 Lovart 风格菜单,菜单项能放大、缩小、显示所有元素和缩放到固定比例。 - `显示画布所有元素` 按可见图层外接矩形计算 viewport。 +- 左下角小地图可展示当前图层分布和视口范围,开关按钮可隐藏 / 恢复小地图,背景色菜单可切换白色、浅灰、暖灰和冷蓝工作区底色。 - 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。 - 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。 +- 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 - 生成资源显示元数据按钮,元数据窗口展示来源、prompt、model、provider、task、尺寸和 OSS 引用。 - 修改生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图。 - 工程刷新后能从后端恢复资源、图层布局和 viewport。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 98e4b55a..32b645f7 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -433,9 +433,16 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorProject` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布工程真相表,保存 owner、标题、viewport typed columns 与图层布局 JSON;只通过 `/api/editor/projects*` BFF 和 `spacetime-client` facade 读写。 +- 说明:图片画布工程真相表,保存 owner、标题和工程时间戳;viewport 与图层布局已拆到 `editor_canvas`,旧 layout columns 暂作为兼容列保留,不再作为权威数据源。只通过 `/api/editor/projects*` BFF 和 `spacetime-client` facade 读写。 - 索引:`by_editor_project_owner_user_id` 用于读取当前用户最近编辑工程。 +### `editor_canvas` + +- Rust 结构体:`EditorCanvas` +- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` +- 说明:图片画布数据表,归属于 `editor_project`,保存默认画布的 viewport typed columns、图层布局 JSON、owner 和时间戳;当前编辑器读取 / 保存 project 的默认 canvas,后续支持一个工程多个 canvas。 +- 索引:`by_editor_canvas_project_id`、`by_editor_canvas_owner_user_id`。 + ### `editor_project_resource` - Rust 结构体:`EditorProjectResource` diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 8d706ac2..2e533b2c 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ - EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectGetRecordInput, - EditorProjectLayoutSaveRecordInput, EditorProjectRecord, + EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, + EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, SpacetimeClientError, }; @@ -120,12 +120,25 @@ pub struct EditorImageGenerationResponse { pub struct EditorProjectPayload { project_id: String, title: String, + canvas: EditorCanvasPayload, viewport: EditorCanvasViewportPayload, layers: Value, resources: Vec, updated_at: String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorCanvasPayload { + canvas_id: String, + project_id: String, + title: String, + viewport: EditorCanvasViewportPayload, + layers: Value, + created_at: String, + updated_at: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectResourcePayload { @@ -417,9 +430,11 @@ pub async fn edit_editor_image( } fn editor_project_payload_from_record(record: EditorProjectRecord) -> EditorProjectPayload { + let canvas = editor_canvas_payload_from_record(record.canvas); EditorProjectPayload { project_id: record.project_id, title: record.title, + canvas, viewport: EditorCanvasViewportPayload { x: record.viewport.x, y: record.viewport.y, @@ -435,6 +450,22 @@ fn editor_project_payload_from_record(record: EditorProjectRecord) -> EditorProj } } +fn editor_canvas_payload_from_record(record: EditorCanvasRecord) -> EditorCanvasPayload { + EditorCanvasPayload { + canvas_id: record.canvas_id, + project_id: record.project_id, + title: record.title, + viewport: EditorCanvasViewportPayload { + x: record.viewport.x, + y: record.viewport.y, + scale: record.viewport.scale, + }, + layers: record.layers, + created_at: record.created_at, + updated_at: record.updated_at, + } +} + fn editor_project_resource_payload_from_record( record: EditorProjectResourceRecord, ) -> EditorProjectResourcePayload { diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index fc8fa653..d8b03f10 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -30,9 +30,9 @@ pub use mapper::{ CustomWorldPublishGateRecord, CustomWorldPublishWorldRecord, CustomWorldPublishWorldRecordInput, CustomWorldPublishedProfileCompileRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, - CustomWorldWorkSummaryRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, - EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, - EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, + CustomWorldWorkSummaryRecord, EditorCanvasRecord, EditorCanvasViewportRecord, + EditorProjectCreateRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, + EditorProjectRecord, EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index e87d41bb..79771c17 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -43,8 +43,8 @@ pub use self::combat::{ ResolveCombatActionRecord, }; pub use self::editor_project::{ - EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectGetRecordInput, - EditorProjectLayoutSaveRecordInput, EditorProjectRecord, + EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, + EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, }; pub use self::common::{ diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index 25ad47ea..8041ac87 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -12,6 +12,7 @@ pub struct EditorProjectRecord { pub project_id: String, pub owner_user_id: String, pub title: String, + pub canvas: EditorCanvasRecord, pub viewport: EditorCanvasViewportRecord, pub layers: serde_json::Value, pub resources: Vec, @@ -19,6 +20,17 @@ pub struct EditorProjectRecord { pub updated_at: String, } +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct EditorCanvasRecord { + pub canvas_id: String, + pub project_id: String, + pub title: String, + pub viewport: EditorCanvasViewportRecord, + pub layers: serde_json::Value, + pub created_at: String, + pub updated_at: String, +} + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct EditorProjectResourceRecord { pub resource_id: String, @@ -178,19 +190,31 @@ pub(crate) fn map_editor_project_resource_procedure_result( fn map_editor_project_snapshot( snapshot: EditorProjectSnapshot, ) -> Result { - let layers = serde_json::from_str(&snapshot.layers_json).map_err(|error| { + let layers: serde_json::Value = + serde_json::from_str(&snapshot.canvas.layers_json).map_err(|error| { SpacetimeClientError::validation_failed(format!("图片画布图层布局 JSON 无法解析:{error}")) })?; + let viewport = EditorCanvasViewportRecord { + x: snapshot.canvas.viewport.x, + y: snapshot.canvas.viewport.y, + scale: snapshot.canvas.viewport.scale, + }; + let canvas = EditorCanvasRecord { + canvas_id: snapshot.canvas.canvas_id, + project_id: snapshot.canvas.project_id, + title: snapshot.canvas.title, + viewport: viewport.clone(), + layers: layers.clone(), + created_at: format_timestamp_micros(snapshot.canvas.created_at_micros), + updated_at: format_timestamp_micros(snapshot.canvas.updated_at_micros), + }; Ok(EditorProjectRecord { project_id: snapshot.project_id, owner_user_id: snapshot.owner_user_id, title: snapshot.title, - viewport: EditorCanvasViewportRecord { - x: snapshot.viewport.x, - y: snapshot.viewport.y, - scale: snapshot.viewport.scale, - }, + canvas, + viewport, layers, resources: snapshot .resources diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index b5bfb97a..296decbb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -347,6 +347,9 @@ pub mod delete_visual_novel_work_procedure; pub mod delete_wooden_fish_work_procedure; pub mod drag_puzzle_piece_or_group_procedure; pub mod drop_square_hole_shape_procedure; +pub mod editor_canvas_snapshot_type; +pub mod editor_canvas_table; +pub mod editor_canvas_type; pub mod editor_project_create_input_type; pub mod editor_project_get_input_type; pub mod editor_project_get_recent_input_type; @@ -362,7 +365,6 @@ pub mod editor_project_table; pub mod editor_project_type; pub mod editor_project_viewport_snapshot_type; pub mod enqueue_external_generation_job_and_return_procedure; - pub mod ensure_analytics_date_dimension_for_date_reducer; pub mod equip_inventory_item_input_type; pub mod execute_custom_world_agent_action_procedure; @@ -409,7 +411,6 @@ pub mod get_custom_world_library_detail_procedure; pub mod get_editor_project_and_return_procedure; pub mod get_external_generation_job_and_return_procedure; pub mod get_external_generation_queue_stats_and_return_procedure; - pub mod get_jump_hop_agent_session_procedure; pub mod get_jump_hop_leaderboard_procedure; pub mod get_jump_hop_run_procedure; @@ -1504,6 +1505,9 @@ pub use delete_visual_novel_work_procedure::delete_visual_novel_work; pub use delete_wooden_fish_work_procedure::delete_wooden_fish_work; pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group; pub use drop_square_hole_shape_procedure::drop_square_hole_shape; +pub use editor_canvas_snapshot_type::EditorCanvasSnapshot; +pub use editor_canvas_table::*; +pub use editor_canvas_type::EditorCanvas; pub use editor_project_create_input_type::EditorProjectCreateInput; pub use editor_project_get_input_type::EditorProjectGetInput; pub use editor_project_get_recent_input_type::EditorProjectGetRecentInput; @@ -1519,7 +1523,6 @@ pub use editor_project_table::*; pub use editor_project_type::EditorProject; pub use editor_project_viewport_snapshot_type::EditorProjectViewportSnapshot; pub use enqueue_external_generation_job_and_return_procedure::enqueue_external_generation_job_and_return; - pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date; pub use equip_inventory_item_input_type::EquipInventoryItemInput; pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action; @@ -1566,7 +1569,6 @@ pub use get_custom_world_library_detail_procedure::get_custom_world_library_deta pub use get_editor_project_and_return_procedure::get_editor_project_and_return; pub use get_external_generation_job_and_return_procedure::get_external_generation_job_and_return; pub use get_external_generation_queue_stats_and_return_procedure::get_external_generation_queue_stats_and_return; - pub use get_jump_hop_agent_session_procedure::get_jump_hop_agent_session; pub use get_jump_hop_leaderboard_procedure::get_jump_hop_leaderboard; pub use get_jump_hop_run_procedure::get_jump_hop_run; @@ -2631,10 +2633,10 @@ pub struct DbUpdate { custom_world_session: __sdk::TableUpdate, database_migration_import_chunk: __sdk::TableUpdate, database_migration_operator: __sdk::TableUpdate, + editor_canvas: __sdk::TableUpdate, editor_project: __sdk::TableUpdate, editor_project_resource: __sdk::TableUpdate, external_generation_job: __sdk::TableUpdate, - inventory_slot: __sdk::TableUpdate, jump_hop_agent_session: __sdk::TableUpdate, jump_hop_event: __sdk::TableUpdate, @@ -2847,6 +2849,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "database_migration_operator" => db_update.database_migration_operator.append( database_migration_operator_table::parse_table_update(table_update)?, ), + "editor_canvas" => db_update + .editor_canvas + .append(editor_canvas_table::parse_table_update(table_update)?), "editor_project" => db_update .editor_project .append(editor_project_table::parse_table_update(table_update)?), @@ -3316,6 +3321,9 @@ impl __sdk::DbUpdate for DbUpdate { &self.database_migration_operator, ) .with_updates_by_pk(|row| &row.operator_identity); + diff.editor_canvas = cache + .apply_diff_to_table::("editor_canvas", &self.editor_canvas) + .with_updates_by_pk(|row| &row.canvas_id); diff.editor_project = cache .apply_diff_to_table::("editor_project", &self.editor_project) .with_updates_by_pk(|row| &row.project_id); @@ -3331,7 +3339,6 @@ impl __sdk::DbUpdate for DbUpdate { &self.external_generation_job, ) .with_updates_by_pk(|row| &row.job_id); - diff.inventory_slot = cache .apply_diff_to_table::("inventory_slot", &self.inventory_slot) .with_updates_by_pk(|row| &row.slot_id); @@ -3859,6 +3866,9 @@ impl __sdk::DbUpdate for DbUpdate { "database_migration_operator" => db_update .database_migration_operator .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "editor_canvas" => db_update + .editor_canvas + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "editor_project" => db_update .editor_project .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -4235,6 +4245,9 @@ impl __sdk::DbUpdate for DbUpdate { "database_migration_operator" => db_update .database_migration_operator .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "editor_canvas" => db_update + .editor_canvas + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "editor_project" => db_update .editor_project .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4537,10 +4550,10 @@ pub struct AppliedDiff<'r> { custom_world_session: __sdk::TableAppliedDiff<'r, CustomWorldSession>, database_migration_import_chunk: __sdk::TableAppliedDiff<'r, DatabaseMigrationImportChunk>, database_migration_operator: __sdk::TableAppliedDiff<'r, DatabaseMigrationOperator>, + editor_canvas: __sdk::TableAppliedDiff<'r, EditorCanvas>, editor_project: __sdk::TableAppliedDiff<'r, EditorProject>, editor_project_resource: __sdk::TableAppliedDiff<'r, EditorProjectResource>, external_generation_job: __sdk::TableAppliedDiff<'r, ExternalGenerationJob>, - inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>, jump_hop_agent_session: __sdk::TableAppliedDiff<'r, JumpHopAgentSessionRow>, jump_hop_event: __sdk::TableAppliedDiff<'r, JumpHopEventRow>, @@ -4821,6 +4834,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.database_migration_operator, event, ); + callbacks.invoke_table_row_callbacks::( + "editor_canvas", + &self.editor_canvas, + event, + ); callbacks.invoke_table_row_callbacks::( "editor_project", &self.editor_project, @@ -5918,10 +5936,10 @@ impl __sdk::SpacetimeModule for RemoteModule { custom_world_session_table::register_table(client_cache); database_migration_import_chunk_table::register_table(client_cache); database_migration_operator_table::register_table(client_cache); + editor_canvas_table::register_table(client_cache); editor_project_table::register_table(client_cache); editor_project_resource_table::register_table(client_cache); external_generation_job_table::register_table(client_cache); - inventory_slot_table::register_table(client_cache); jump_hop_agent_session_table::register_table(client_cache); jump_hop_event_table::register_table(client_cache); @@ -6042,10 +6060,10 @@ impl __sdk::SpacetimeModule for RemoteModule { "custom_world_session", "database_migration_import_chunk", "database_migration_operator", + "editor_canvas", "editor_project", "editor_project_resource", "external_generation_job", - "inventory_slot", "jump_hop_agent_session", "jump_hop_event", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_snapshot_type.rs new file mode 100644 index 00000000..a11f1bb4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_snapshot_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_viewport_snapshot_type::EditorProjectViewportSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCanvasSnapshot { + pub canvas_id: String, + pub project_id: String, + pub title: String, + pub viewport: EditorProjectViewportSnapshot, + pub layers_json: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorCanvasSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_table.rs new file mode 100644 index 00000000..6e0eb9c8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_table.rs @@ -0,0 +1,159 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::editor_canvas_type::EditorCanvas; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `editor_canvas`. +/// +/// Obtain a handle from the [`EditorCanvasTableAccess::editor_canvas`] method on [`super::RemoteTables`], +/// like `ctx.db.editor_canvas()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_canvas().on_insert(...)`. +pub struct EditorCanvasTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `editor_canvas`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait EditorCanvasTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`EditorCanvasTableHandle`], which mediates access to the table `editor_canvas`. + fn editor_canvas(&self) -> EditorCanvasTableHandle<'_>; +} + +impl EditorCanvasTableAccess for super::RemoteTables { + fn editor_canvas(&self) -> EditorCanvasTableHandle<'_> { + EditorCanvasTableHandle { + imp: self.imp.get_table::("editor_canvas"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct EditorCanvasInsertCallbackId(__sdk::CallbackId); +pub struct EditorCanvasDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for EditorCanvasTableHandle<'ctx> { + type Row = EditorCanvas; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = EditorCanvasInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorCanvasInsertCallbackId { + EditorCanvasInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: EditorCanvasInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = EditorCanvasDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorCanvasDeleteCallbackId { + EditorCanvasDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: EditorCanvasDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct EditorCanvasUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for EditorCanvasTableHandle<'ctx> { + type UpdateCallbackId = EditorCanvasUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> EditorCanvasUpdateCallbackId { + EditorCanvasUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: EditorCanvasUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `canvas_id` unique index on the table `editor_canvas`, +/// which allows point queries on the field of the same name +/// via the [`EditorCanvasCanvasIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_canvas().canvas_id().find(...)`. +pub struct EditorCanvasCanvasIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> EditorCanvasTableHandle<'ctx> { + /// Get a handle on the `canvas_id` unique index on the table `editor_canvas`. + pub fn canvas_id(&self) -> EditorCanvasCanvasIdUnique<'ctx> { + EditorCanvasCanvasIdUnique { + imp: self.imp.get_unique_constraint::("canvas_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> EditorCanvasCanvasIdUnique<'ctx> { + /// Find the subscribed row whose `canvas_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("editor_canvas"); + _table.add_unique_constraint::("canvas_id", |row| &row.canvas_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `EditorCanvas`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait editor_canvasQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `EditorCanvas`. + fn editor_canvas(&self) -> __sdk::__query_builder::Table; +} + +impl editor_canvasQueryTableAccess for __sdk::QueryTableAccessor { + fn editor_canvas(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("editor_canvas") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_type.rs new file mode 100644 index 00000000..0854e6ad --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_type.rs @@ -0,0 +1,80 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCanvas { + pub canvas_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub viewport_x: f64, + pub viewport_y: f64, + pub viewport_scale: f64, + pub layers_json: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for EditorCanvas { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `EditorCanvas`. +/// +/// Provides typed access to columns for query building. +pub struct EditorCanvasCols { + pub canvas_id: __sdk::__query_builder::Col, + pub project_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub viewport_x: __sdk::__query_builder::Col, + pub viewport_y: __sdk::__query_builder::Col, + pub viewport_scale: __sdk::__query_builder::Col, + pub layers_json: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for EditorCanvas { + type Cols = EditorCanvasCols; + fn cols(table_name: &'static str) -> Self::Cols { + EditorCanvasCols { + canvas_id: __sdk::__query_builder::Col::new(table_name, "canvas_id"), + project_id: __sdk::__query_builder::Col::new(table_name, "project_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + title: __sdk::__query_builder::Col::new(table_name, "title"), + viewport_x: __sdk::__query_builder::Col::new(table_name, "viewport_x"), + viewport_y: __sdk::__query_builder::Col::new(table_name, "viewport_y"), + viewport_scale: __sdk::__query_builder::Col::new(table_name, "viewport_scale"), + layers_json: __sdk::__query_builder::Col::new(table_name, "layers_json"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `EditorCanvas`. +/// +/// Provides typed access to indexed columns for query building. +pub struct EditorCanvasIxCols { + pub canvas_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, + pub project_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for EditorCanvas { + type IxCols = EditorCanvasIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + EditorCanvasIxCols { + canvas_id: __sdk::__query_builder::IxCol::new(table_name, "canvas_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + project_id: __sdk::__query_builder::IxCol::new(table_name, "project_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for EditorCanvas {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_snapshot_type.rs index 84fbc062..d0e69216 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_snapshot_type.rs @@ -4,8 +4,8 @@ #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use super::editor_canvas_snapshot_type::EditorCanvasSnapshot; use super::editor_project_resource_snapshot_type::EditorProjectResourceSnapshot; -use super::editor_project_viewport_snapshot_type::EditorProjectViewportSnapshot; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] @@ -13,8 +13,7 @@ pub struct EditorProjectSnapshot { pub project_id: String, pub owner_user_id: String, pub title: String, - pub viewport: EditorProjectViewportSnapshot, - pub layers_json: String, + pub canvas: EditorCanvasSnapshot, pub resources: Vec, pub created_at_micros: i64, pub updated_at_micros: i64, diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index db167054..8b6903b9 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -1,6 +1,7 @@ use crate::*; const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布"; +const EDITOR_CANVAS_DEFAULT_TITLE: &str = "默认画布"; const EDITOR_PROJECT_MAX_TITLE_CHARS: usize = 80; const EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES: usize = 256 * 1024; const EDITOR_PROJECT_SOURCE_TYPES: [&str; 3] = ["uploaded", "generated", "mock_generated"]; @@ -22,6 +23,25 @@ pub struct EditorProject { updated_at: Timestamp, } +#[spacetimedb::table( + accessor = editor_canvas, + index(accessor = by_editor_canvas_project_id, btree(columns = [project_id])), + index(accessor = by_editor_canvas_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct EditorCanvas { + #[primary_key] + canvas_id: String, + project_id: String, + owner_user_id: String, + title: String, + viewport_x: f64, + viewport_y: f64, + viewport_scale: f64, + layers_json: String, + created_at: Timestamp, + updated_at: Timestamp, +} + #[spacetimedb::table( accessor = editor_project_resource, index(accessor = by_editor_project_resource_project_id, btree(columns = [project_id])), @@ -123,13 +143,23 @@ pub struct EditorProjectResourceSnapshot { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorCanvasSnapshot { + pub canvas_id: String, + pub project_id: String, + pub title: String, + pub viewport: EditorProjectViewportSnapshot, + pub layers_json: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct EditorProjectSnapshot { pub project_id: String, pub owner_user_id: String, pub title: String, - pub viewport: EditorProjectViewportSnapshot, - pub layers_json: String, + pub canvas: EditorCanvasSnapshot, pub resources: Vec, pub created_at_micros: i64, pub updated_at_micros: i64, @@ -233,7 +263,7 @@ fn create_editor_project( ctx.db.editor_project().insert(EditorProject { project_id: project_id.clone(), - owner_user_id, + owner_user_id: owner_user_id.clone(), title, viewport_x: 0.0, viewport_y: 0.0, @@ -242,6 +272,13 @@ fn create_editor_project( created_at: now, updated_at: now, }); + ensure_default_canvas( + ctx, + project_id.as_str(), + owner_user_id.as_str(), + None, + now, + )?; build_project_snapshot(ctx, project_id.as_str()) } @@ -290,7 +327,28 @@ fn save_editor_project_layout( let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?; let layers_json = normalize_layout_json(input.layers_json)?; let project = require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let canvas = ensure_default_canvas( + ctx, + project.project_id.as_str(), + project.owner_user_id.as_str(), + Some(&project), + now, + )?; + ctx.db.editor_canvas().canvas_id().delete(&canvas.canvas_id); + ctx.db.editor_canvas().insert(EditorCanvas { + canvas_id: canvas.canvas_id, + project_id: project.project_id.clone(), + owner_user_id: project.owner_user_id.clone(), + title: canvas.title, + viewport_x: input.viewport.x, + viewport_y: input.viewport.y, + viewport_scale: input.viewport.scale.clamp(0.01, 8.0), + layers_json: layers_json.clone(), + created_at: canvas.created_at, + updated_at: now, + }); ctx.db.editor_project().project_id().delete(&project_id); ctx.db.editor_project().insert(EditorProject { project_id: project.project_id.clone(), @@ -301,7 +359,7 @@ fn save_editor_project_layout( viewport_scale: input.viewport.scale.clamp(0.01, 8.0), layers_json, created_at: project.created_at, - updated_at: Timestamp::from_micros_since_unix_epoch(input.updated_at_micros), + updated_at: now, }); build_project_snapshot(ctx, project_id.as_str()) @@ -384,6 +442,13 @@ fn build_project_snapshot( .project_id() .find(&project_key) .ok_or_else(|| "图片画布工程不存在".to_string())?; + let canvas = ensure_default_canvas( + ctx, + project.project_id.as_str(), + project.owner_user_id.as_str(), + Some(&project), + project.created_at, + )?; let mut resources = ctx .db .editor_project_resource() @@ -401,18 +466,77 @@ fn build_project_snapshot( project_id: project.project_id, owner_user_id: project.owner_user_id, title: project.title, - viewport: EditorProjectViewportSnapshot { - x: project.viewport_x, - y: project.viewport_y, - scale: project.viewport_scale, - }, - layers_json: project.layers_json, + canvas: canvas_snapshot_from_row(canvas), resources, created_at_micros: project.created_at.to_micros_since_unix_epoch(), updated_at_micros: project.updated_at.to_micros_since_unix_epoch(), }) } +fn ensure_default_canvas( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + legacy_project: Option<&EditorProject>, + now: Timestamp, +) -> Result { + let canvas_id = default_canvas_id(project_id); + if let Some(canvas) = ctx.db.editor_canvas().canvas_id().find(&canvas_id) { + return Ok(canvas); + } + + let legacy_view = legacy_project + .map(|project| { + ( + project.viewport_x, + project.viewport_y, + project.viewport_scale, + project.layers_json.clone(), + project.created_at, + project.updated_at, + ) + }) + .unwrap_or((0.0, 0.0, 1.0, "[]".to_string(), now, now)); + let canvas = EditorCanvas { + canvas_id: canvas_id.clone(), + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), + title: EDITOR_CANVAS_DEFAULT_TITLE.to_string(), + viewport_x: legacy_view.0, + viewport_y: legacy_view.1, + viewport_scale: legacy_view.2, + layers_json: legacy_view.3, + created_at: legacy_view.4, + updated_at: legacy_view.5, + }; + ctx.db.editor_canvas().insert(canvas); + ctx.db + .editor_canvas() + .canvas_id() + .find(&canvas_id) + .ok_or_else(|| "图片画布创建失败".to_string()) +} + +fn default_canvas_id(project_id: &str) -> String { + format!("{project_id}:canvas:default") +} + +fn canvas_snapshot_from_row(row: EditorCanvas) -> EditorCanvasSnapshot { + EditorCanvasSnapshot { + canvas_id: row.canvas_id, + project_id: row.project_id, + title: row.title, + viewport: EditorProjectViewportSnapshot { + x: row.viewport_x, + y: row.viewport_y, + scale: row.viewport_scale, + }, + layers_json: row.layers_json, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + fn require_owned_project( ctx: &ReducerContext, project_id: &str, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index d2c557a4..bf0a3fbc 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -230,6 +230,7 @@ macro_rules! migration_tables { asset_entity_binding, asset_event, editor_project, + editor_canvas, editor_project_resource, puzzle_agent_session, puzzle_background_compile_task, diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 0a3e81d2..6dafcd4e 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -284,7 +284,27 @@ describe('ImageCanvasEditorView', () => { expect(screen.getByRole('button', { name: '当前缩放比例 50%' })).toBeTruthy(); }); - it('opens a generation dialog before creating a generated layer', async () => { + it('shows the Lovart-style minimap and canvas background controls', () => { + render(); + + const viewport = screen.getByLabelText('画布工作区'); + const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' }); + + expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy(); + expect(within(panelToolbar).getByRole('button', { name: '画布背景色' })).toBeTruthy(); + expect(within(panelToolbar).getByRole('button', { name: '切换小地图' })).toBeTruthy(); + + fireEvent.click(within(panelToolbar).getByRole('button', { name: '画布背景色' })); + expect(screen.getByRole('menu', { name: '画布背景色菜单' })).toBeTruthy(); + fireEvent.click(screen.getByRole('menuitem', { name: '切换画布背景色为暖灰' })); + + expect((viewport as HTMLElement).style.backgroundColor).toBe('rgb(243, 240, 234)'); + + fireEvent.click(within(panelToolbar).getByRole('button', { name: '切换小地图' })); + expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull(); + }); + + it('opens a canvas generation frame and composer before creating a generated layer', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', width: 1024, @@ -302,12 +322,19 @@ describe('ImageCanvasEditorView', () => { fireEvent.click(within(bottomToolbar).getByRole('button', { name: '生成工具' })); const generateDialog = screen.getByRole('dialog', { name: '生成图片' }); - expect(generateDialog).toBeTruthy(); + const initialComposerTop = Number.parseFloat( + (generateDialog as HTMLElement).style.top, + ); + expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); + expect(within(generateDialog).getByText('参考图')).toBeTruthy(); + expect(within(generateDialog).getByRole('button', { name: '生成比例 1:1 2k 1张' })).toBeTruthy(); + expect(within(generateDialog).getByRole('button', { name: '生成模型 GPT Image' })).toBeTruthy(); + expect(screen.queryByRole('toolbar', { name: 'AI画布工具栏' })).toBeNull(); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '一张明亮的拼图主视觉' }, }); - fireEvent.click(screen.getByRole('button', { name: '生成' })); + fireEvent.click(within(generateDialog).getByRole('button', { name: '生成' })); expect(screen.getByRole('status').textContent).toContain('生成中'); expect(generateEditorImageMock).toHaveBeenCalledWith({ @@ -315,15 +342,113 @@ describe('ImageCanvasEditorView', () => { }); await waitFor(() => { - expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); + expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); - expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); + const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' }); + expect(anchoredGenerateDialog).toBeTruthy(); + expect( + Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top), + ).toBeCloseTo(initialComposerTop, 1); + expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); const metadataButtons = screen.getAllByRole('button', { name: /查看生成图片 .*元数据/, }); expect(metadataButtons[0]).toBeTruthy(); }); + it('drags the generation placeholder and places the generated layer there', async () => { + generateEditorImageMock.mockResolvedValueOnce({ + imageSrc: 'data:image/png;base64,ZHJhZ2dlZC1mcmFtZQ==', + width: 1024, + height: 1024, + sourceType: 'generated', + prompt: '拖拽后的生成图', + actualPrompt: '拖拽后的生成图', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'editor-drag-frame-1', + }); + render(); + + fireEvent.click(screen.getByRole('button', { name: '生成工具' })); + const initialComposerTop = Number.parseFloat( + (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style.top, + ); + const frame = screen.getByLabelText('图像生成占位图'); + dispatchPointerEvent(frame, 'pointerdown', { + button: 0, + pointerId: 61, + clientX: 500, + clientY: 260, + }); + dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { + pointerId: 61, + clientX: 582, + clientY: 342, + }); + dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { + pointerId: 61, + clientX: 582, + clientY: 342, + }); + const draggedComposerTop = Number.parseFloat( + (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style.top, + ); + expect(draggedComposerTop).toBeGreaterThan(initialComposerTop); + fireEvent.change(screen.getByLabelText('生成提示词'), { + target: { value: '拖拽后的生成图' }, + }); + fireEvent.click(screen.getByRole('button', { name: '生成' })); + + await waitFor(() => { + expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); + }); + + const generatedLayer = screen.getByAltText(/画布图片:生成图片/).closest('button')!; + const anchoredGenerateDialog = screen.getByRole('dialog', { name: '生成图片' }); + expect(anchoredGenerateDialog).toBeTruthy(); + expect( + Number.parseFloat((anchoredGenerateDialog as HTMLElement).style.top), + ).toBeCloseTo(draggedComposerTop, 1); + expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); + expect(Number.parseFloat((generatedLayer as HTMLElement).style.left)).toBeGreaterThan(700); + expect(Number.parseFloat((generatedLayer as HTMLElement).style.top)).toBeGreaterThan(150); + }); + + it('keeps the generation composer when selecting another image', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '生成工具' })); + expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); + + fireEvent.pointerDown(screen.getByAltText('画布图片:拼图素材').closest('button')!, { + button: 0, + pointerId: 62, + clientX: 120, + clientY: 120, + }); + + expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); + expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); + }); + + it('keeps the generation composer when clicking the canvas outside generation controls', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '生成工具' })); + expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); + + fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { + button: 0, + pointerId: 63, + clientX: 260, + clientY: 180, + }); + + expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); + expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); + }); + it('shows generation errors instead of falling back to mock images', async () => { generateEditorImageMock.mockRejectedValueOnce(new Error('VectorEngine 未配置')); render(); @@ -339,6 +464,7 @@ describe('ImageCanvasEditorView', () => { await waitFor(() => { expect(screen.getByRole('alert').textContent).toContain('VectorEngine 未配置'); }); + expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull(); }); @@ -505,8 +631,9 @@ describe('ImageCanvasEditorView', () => { fireEvent.click(screen.getByRole('button', { name: '生成' })); await waitFor(() => { - expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); + expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); + expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); const metadataCornerButton = screen.getAllByRole('button', { name: /查看生成图片 .*元数据/, diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 4a1cf343..4be77503 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -9,9 +9,11 @@ import { Folder, FolderPlus, Hand, + ImageIcon, ImagePlus, Info, Layers, + Map as MapIcon, MousePointer2, Pencil, RotateCcw, @@ -24,8 +26,10 @@ import { X, } from 'lucide-react'; import { + type ComponentType, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, + type ReactNode, useCallback, useEffect, useMemo, @@ -114,7 +118,16 @@ type GenerateDialogState = { prompt: string; status: 'idle' | 'generating' | 'failed'; sourceLayerId?: string; + generatedLayerId?: string; errorMessage?: string; + placeholder?: { + x: number; + y: number; + width: number; + height: number; + originalWidth: number; + originalHeight: number; + }; }; type SnapGuide = { @@ -128,6 +141,34 @@ type SnapCandidate = { distance: number; }; +type EditorIconButtonProps = { + label: string; + title?: string; + icon: ComponentType<{ className?: string }>; + className?: string; + type?: 'button' | 'submit'; + disabled?: boolean; + pressed?: boolean; + expanded?: boolean; + onClick?: () => void; +}; + +type SidebarMediaItemProps = { + title: string; + detail: string; + imageSrc: string; + imageAlt: string; + selected?: boolean; + primaryLabel: string; + onPrimaryClick: () => void; + thumbnailClassName: string; + metaClassName: string; + rowClassName: string; + primaryClassName?: string; + actions?: ReactNode; + titleNode?: ReactNode; +}; + type DragState = | { kind: 'pan'; @@ -145,6 +186,15 @@ type DragState = startLayerX: number; startLayerY: number; startScale: number; + } + | { + kind: 'generation-frame'; + pointerId: number; + startClientX: number; + startClientY: number; + startFrameX: number; + startFrameY: number; + startScale: number; }; const EDITOR_ASSETS: EditorAsset[] = [ @@ -245,6 +295,79 @@ const TOOLBAR_HALF_WIDTH = 132; const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; const SNAP_THRESHOLD_SCREEN_PX = 18; const FIT_VIEW_PADDING = 10; +const MINIMAP_SIZE = { width: 132, height: 84 }; +const MINIMAP_PADDING = 8; +const CANVAS_BACKGROUND_OPTIONS = [ + { label: '白色', value: '#ffffff' }, + { label: '浅灰', value: '#f8fafc' }, + { label: '暖灰', value: '#f3f0ea' }, + { label: '冷蓝', value: '#eef6ff' }, +]; + +function EditorIconButton({ + label, + title = label, + icon: Icon, + className, + type = 'button', + disabled, + pressed, + expanded, + onClick, +}: EditorIconButtonProps) { + return ( + + ); +} + +function SidebarMediaItem({ + title, + detail, + imageSrc, + imageAlt, + selected = false, + primaryLabel, + onPrimaryClick, + thumbnailClassName, + metaClassName, + rowClassName, + primaryClassName, + actions, + titleNode, +}: SidebarMediaItemProps) { + return ( +
+ +
+ {titleNode ?? {title}} + {detail} +
+ {actions} +
+ ); +} function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); @@ -371,6 +494,27 @@ function isGeneratedLayer(layer: CanvasLayer) { return layer.sourceType === 'generated' || layer.sourceType === 'mock_generated'; } +function getLayerBounds(targetLayers: CanvasLayer[]) { + if (targetLayers.length === 0) { + return null; + } + + return targetLayers.reduce( + (current, layer) => ({ + minX: Math.min(current.minX, layer.x), + minY: Math.min(current.minY, layer.y), + maxX: Math.max(current.maxX, layer.x + layer.width), + maxY: Math.max(current.maxY, layer.y + layer.height), + }), + { + minX: Number.POSITIVE_INFINITY, + minY: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxY: Number.NEGATIVE_INFINITY, + }, + ); +} + function isEditableTarget(event: KeyboardEvent) { const target = event.target as HTMLElement | null; if (!target) { @@ -477,6 +621,11 @@ export function ImageCanvasEditorView() { const [isPanning, setIsPanning] = useState(false); const [snapGuide, setSnapGuide] = useState(null); const [isZoomMenuOpen, setIsZoomMenuOpen] = useState(false); + const [isBackgroundMenuOpen, setIsBackgroundMenuOpen] = useState(false); + const [isMinimapOpen, setIsMinimapOpen] = useState(true); + const [canvasBackgroundColor, setCanvasBackgroundColor] = useState( + CANVAS_BACKGROUND_OPTIONS[1]?.value ?? '#f8fafc', + ); const [metadataLayer, setMetadataLayer] = useState(null); const [generateDialog, setGenerateDialog] = useState(null); @@ -486,6 +635,28 @@ export function ImageCanvasEditorView() { () => layers.find((layer) => layer.id === selectedLayerId) ?? null, [layers, selectedLayerId], ); + const activeGenerationLayer = useMemo( + () => + generateDialog?.mode === 'generate' && generateDialog.generatedLayerId + ? layers.find((layer) => layer.id === generateDialog.generatedLayerId) ?? null + : null, + [generateDialog, layers], + ); + const generationAnchor = + generateDialog?.mode === 'generate' + ? (activeGenerationLayer ?? generateDialog.placeholder ?? null) + : null; + const generationComposerStyle = generationAnchor + ? { + left: + viewport.x + + (generationAnchor.x + generationAnchor.width / 2) * viewport.scale, + top: + viewport.y + + (generationAnchor.y + generationAnchor.height) * viewport.scale + + 10, + } + : null; const selectedToolbarStyle = selectedLayer ? { left: clamp( @@ -506,6 +677,56 @@ export function ImageCanvasEditorView() { })), [assetFolders, assets], ); + const minimapModel = useMemo(() => { + const layerBounds = getLayerBounds(layers); + if (!layerBounds) { + return null; + } + + const visibleBounds = { + minX: (0 - viewport.x) / viewport.scale, + minY: (0 - viewport.y) / viewport.scale, + maxX: (canvasSize.width - viewport.x) / viewport.scale, + maxY: (canvasSize.height - viewport.y) / viewport.scale, + }; + const bounds = { + minX: Math.min(layerBounds.minX, visibleBounds.minX), + minY: Math.min(layerBounds.minY, visibleBounds.minY), + maxX: Math.max(layerBounds.maxX, visibleBounds.maxX), + maxY: Math.max(layerBounds.maxY, visibleBounds.maxY), + }; + const boundsWidth = Math.max(1, bounds.maxX - bounds.minX); + const boundsHeight = Math.max(1, bounds.maxY - bounds.minY); + const scale = Math.min( + (MINIMAP_SIZE.width - MINIMAP_PADDING * 2) / boundsWidth, + (MINIMAP_SIZE.height - MINIMAP_PADDING * 2) / boundsHeight, + ); + const projectRect = (rect: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }) => ({ + left: MINIMAP_PADDING + (rect.minX - bounds.minX) * scale, + top: MINIMAP_PADDING + (rect.minY - bounds.minY) * scale, + width: Math.max(2, (rect.maxX - rect.minX) * scale), + height: Math.max(2, (rect.maxY - rect.minY) * scale), + }); + + return { + layers: layers.map((layer) => ({ + id: layer.id, + title: layer.title, + rect: projectRect({ + minX: layer.x, + minY: layer.y, + maxX: layer.x + layer.width, + maxY: layer.y + layer.height, + }), + })), + viewport: projectRect(visibleBounds), + }; + }, [canvasSize.height, canvasSize.width, layers, viewport]); useEffect(() => { let cancelled = false; @@ -567,6 +788,7 @@ export function ImageCanvasEditorView() { if (event.key === 'Escape') { setActiveSidebarPanel(null); setIsZoomMenuOpen(false); + setIsBackgroundMenuOpen(false); setGenerateDialog((currentDialog) => currentDialog?.status === 'generating' ? currentDialog : null, ); @@ -622,20 +844,10 @@ export function ImageCanvasEditorView() { return; } - const bounds = targetLayers.reduce( - (current, layer) => ({ - minX: Math.min(current.minX, layer.x), - minY: Math.min(current.minY, layer.y), - maxX: Math.max(current.maxX, layer.x + layer.width), - maxY: Math.max(current.maxY, layer.y + layer.height), - }), - { - minX: Number.POSITIVE_INFINITY, - minY: Number.POSITIVE_INFINITY, - maxX: Number.NEGATIVE_INFINITY, - maxY: Number.NEGATIVE_INFINITY, - }, - ); + const bounds = getLayerBounds(targetLayers); + if (!bounds) { + return; + } const boundsWidth = Math.max(1, bounds.maxX - bounds.minX); const boundsHeight = Math.max(1, bounds.maxY - bounds.minY); const availableWidth = Math.max(1, canvasSize.width - FIT_VIEW_PADDING * 2); @@ -907,12 +1119,25 @@ export function ImageCanvasEditorView() { }; const openGenerateDialog = () => { + const placeholderWidth = 420; + const placeholderHeight = 420; + const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; + const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; setGenerateDialog({ mode: 'generate', prompt: '', status: 'idle', + placeholder: { + x: worldCenterX - placeholderWidth / 2, + y: worldCenterY - placeholderHeight / 2, + width: placeholderWidth, + height: placeholderHeight, + originalWidth: 2048, + originalHeight: 2048, + }, }); setActiveTool('generate'); + setSelectedLayerId(null); }; const openEditDialog = (sourceLayer: CanvasLayer) => { @@ -930,7 +1155,7 @@ export function ImageCanvasEditorView() { const addGeneratedResultLayer = ( generated: EditorImageGenerationResult, - options: { sourceLayer?: CanvasLayer } = {}, + options: { sourceLayer?: CanvasLayer; frame?: GenerateDialogState['placeholder'] } = {}, ) => { layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; @@ -938,8 +1163,8 @@ export function ImageCanvasEditorView() { const originalHeight = generated.height || 1024; const longestSide = Math.max(originalWidth, originalHeight); const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1; - const width = Math.round(originalWidth * sizeRatio); - const height = Math.round(originalHeight * sizeRatio); + const width = options.frame?.width ?? Math.round(originalWidth * sizeRatio); + const height = options.frame?.height ?? Math.round(originalHeight * sizeRatio); const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; const nextLayer: CanvasLayer = { @@ -955,8 +1180,8 @@ export function ImageCanvasEditorView() { src: generated.imageSrc, x: options.sourceLayer ? options.sourceLayer.x + options.sourceLayer.width + 32 - : worldCenterX - width / 2, - y: options.sourceLayer ? options.sourceLayer.y : worldCenterY - height / 2, + : options.frame?.x ?? worldCenterX - width / 2, + y: options.sourceLayer ? options.sourceLayer.y : options.frame?.y ?? worldCenterY - height / 2, width, height, originalWidth, @@ -974,8 +1199,22 @@ export function ImageCanvasEditorView() { setLayers((currentLayers) => [...currentLayers, nextLayer]); setSelectedLayerId(nextLayer.id); setActiveSidebarPanel('layers'); - setGenerateDialog(null); - setActiveTool('select'); + if (options.sourceLayer) { + setGenerateDialog(null); + setActiveTool('select'); + } else { + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'generate' + ? { + ...currentDialog, + status: 'idle', + generatedLayerId: nextLayer.id, + placeholder: undefined, + errorMessage: undefined, + } + : currentDialog, + ); + } if (options.sourceLayer) { fitLayers([options.sourceLayer, nextLayer]); } @@ -1023,7 +1262,7 @@ export function ImageCanvasEditorView() { addGeneratedResultLayer(generated, { sourceLayer }); } else { const generated = await generateEditorImage({ prompt: normalizedPrompt }); - addGeneratedResultLayer(generated); + addGeneratedResultLayer(generated, { frame: dialog.placeholder }); } } catch (error) { setGenerateDialog({ @@ -1088,7 +1327,6 @@ export function ImageCanvasEditorView() { if (button !== 0) { return; } - setSelectedLayerId(null); }; @@ -1123,6 +1361,38 @@ export function ImageCanvasEditorView() { }; }; + const handleGenerationFramePointerDown = ( + event: ReactPointerEvent, + ) => { + if (!generateDialog?.placeholder) { + return; + } + const button = getPointerButton(event); + if (button === 1 || effectiveTool === 'hand') { + event.stopPropagation(); + startPan(event as unknown as ReactPointerEvent); + return; + } + if (button !== 0 || generateDialog.status === 'generating') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + const pointer = getPointerClient(event); + canvasViewportRef.current?.setPointerCapture?.(event.pointerId); + setSelectedLayerId(null); + dragStateRef.current = { + kind: 'generation-frame', + pointerId: getPointerId(event), + startClientX: pointer.x, + startClientY: pointer.y, + startFrameX: generateDialog.placeholder.x, + startFrameY: generateDialog.placeholder.y, + startScale: viewport.scale, + }; + }; + const handlePointerMove = (event: ReactPointerEvent) => { const dragState = dragStateRef.current; const pointerId = getPointerId(event); @@ -1143,6 +1413,25 @@ export function ImageCanvasEditorView() { return; } + if (dragState.kind === 'generation-frame') { + const pointer = getPointerClient(event); + const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale; + const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'generate' && currentDialog.placeholder + ? { + ...currentDialog, + placeholder: { + ...currentDialog.placeholder, + x: dragState.startFrameX + deltaX, + y: dragState.startFrameY + deltaY, + }, + } + : currentDialog, + ); + return; + } + const movingLayer = layers.find((layer) => layer.id === dragState.layerId); if (!movingLayer) { return; @@ -1260,15 +1549,13 @@ export function ImageCanvasEditorView() { {activeSidebarPanel === 'assets' ? ( - + /> ) : null} @@ -1295,19 +1582,19 @@ export function ImageCanvasEditorView() { } }} /> - - + /> ) : null} {groupedAssets.map((folder) => ( @@ -1350,93 +1637,78 @@ export function ImageCanvasEditorView() { > {folder.assets.map((asset) => { const isRenaming = renamingAsset?.assetId === asset.id; - return ( -
- -
- {isRenaming ? ( - - setRenamingAsset({ - assetId: asset.id, - value: event.target.value, - }) - } - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - commitAssetRename(asset); - } - if (event.key === 'Escape') { - event.preventDefault(); - setRenamingAsset(null); - } - }} - /> - ) : ( - {asset.label} - )} - - {asset.width} x {asset.height} - -
- {isRenaming ? ( -
- - -
- ) : ( -
- - {asset.sourceKind === 'uploaded' ? ( - - ) : null} -
- )} + const titleNode = isRenaming ? ( + + setRenamingAsset({ + assetId: asset.id, + value: event.target.value, + }) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitAssetRename(asset); + } + if (event.key === 'Escape') { + event.preventDefault(); + setRenamingAsset(null); + } + }} + /> + ) : undefined; + const actions = isRenaming ? ( +
+ commitAssetRename(asset)} + /> + setRenamingAsset(null)} + />
+ ) : ( +
+ startRenamingAsset(asset)} + /> + {asset.sourceKind === 'uploaded' ? ( + deleteUploadedAsset(asset)} + /> + ) : null} +
+ ); + return ( + addAssetLayer(asset)} + rowClassName="image-canvas-editor__asset-row" + primaryClassName="image-canvas-editor__asset-button" + thumbnailClassName="image-canvas-editor__asset-thumb" + metaClassName="image-canvas-editor__asset-meta" + titleNode={titleNode} + actions={actions} + /> ); })}
@@ -1449,23 +1721,20 @@ export function ImageCanvasEditorView() { .slice() .sort((left, right) => right.zIndex - left.zIndex) .map((layer) => ( - + title={layer.title} + detail={`${Math.round(layer.width)} x ${Math.round(layer.height)}`} + imageSrc={layer.src} + imageAlt={`图层缩略图:${layer.title}`} + selected={selectedLayerId === layer.id} + primaryLabel={`选择图层${layer.title}`} + onPrimaryClick={() => setSelectedLayerId(layer.id)} + rowClassName="image-canvas-editor__layer-row" + primaryClassName="image-canvas-editor__layer-row-button" + thumbnailClassName="image-canvas-editor__layer-row-thumb" + metaClassName="image-canvas-editor__layer-row-meta" + /> ))} )} @@ -1546,6 +1815,7 @@ export function ImageCanvasEditorView() {
-
-
); })} + {generateDialog?.mode === 'generate' && generateDialog.placeholder ? ( +
+ + + Image Generator + + + {generateDialog.placeholder.originalWidth} x{' '} + {generateDialog.placeholder.originalHeight} + + + + +
+ ) : null}
{selectedLayer && selectedToolbarStyle ? ( @@ -1649,56 +1942,46 @@ export function ImageCanvasEditorView() { onPointerDown={(event) => event.stopPropagation()} > {toolButtons.map(({ label, icon: Icon }) => ( - + /> ))} - + /> {isGeneratedLayer(selectedLayer) ? ( <> - - + /> ) : null}
) : null} - + />
event.stopPropagation()} > - + {isBackgroundMenuOpen ? ( +
+ {CANVAS_BACKGROUND_OPTIONS.map((option) => ( + + ))} +
+ ) : null} +
+ toggleSidebarPanel('assets')} - > - - - + /> + setIsMinimapOpen((open) => !open)} + />
-
event.stopPropagation()} - > - {canvasTools.map(({ id, label, icon: Icon }) => ( + {isMinimapOpen && minimapModel ? ( + + ) : null} + + {generateDialog?.mode === 'generate' ? null : ( +
event.stopPropagation()} + > + {canvasTools.map(({ id, label, icon: Icon }) => ( + switchTool(id)} + /> + ))} +
+ )} + + {generateDialog?.mode === 'generate' && generationComposerStyle ? ( +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + if (generateDialog.status !== 'generating') { + void submitImageGeneration(generateDialog); + } + }} + > - ))} -
+