diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 9a6bb3945..dc4d77327 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -374,10 +374,6 @@ export function registerClientHomeTests() { resolveStaleRefresh = resolve; }); let holdFirstRefresh = false; - let signalFirstRefreshStarted!: () => void; - const firstRefreshStarted = new Promise((resolve) => { - signalFirstRefreshStarted = resolve; - }); const runtimeHarness = createProjectSupervisorRuntimeHarness({ projectPath: firstProjectPath, }); @@ -401,7 +397,6 @@ export function registerClientHomeTests() { return secondManifest; } if (holdFirstRefresh) { - signalFirstRefreshStarted(); return staleRefresh; } return firstManifest; @@ -469,11 +464,30 @@ export function registerClientHomeTests() { }); fireEvent.click(screen.getByRole('button', { name: '打开' })); await screen.findByLabelText('项目开发工作台'); + await waitFor(() => { + expect(runtimeHarness.listen).toHaveBeenCalledWith( + 'game-creator-manifest-invalidated', + expect.any(Function), + ); + }); + const firstProjectManifestReadsBeforeRefresh = invoke.mock.calls.filter( + ([command, args]) => + command === 'get_local_game_manifest' && + args?.projectPath === firstProjectPath, + ).length; holdFirstRefresh = true; act(() => { runtimeHarness.emitManifestInvalidated('code-prototype'); }); - await firstRefreshStarted; + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command, args]) => + command === 'get_local_game_manifest' && + args?.projectPath === firstProjectPath, + ).length, + ).toBeGreaterThan(firstProjectManifestReadsBeforeRefresh); + }); fireEvent.click(screen.getByRole('button', { name: '项目组' })); fireEvent.change(screen.getByLabelText('项目目录'), { @@ -489,8 +503,10 @@ export function registerClientHomeTests() { await screen.findByRole('button', { name: /second\.png/ }), ).not.toBeNull(); - act(() => resolveStaleRefresh(staleFirstManifest)); - await Promise.resolve(); + await act(async () => { + resolveStaleRefresh(staleFirstManifest); + await staleRefresh; + }); expect( screen.queryByRole('button', { name: /stale-first\.png/ }), ).toBeNull(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c596722e7..8ade79462 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1458,7 +1458,8 @@ - 2026-06-20 桌面能力清单单测边界:Tauri `capabilities.rs` 必须用 Rust 单测同时覆盖桌面 runtime capability 清单顺序、无重复、真实桌面能力完整包含,并显式排除 `auth.requestLogin`、`payment.request`、`file.captureImage`、`scanner.scanQrCode` 和 `haptics.impact` 等未接入能力;桌面单端配置检查会反查该测试边界,避免只靠方案文档或共享 profile 发现桌面壳能力伪声明。 - 2026-06-20 桌面本地通知契约镜像:Tauri `notification.showLocal` 的 title / body 归一化、长度上限和成功结果 action 必须镜像共享 HostBridge 契约;Rust 侧常量使用 `HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH`、`HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH` 和 `HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION` 命名,桌面单端配置检查会与 `packages/shared/src/contracts/hostBridge.ts` 比对数值并反查成功结果由该 action 常量组装,避免通知 payload 边界变成桌面壳本地规则。 - 2026-06-19 桌面壳外链打开 helper 共用:Tauri WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `open_normalized_desktop_external_url` 执行系统外链打开动作;HostBridge 分支仍先用 `normalize_external_url` 保留 payload 错误语义并把 opener 错误回传给 H5,WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。 - > 2026-07-18 覆盖说明:本段后续关于微信 `navigation.openNativePage`、生成结果订阅页、`[subscribe-message]` 日志和订阅页路由门禁的 2026-06 决策均已由旧创作模板退役决策废止,只作为历史记录。Expo / Tauri 的同源 H5 受控导航及微信登录、支付、分享能力继续有效。 + +> 2026-07-18 覆盖说明:本段后续关于微信 `navigation.openNativePage`、生成结果订阅页、`[subscribe-message]` 日志和订阅页路由门禁的 2026-06 决策均已由旧创作模板退役决策废止,只作为历史记录。Expo / Tauri 的同源 H5 受控导航及微信登录、支付、分享能力继续有效。 - 2026-06-20 H5 原生导航预校验:`navigateHostNativePage()` 在 `native_app` 下发送 `navigation.openNativePage` 前必须先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续交给 Expo / Tauri 壳二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。根级 `npm run check:native-shells` 会反查 H5 facade 仍使用 `normalizeNativeAppPageUrl(...)` 且发送归一后的 URL,避免明显不安全目标触达原生壳。 - 2026-06-20 微信受控原生页能力声明:微信小程序壳真实 capability profile 声明 `navigation.openNativePage`,用于承接已经登记并测试的小程序原生页 flow;当前订阅生成结果通知页通过 H5 `requestGenerationResultSubscribePermission()` 调用 `navigateHostNativePage()` 打开 `/pages/subscribe-message/index`,小程序页再调用真实 `wx.requestSubscribeMessage` 并按既有结果协议回灌。根级 `npm run check:native-shells` 必须把该能力反查到共享 profile、微信 `WECHAT_HOST_CAPABILITIES` 镜像、订阅页协议常量、H5 入口、小程序 host-bridge / shell / page 文件和相关测试;该能力不代表开放任意小程序页面跳转。 - 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities`;`openHostShare`、`writeHostClipboardText`、`requestHostHapticsImpact`、`setHostAppTitle`、`exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示受控分享动作,并按 `hostShell` 区分 Expo 系统分享面板和 Tauri 剪贴板复制表达,避免旧壳或裁剪壳露出不可用入口。 @@ -6031,6 +6032,12 @@ - 当前口径:`server-rs/crates/platform-llm/tests/live_stream_tool_calls.rs` 是默认忽略的真实端点工具调用 smoke;`on_delta` 只接收文本,工具调用从最终 `LlmRunResponse.tool_calls` 读取。现有测试没有原始 SSE 录制、事件类型/slot/分片顺序保存或逐事件比较,因此两类测试都不能证明 raw SSE fidelity 或抓包转录无偏差。 - 现有确定性流式工具覆盖应与普通 Anthropic 文本流测试分开统计:三协议真实来源 fixture、Responses 仅有 completed / incomplete 终态事件时的恢复、并行 slot 聚合、截断参数和无片段 `StreamUnavailable` 等用例共同覆盖 parser 边界;未来若需证明转录一致性,必须另行增加受控原始 SSE capture/compare 能力。 +## 2026-07-27 图片画布左侧素材库统一稳定多选与批量操作 + +- 范围边界:本次只重写左侧素材库选择模式,不改变中央画布舞台和图层列表的选择、框选或下载语义。选择集合以全部上传完成且媒体地址有效的素材为有效性边界,不因搜索、折叠或展开变化而收缩;只有素材被删除、进入上传中 / 失败态或媒体地址失效时才清理对应选择和范围锚点。 +- 输入语义:鼠标、键盘、触摸和笔输入单击都只切换当前素材,不替换其它已选素材;`Shift + 点击` 按当前可见顺序把连续区间增量加入现有选择,锚点当前不可见时退化为切换目标素材并建立新锚点。当前搜索结果的全选 / 取消全选只增量增删已展开的可见素材并保留其它选择,同时清空上次单项选择的范围锚点。退出选择模式、关闭素材栏或切到图层栏统一清空选择、锚点和框选状态,非选择模式不显示历史选中高亮。触摸素材卡仍可单击切换,但触摸列表空白区域必须保留纵向滚动,不启动框选;鼠标 / 笔框选使用素材列表内容坐标承接滚动偏移,并以 `pointerup` 的最终坐标提交,`pointercancel` 只取消框选。 +- 工具栏与导出:批量工具栏作为素材滚动列表的固定非滚动底栏,展示跨搜索与折叠状态保留的全部已选数量,并提供当前可见范围全选 / 取消全选、下载、删除和取消。下载消费完整选中集合;删除完整选中集合时,如果其中存在当前未显示素材,必须先用危险确认弹窗明确展示全部删除数量和未显示数量,用户确认前不得执行删除。选择模式隐藏单行下载 / 重命名并禁用行拖拽和右键菜单;内置、上传未完成、上传失败或无可读来源的行显示为不可选择,不暴露虚假的可用按钮。移动端选择模式使用独立的侧栏高度状态,并让素材列表恢复纵向滚动,避免普通模式 `14rem` 高度上限被固定底栏、标题和搜索区吃完。一个选中素材直接下载,多个选中素材复用画布素材导出管线生成 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,根目录为 `项目名-选中素材/`;单素材、序列帧和两类集合 ZIP 的下载名统一包含到秒的本地时间戳。素材卡整行是统一选择命中区,标题和空白区不得绕过单项切换或 Shift 范围处理。序列帧层的可导出性以至少一帧具有可读 `imageSrc / objectKey` 为准,不依赖层级 `src / objectKey`;单项、选中集合和整画布导出共用一个前端互斥锁,避免下载和状态提示互相覆盖。 + ## 2026-07-29 抽取通用多 Agent Runtime 公共内核第一阶段 - 背景:AI 游戏创作 Runtime 已有独立 Runner、持久任务、Provider 恢复、Goal、计划、静态/隔离协作和 finalization,但实现仍属于 Tauri package;内建 capability、Agent 目录和 Run Profile 缺少第二个产品可直接依赖的公开契约。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f1b9fa9b7..460565737 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -312,6 +312,14 @@ - 验证:`ImageCanvasEditorModel.test.ts` 覆盖素材库 source resource 保留,`useImageCanvasAssetCanvasBridge.test.tsx` 覆盖资源 ID 级联清理,`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖删除后保存的新 layout 不再包含被删图层。 - 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/useImageCanvasAssetCanvasBridge.ts`、`src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`。 +## 图片画布素材选择有效性不要绑定搜索与折叠可见性 + +- 现象:批量选择多个素材后,搜索、折叠文件夹或展开文件夹会让已选数量下降、Shift 范围锚点丢失,后续批量下载或删除遗漏此前已选素材。 +- 原因:搜索结果和文件夹展开状态只描述当前 UI 可见范围,不描述素材是否仍然有效;用 `visibleAssetIds` reconcile 全局选择会把暂时隐藏误判为素材失效。 +- 处理:由唯一 `useImageCanvasAssetSelection` 持有选择集合、范围锚点、框选和全部选择 mutation;全局选择只按全部 `selectableAssetIds` 清理真正删除、上传未完成、上传失败或媒体地址无效的 ID。`visibleAssetIds` 只作为单项切换、Shift 可见区间和当前结果全选 / 取消全选的动作入参,批量下载与删除消费 hook 输出的完整 `selectedAssets`;删除中包含当前未显示选择时,必须明确展示全部数量和未显示数量并二次确认。 +- 验证:模型测试覆盖隐藏选择保留、可见范围增量和真正失效 ID 清理;图片画布素材集成测试覆盖搜索、折叠 / 展开后选中数量稳定及当前可见全选不影响隐藏选择。 +- 关联:`src/components/image-editor/useImageCanvasAssetSelection.ts`、`src/components/image-editor/useImageCanvasAssetLibrary.ts`、`src/components/image-editor/ImageCanvasSidebarView.tsx`、`src/components/image-editor/ImageCanvasEditorView.tsx`。 + ## 后台素材查询不要用 SQL 直查 editor_asset - 现象:后台“素材查询”报 `HTTP 400:no such table: editor_asset. If the table exists, it may be marked private.`。 diff --git a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md index 4d2dd00d8..97f13fd32 100644 --- a/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md +++ b/docs/technical/【前端架构】图片画布素材导出方案-2026-06-15.md @@ -28,6 +28,10 @@ - 锁定、分组、翻转等状态不影响图片文件导出,但写入元数据。 - 空画布时按钮置灰,或点击后显示轻提示。 +左侧素材库选择模式同时支持导出当前可见范围内的选中素材:只选中一个普通素材时直接下载原文件;选中多个素材时沿用画布素材 ZIP 的读取、去重、媒体分目录、序列帧、元数据、部分失败和浏览器下载能力,生成 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,包内根目录为 `项目名-选中素材/`。单素材、单序列帧、选中素材 ZIP 和画布素材 ZIP 的下载文件名都必须包含到秒的本地时间戳,避免同一天重复导出时重名。序列帧层只要至少一帧具有可读 `imageSrc / objectKey` 即可进入导出计划,不要求层级 `src / objectKey`;单层、选中素材和画布集合导出必须共用同一互斥状态,避免并发下载覆盖进度与结果提示。该入口不改变中央画布和图层列表的选择 / 下载语义。 + +持久素材只要 `src` 或 `objectKey` 任一有效即可进入点击、Shift、框选和全选范围。`src` 为空但保留私有 `objectKey` 的素材,导出时复用统一资源读取链路换签;浏览器无法直读签名 URL 时继续回退同源 `/api/assets/read-bytes` 字节代理,不得因缺少临时展示地址而过滤。 + 暂不实现: - 画布整体截图 PNG。 @@ -40,7 +44,7 @@ 导出文件名: ```text -项目名-画布素材-YYYYMMDD.zip +项目名-画布素材-YYYYMMDD-HHmmss.zip ``` 包内结构: @@ -72,7 +76,7 @@ 普通序列帧 ZIP 结构: ```text -角色动作-Sequence.zip +角色动作-Sequence-YYYYMMDD-HHmmss.zip ├─ frames/ │ ├─ frame-01.png │ └─ frame-02.png @@ -84,7 +88,7 @@ Spine JSON ZIP 结构: ```text -角色动作-SpineJSON.zip +角色动作-SpineJSON-YYYYMMDD-HHmmss.zip ├─ frames/ │ ├─ frame-01.png │ └─ frame-02.png @@ -164,6 +168,7 @@ assetObjectId > objectKey > sourceAssetId > src 2. 过滤无效图层,保留隐藏图层。 3. 按去重 key 合并图片源或序列帧源。 4. 对每个素材源读取 Blob: + - 集合导出先按图层顺序完成去重和文件名规划,再用同一个四路并发读取器读取 / 转换普通素材与序列帧,最后按规划和原帧顺序写入 ZIP;不得让响应完成顺序改变导出目录或帧顺序。 - `data:image/...` 直接转换为 Blob。 - 同源或可访问 URL 使用 `fetch` 拉取 Blob。 - 私有 generated / OSS 素材先走 `/api/assets/read-url` 换签并由浏览器直接 `fetch` OSS 签名 URL;必须在同一保护边界内完整消费响应体,换签、请求、状态码或响应体读取任一阶段失败时,才 fallback 到同源 `/api/assets/read-bytes`,不得在只拿到 `2xx/206` 响应头后提前视为读取成功。 @@ -198,6 +203,10 @@ assetObjectId > objectKey > sourceAssetId > src - 动作图层右键菜单把 `导出为` 作为一级入口,二级菜单提供 `序列帧导出(zip)` 和 `Spine 导出(zip)`。 - 单图层普通序列帧 ZIP 包含 `frames/`、`preview.gif`、`metadata.json` 和 `manifest.txt`,且不包含 `skeleton.json`。 - 序列帧导出的 `skeleton.json` 可被独立验证器解析并预览。 +- 左侧素材库只选一个素材时直接下载该素材,不创建集合 ZIP。 +- 左侧素材库选择多个素材时只把选中素材写入 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,不混入未选素材,并继续覆盖部分失败和全部失败边界。 +- 同一事件循环内重复触发集合导出时只启动一次读取和下载;单个选中素材下载成功后显示 `选中素材已导出`。 +- 多素材与长序列帧集合导出同时读取不超过四项,外层素材无需等待前一个序列全部完成,异步读取逆序完成时 ZIP 文件与帧顺序仍保持稳定。 ## Spine JSON 验证器 @@ -226,7 +235,6 @@ npm run spine-export-validator:build ## 后续扩展 -- 导出当前选中素材。 - 导出画布快照 PNG。 - 导出可恢复工程包。 - 导入工程包恢复画布。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 11c8cbfb4..79f28a330 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -204,7 +204,8 @@ - 画布素材 ZIP 的 `metadata.json` 只保存前端信息弹窗和导出文件列表可见的展示快照:项目标题、导出时间、图层标题、文件路径、类型、生成输入、模型显示名、Task 短 ID、Object 显示值、Resolution / 时长和导出错误。导出的生成输入只保留用户实际填写或选择的内容;系统默认兜底提示词、固定工作流提示词、内置图标描述、UI 提取素材固定提示词等内置提示词即使存在于历史 `generationInputs`,也不得写入导出元数据。不得把 `projectId`、`layerId`、`resourceId`、`sourceAssetId`、`sourceResourceId`、原始 `prompt` / `actualPrompt` / `provider` 或画布坐标、锁定、隐藏等布局状态写入导出元数据;Object 字段仅沿用信息弹窗当前可见值。 - `生成角色动作` 的完成结果按序列帧素材处理:`assetKind` 固定为 `character-animation`,图层主 `src` 使用首帧,前端内部据 `assetKind` 派生 `CanvasMediaType="image-sequence"` 选择播放器。`previewVideoPath` 只作为上游来源,不作为画布主媒体。下载和 ZIP 导出必须得到序列帧 ZIP / frames 目录,不能回退为预览视频或首帧 PNG;后端抽帧后逐帧保存带绿幕源图,上传时消费并释放原帧字节,再用该对象的 600 秒 OSS URL 完成透明化并落盘透明帧素材。 - 素材库和项目资源 API 不返回独立 `mediaType`;前端只按 `assetKind` 映射渲染器:`character-animation → image-sequence`、`video → video`、`audio/sound-effect/background-music → audio`、其余 → image,不按扩展名猜类型。最终动作 asset / resource 平铺返回 `imageSequenceFrames/imageSequenceDurationMs`;正式帧对象不含 `frameIndex`,数组位置是唯一播放顺序,帧数取数组长度,FPS 按帧数与毫秒时长即时推导。只有至少两帧且播放时长大于 0 的角色动作素材才能进入序列播放器,否则按损坏素材失败关闭。预览视频作为独立 `assetKind=video` 资源保存,最终动作以 `sourceResourceId` 保留来源关系,不重复保存路径。从素材库点击、HTML5 拖放或指针拖放到画布时必须复制完整字段并先创建正式 project resource;新 layout 只保存 resource 引用和几何,不保存帧集或预览视频,刷新后从 project resource 恢复循环播放。历史 `local-*` 自包含动作布局仍可读取。 -- 素材面板支持按素材名、文件夹名、生成信息、用户可见模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。Provider 与 `isEditorInternalProcessingModel(...)` 识别的内部处理模型不得进入搜索索引,避免出现隐藏字段可命中或命中但无可见匹配字段;原始审计元数据只保留给服务端和后台管理读取,普通用户(包括素材 owner)不得通过响应取得。 +- 素材面板支持按素材名、文件夹名、生成信息、用户可见模型、任务和媒体类型搜索,并支持选择模式框选。选择模式的选中集合以全部上传完成且媒体地址有效的可选素材为有效性边界,不因搜索、折叠或展开变化而收缩;只有素材被删除、进入上传中 / 失败态或媒体地址失效时才剔除对应选择及范围锚点。素材缩略图、标题、元数据和卡片空白区统一作为整行选择命中区:鼠标、键盘、触摸和笔输入单击都只切换当前素材,不替换其它已选素材;`Shift + 点击` 按当前可见顺序把连续区间增量加入现有选择,锚点当前不可见时退化为切换目标素材并建立新锚点。当前搜索结果的全选 / 取消全选只增量增删当前已展开的可见素材并保留其它选择,同时清空上次单项选择的范围锚点。触摸素材卡仍可切换选中态,但触摸列表空白区必须继续滚动,不能捕获为框选;鼠标 / 笔框选坐标必须包含列表滚动偏移,`pointerup` 提交最后坐标,`pointercancel` 只取消。内置、上传未完成、上传失败或无可读来源的素材行不得暴露可用的选择按钮。退出选择模式、关闭素材栏或切换到图层栏时必须同时清空选择锚点、选中集合和框选状态,非选择模式不得残留选中高亮。 +- 素材选择模式的批量工具栏是素材列表滚动区之外的固定底栏,始终显示跨搜索与折叠状态保留的全部已选数量、当前可见素材全选 / 取消全选、下载、删除和取消;下载和删除作用于完整选中集合,不得只消费当前可见 ID。删除集合中存在当前未显示素材时,必须先用危险确认弹窗明确展示全部删除数量和未显示数量,确认目标使用打开弹窗时的素材 ID 快照;选择集合、可见集合或选择模式漂移后关闭旧确认弹窗,确认前不得执行删除。工具栏自身禁止横向溢出和滚动,空间不足时按钮在工具栏内部换行。不得把工具栏作为滚动列表末项或依赖 `position: sticky` 的自然位置。移动端进入素材选择模式时必须提高素材侧栏高度并允许素材列表纵向滚动,为固定底栏之上至少保留一张素材卡的可操作空间,同时受当前视口高度上限约束,不得在横屏或嵌入式短视口中挤占画布。选择模式隐藏单素材下载 / 重命名动作,进入时清理已有拖拽 / 投放状态,并同时阻断素材行、文件夹空白区的内部素材移动和外部文件投放以及右键菜单,避免批量意图回退成单素材操作。Provider 与 `isEditorInternalProcessingModel(...)` 识别的内部处理模型不得进入搜索索引,避免出现隐藏字段可命中或命中但无可见匹配字段;原始审计元数据只保留给服务端和后台管理读取,普通用户(包括素材 owner)不得通过响应取得。 - 图层面板支持按图层名、生成信息、用户可见模型、任务和媒体类型搜索;Provider 与内部处理模型同样不得进入搜索索引。普通用户图层快照也不得携带这些内部字段。支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。 - 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。 - 鼠标滚轮默认按设备原始 `deltaX / deltaY` 二维平移画布视口;当按住 Shift 且设备上报 `deltaX = 0` 时,视口输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型仍只消费归一化后的 X / Y。按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index 69b40183f..291f995b8 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -101,9 +101,12 @@ ## 第十阶段模块 - `useImageCanvasAssetLibrary.ts` - - 承载账号级素材库状态模型:素材文件夹、素材列表、文件夹折叠 / 新建 / 重命名 / 删除、素材重命名 / 删除、素材选择模式、框选、多选删除、素材拖到文件夹和鉴权失败登录弹窗。 + - 承载账号级素材库状态模型:素材文件夹、素材列表、文件夹折叠 / 新建 / 重命名 / 删除、素材重命名 / 删除、批量删除资源副作用、素材拖到文件夹和鉴权失败登录弹窗;不再持有选择集合、范围锚点或框选交互状态。 - 主视图继续保留上传文件读取、上传占位卡片进度、拖到画布坐标、创建画布图层、工程资源持久化和画布图层清理;素材删除通过 `onDeleteAssets` 回调通知主视图清理关联图层。 - 该 hook 有独立单测覆盖素材库加载归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除,避免后续整理侧栏 JSX 时丢失素材库能力。 +- `useImageCanvasAssetSelection.ts` + - 作为素材选择的唯一状态边界,统一持有选择模式、完整选中集合、范围锚点、可选素材有效性 reconcile、单项 / Shift / 当前可见全选增量、框选几何与框选生命周期;框选除 `pointerup / pointercancel` 外必须在匹配的 `lostpointercapture` 到达时只清理框选状态,不得再次释放已经丢失的 capture。该 hook 向批量下载 / 删除只暴露已经按素材顺序解析的 `selectedAssets`;删除入口根据当前 `visibleAssetIds` 识别未显示选择并在执行完整集合删除前弹出危险确认。 + - 搜索和文件夹折叠只在侧栏产生 `visibleAssetIds` 并传入增量动作,不得直接修改或 reconcile 选择集合;选择行为测试集中在该 hook,素材库 model 不再维护第二套选择状态机。 ## 第十一阶段模块 diff --git a/docs/technical/【前端测试】React组件测试准则-2026-06-26.md b/docs/technical/【前端测试】React组件测试准则-2026-06-26.md index 3ae1ea268..6b32b7ce5 100644 --- a/docs/technical/【前端测试】React组件测试准则-2026-06-26.md +++ b/docs/technical/【前端测试】React组件测试准则-2026-06-26.md @@ -37,6 +37,7 @@ - 测试用 fixture 只包含本行为需要的字段。演化中的 payload 使用 `expect.objectContaining(...)` 或 helper 生成默认对象,避免一处契约加字段导致大量无关用例碎裂。 - 当测试是为防止历史回归,应在测试名或邻近注释中说明防的是什么行为,而不是记录实现步骤。 - 同一用例既要验证定时器调度参数,又要断言确定的中间帧或中间状态时,必须 mock 定时器回调或使用可控假时钟;不得让真实墙上时间在异步交互期间推进被断言的状态,否则本地通过的用例会在较慢 CI 中偶发失败。 +- 测试 React effect 中注册的事件监听时,触发事件前先用可观测的 listener 调用确认注册已完成;异步请求已开始应用有界 `waitFor` 断言确认,不要用无界手工 Promise 等待一次性信号。解除挂起请求时将 Promise 收尾纳入异步 `act`,确保后续 React 更新在断言前已冲刷。 ## 试点调整 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index b9df12ad5..6bc6a21b3 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -2,7 +2,7 @@ 日期:`2026-06-15` -更新时间:`2026-07-28` +更新时间:`2026-08-07` ## 背景 @@ -187,6 +187,7 @@ - 画板前端回填角色动作结果时,以正式 resource / asset 的 `assetKind: "character-animation"` 选择序列帧播放器并以首帧作为图层主图;带项目上下文的生成必须返回正式 project resource,图层直接使用其 `resourceId`。无项目放置只允许绑定响应中的正式账号素材,不从生成结果 `frames` 构造自包含 `local-resource-character-animation-*`;此时图层的 `sourceResourceId` 继续使用正式账号素材记录的直接来源,不回退成请求中的原角色资源。预览视频作为独立 `assetKind: "video"` 资源保存,但不复制进最终动作 layout。 - 角色动作图层在画布中循环展示透明 PNG 帧;刷新恢复只从 project resource 的 `imageSequenceFrames/imageSequenceDurationMs` 读取正式结果。动作 layer layout 只保存 `layerId/resourceId` 和几何、层级、可见性等放置信息,不保存 `mediaType`、动作帧、时长、预览、生成输入或资源元数据副本;资源缺字段时按损坏数据失败关闭。 - 点击角色动作图层下载时,必须打包下载序列帧 ZIP;画布素材 ZIP 导出时,角色动作写入 `sequences/<编号-标题>/frames/`,而不是导出预览视频。 +- 集合 ZIP 对角色动作去重时,稳定身份依次取层级 `taskId`、`sourceResourceId`,再按帧顺序取每帧 `assetObjectId -> objectKey -> imageSrc`;帧数组必须使用有边界编码,不得只拼接可为空的 `imageSrc`。两个仅含不同私有 `objectKey` 的序列必须同时进入 ZIP,根 `metadata.json` 分别指向各自的序列目录。 - 角色动作生成 BFF 在同一请求内保存两类账号素材:上游预览 MP4 使用 `assetKind=video` 并承载生成成本,最终透明帧集使用 `assetKind=character-animation` 且派生成本为 0;响应中的 `resource` / `asset` 固定指向最终透明帧集对应的项目资源与账号素材。调用方必须直接使用它们,不得再以第一帧调用资源或素材创建接口。 - 最终项目资源和账号素材只把 `imageSequenceFrames/imageSequenceDurationMs` 写入正式媒体字段,并以 `sourceResourceId` 指向独立预览视频资源;正式帧对象和角色动作生成响应都不含 `frameIndex`,数组位置是唯一播放顺序,帧数和 FPS 均按需派生。`generationInputs` 只保存 `fields/references` 等用户可重放输入,`screenColorHex` 和动作运行结果均不落入其中。存量 `characterAnimation`、已确认动作行的 helper 顶层字段、`frameIndex`、误标预览 MP4 及动作 layout 副本由受 migration operator 限制的四阶段数据库 procedure 一次性规范化;迁移按权威对象类型排除同 task 的预览视频、只接受唯一最终图片序列并逐帧补齐登记对象身份,且不把 layout 复制的 `sourceResourceId` 当作历史血缘证据。CLI tuple 与 HTTP object 两种 procedure 返回都必须先归一为脚本内部统一的 `batch_sha256`,分页还必须拒绝未推进或重复出现的 cursor,避免安全校验误拒绝或生产迁移无限循环。该宽容只属于迁移;新生成仍严格使用“原角色资源 → 预览视频资源 → 最终序列资源”直接来源链。上线完成后 api-server、后台、Web 和外部 helper 不再读取 legacy fallback。新动作写入若提交旧运行字段或 `frameIndex`,HTTP 与 SpacetimeDB storage 均明确拒绝。 - `frames[0].imageSrc` 仍作为后续动作素材快速编辑或再次生成动作时的透明帧来源。该规则不得跳过后端原有的视频生成、抽帧、透明化处理和帧素材落盘流程。 diff --git a/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx index 5fb1720a7..392b8900f 100644 --- a/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx +++ b/src/components/image-editor/ImageCanvasAssetFolderSectionView.tsx @@ -29,6 +29,7 @@ import type { EditorAssetFolder, UploadTarget, } from './ImageCanvasEditorTypes'; +import type { AssetSelectionInteraction } from './useImageCanvasAssetSelection'; type ImageCanvasAssetFolderSectionViewProps = { folder: GroupedEditorAssetFolder; @@ -36,6 +37,7 @@ type ImageCanvasAssetFolderSectionViewProps = { suppressAssetClickRef: { current: boolean }; isAssetSelectionMode: boolean; selectedAssetIds: Set; + visibleAssetIds: string[]; assetMoveDropFolderId: string | null; renamingFolder: { folderId: string; value: string } | null; renamingAsset: { assetId: string; value: string } | null; @@ -48,9 +50,11 @@ type ImageCanvasAssetFolderSectionViewProps = { setActiveUploadFolderId: Dispatch>; setUploadDropTarget: Dispatch>; setAssetPointerDrag: Dispatch>; - setSelectedAssetIds: Dispatch>>; updateAssetMoveDropFolder: (folderId: string | null) => void; - addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void; + addUploadedFiles: ( + files: FileList | File[], + options?: UploadFilesOptions, + ) => void; requestUpload: (target: UploadTarget, options?: UploadRequestOptions) => void; moveAssetToFolder: (assetId: string, folderId: string) => void; toggleAssetFolder: (folderId: string) => void; @@ -61,7 +65,10 @@ type ImageCanvasAssetFolderSectionViewProps = { commitAssetRename: (asset: EditorAsset) => void; deleteUploadedAsset: (asset: EditorAsset) => void; submitAssetToShowcase: (asset: EditorAsset) => void; - toggleAssetSelected: (assetId: string) => void; + toggleAssetSelected: ( + assetId: string, + interaction: AssetSelectionInteraction, + ) => void; addAssetLayer: (asset: EditorAsset) => void; onDownloadAsset: (asset: EditorAsset) => void; }; @@ -72,6 +79,7 @@ export function ImageCanvasAssetFolderSectionView({ suppressAssetClickRef, isAssetSelectionMode, selectedAssetIds, + visibleAssetIds, assetMoveDropFolderId, renamingFolder, renamingAsset, @@ -80,7 +88,6 @@ export function ImageCanvasAssetFolderSectionView({ setActiveUploadFolderId, setUploadDropTarget, setAssetPointerDrag, - setSelectedAssetIds, updateAssetMoveDropFolder, addUploadedFiles, requestUpload, @@ -110,6 +117,17 @@ export function ImageCanvasAssetFolderSectionView({ aria-label={folder.label} data-asset-folder-id={folder.id} onDragOver={(event) => { + if (isAssetSelectionMode) { + if ( + hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE) || + hasDataTransferType(event.dataTransfer, 'Files') + ) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'none'; + } + return; + } if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) { event.preventDefault(); event.stopPropagation(); @@ -126,6 +144,18 @@ export function ImageCanvasAssetFolderSectionView({ } }} onDrop={(event) => { + if (isAssetSelectionMode) { + if ( + getDraggedAssetId(event.dataTransfer) || + event.dataTransfer.files.length > 0 + ) { + event.preventDefault(); + event.stopPropagation(); + setUploadDropTarget(null); + updateAssetMoveDropFolder(null); + } + return; + } const movingAssetId = getDraggedAssetId(event.dataTransfer); if (movingAssetId) { event.preventDefault(); @@ -241,11 +271,11 @@ export function ImageCanvasAssetFolderSectionView({ suppressAssetClickRef={suppressAssetClickRef} isAssetSelectionMode={isAssetSelectionMode} selectedAssetIds={selectedAssetIds} + visibleAssetIds={visibleAssetIds} renamingAsset={renamingAsset} setRenamingAsset={setRenamingAsset} setUploadDropTarget={setUploadDropTarget} setAssetPointerDrag={setAssetPointerDrag} - setSelectedAssetIds={setSelectedAssetIds} updateAssetMoveDropFolder={updateAssetMoveDropFolder} addUploadedFiles={addUploadedFiles} moveAssetToFolder={moveAssetToFolder} diff --git a/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts b/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts index 46253fc37..889a87565 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts +++ b/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts @@ -1,33 +1,25 @@ import { describe, expect, it } from 'vitest'; import { - areAllSelectableAssetsSelected, - createAssetMarqueeFromPointer, - createAssetMarqueeSelectionRect, createLocalAssetFolder, deleteAssetFolderLocally, filterGroupedAssetsBySearch, - getSelectableAssets, groupAssetsByFolder, layerMatchesSearch, layerStartsWithSearch, - moveAssetMarqueeToPointer, moveAssetToFolderLocally, removeAssetById, removeSelectedAssets, renameAssetById, renameAssetFolderById, replaceLocalAssetFolder, - resolveAllAssetSelection, resolveAssetFolderIdFromPoint, resolveAssetShowcaseSubmissionState, resolveDefaultAssetFolder, resolvePinnedAssetMoveFolderId, resolvePreferredAssetUploadFolder, resolveUploadAssetFolder, - selectUploadedAssetsInRect, toggleAssetFolderCollapsed, - toggleAssetSelection, } from './ImageCanvasAssetLibraryModel'; import type { CanvasLayer, @@ -105,16 +97,12 @@ describe('ImageCanvasAssetLibraryModel', () => { { id: 'project', assets: [{ id: 'asset-a' }, { id: 'built-in' }] }, { id: 'folder-role', assets: [{ id: 'asset-b' }] }, ]); - expect(getSelectableAssets(assets).map((asset) => asset.id)).toEqual([ - 'asset-a', - 'asset-b', - ]); }); it('explains why assets cannot be submitted to showcase review', () => { - expect( - resolveAssetShowcaseSubmissionState(createAsset()).reason, - ).toBe('只有生成素材可以提交精选审核'); + expect(resolveAssetShowcaseSubmissionState(createAsset()).reason).toBe( + '只有生成素材可以提交精选审核', + ); expect( resolveAssetShowcaseSubmissionState( createAsset({ sourceType: 'generated', persisted: false }), @@ -260,15 +248,15 @@ describe('ImageCanvasAssetLibraryModel', () => { }); it('renames assets, toggles folders and creates local folders', () => { - expect(renameAssetById([createAsset()], 'asset-a', '新名字')[0]).toMatchObject( - { label: '新名字' }, - ); + expect( + renameAssetById([createAsset()], 'asset-a', '新名字')[0], + ).toMatchObject({ label: '新名字' }); expect( renameAssetFolderById([createFolder()], 'project', '默认素材')[0], ).toMatchObject({ label: '默认素材' }); - expect(toggleAssetFolderCollapsed([createFolder()], 'project')[0]).toMatchObject( - { collapsed: true }, - ); + expect( + toggleAssetFolderCollapsed([createFolder()], 'project')[0], + ).toMatchObject({ collapsed: true }); expect( createLocalAssetFolder({ folderId: 'folder-local', @@ -316,9 +304,9 @@ describe('ImageCanvasAssetLibraryModel', () => { createAsset({ id: 'asset-b', folderId: 'project' }), ]; - expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual([ - 'asset-b', - ]); + expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual( + ['asset-b'], + ); expect(resolveDefaultAssetFolder(folders)?.id).toBe('project'); expect( resolveUploadAssetFolder([ @@ -343,23 +331,11 @@ describe('ImageCanvasAssetLibraryModel', () => { }); }); - it('toggles selection, selects all uploaded assets and removes selected assets', () => { + it('removes selected assets', () => { const assets = [ createAsset({ id: 'asset-a' }), createAsset({ id: 'asset-b' }), ]; - const selected = toggleAssetSelection(new Set(), 'asset-a'); - - expect([...selected]).toEqual(['asset-a']); - expect([...toggleAssetSelection(selected, 'asset-a')]).toEqual([]); - expect(areAllSelectableAssetsSelected(assets, new Set(['asset-a']))).toBe( - false, - ); - const allSelected = resolveAllAssetSelection({ - allSelectableAssetsSelected: false, - selectableAssets: assets, - }); - expect([...allSelected]).toEqual(['asset-a', 'asset-b']); const removal = removeSelectedAssets(assets, new Set(['asset-b'])); expect(removal.assets.map((asset) => asset.id)).toEqual(['asset-a']); expect(removal.deletedAssets.map((asset) => asset.id)).toEqual(['asset-b']); @@ -438,71 +414,4 @@ describe('ImageCanvasAssetLibraryModel', () => { }), ).toBeNull(); }); - - it('updates marquee geometry from pointer coordinates and normalizes reverse drags', () => { - const marquee = createAssetMarqueeFromPointer({ - pointerId: 5, - point: { clientX: 180, clientY: 220 }, - containerRect: { left: 100, top: 200 }, - }); - - expect(marquee).toEqual({ - pointerId: 5, - startX: 80, - startY: 20, - currentX: 80, - currentY: 20, - }); - expect( - moveAssetMarqueeToPointer({ - marquee, - point: { clientX: 120, clientY: 205 }, - containerRect: { left: 100, top: 200 }, - }), - ).toMatchObject({ currentX: 20, currentY: 5 }); - expect( - createAssetMarqueeSelectionRect({ - marquee, - point: { clientX: 120, clientY: 205 }, - containerRect: { left: 100, top: 200 }, - }), - ).toEqual({ - left: 120, - right: 180, - top: 205, - bottom: 220, - }); - }); - - it('selects only uploaded assets intersecting the marquee rectangle', () => { - const assets = [ - createAsset({ id: 'asset-a' }), - createAsset({ id: 'asset-b' }), - createAsset({ id: 'built-in', sourceKind: 'built-in' }), - ]; - const selectedIds = selectUploadedAssetsInRect({ - assets, - assetTargets: [ - { - assetId: 'asset-a', - rect: { left: 10, right: 20, top: 10, bottom: 20 }, - }, - { - assetId: 'asset-b', - rect: { left: 30, right: 40, top: 30, bottom: 40 }, - }, - { - assetId: 'built-in', - rect: { left: 15, right: 25, top: 15, bottom: 25 }, - }, - { - assetId: 'missing', - rect: { left: 15, right: 25, top: 15, bottom: 25 }, - }, - ], - selectionRect: { left: 20, right: 35, top: 20, bottom: 35 }, - }); - - expect([...selectedIds]).toEqual(['asset-a', 'asset-b']); - }); }); diff --git a/src/components/image-editor/ImageCanvasAssetLibraryModel.ts b/src/components/image-editor/ImageCanvasAssetLibraryModel.ts index c254d1d04..dcff8868e 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryModel.ts +++ b/src/components/image-editor/ImageCanvasAssetLibraryModel.ts @@ -1,6 +1,5 @@ import { isEditorInternalProcessingModel } from './ImageCanvasEditorModel'; import type { - AssetMarqueeState, CanvasLayer, EditorAsset, EditorAssetFolder, @@ -18,11 +17,6 @@ type AssetFolderHitTarget = { rect: ClientRectLike; }; -type AssetHitTarget = { - assetId: string; - rect: ClientRectLike; -}; - export type GroupedAssetFolder = EditorAssetFolder & { assets: EditorAsset[]; }; @@ -38,15 +32,6 @@ function isPointInRect(point: ClientPoint, rect: ClientRectLike) { ); } -function doRectsIntersect(rect: ClientRectLike, selectionRect: ClientRectLike) { - return ( - rect.left <= selectionRect.right && - rect.right >= selectionRect.left && - rect.top <= selectionRect.bottom && - rect.bottom >= selectionRect.top - ); -} - export function resolveAssetFolderIdFromPoint({ point, listRect, @@ -85,98 +70,6 @@ export function resolvePinnedAssetMoveFolderId({ return folderId; } -export function shouldStartAssetMarquee({ - isAssetSelectionMode, - button, - isBlockedTarget, -}: { - isAssetSelectionMode: boolean; - button: number; - isBlockedTarget: boolean; -}) { - return isAssetSelectionMode && button === 0 && !isBlockedTarget; -} - -export function createAssetMarqueeFromPointer({ - pointerId, - point, - containerRect, -}: { - pointerId: number; - point: ClientPoint; - containerRect: Pick | null | undefined; -}): AssetMarqueeState { - const startX = point.clientX - (containerRect?.left ?? 0); - const startY = point.clientY - (containerRect?.top ?? 0); - return { - pointerId, - startX, - startY, - currentX: startX, - currentY: startY, - }; -} - -export function moveAssetMarqueeToPointer({ - marquee, - point, - containerRect, -}: { - marquee: AssetMarqueeState; - point: ClientPoint; - containerRect: Pick | null | undefined; -}) { - return { - ...marquee, - currentX: point.clientX - (containerRect?.left ?? 0), - currentY: point.clientY - (containerRect?.top ?? 0), - }; -} - -export function createAssetMarqueeSelectionRect({ - marquee, - point, - containerRect, -}: { - marquee: AssetMarqueeState; - point: ClientPoint; - containerRect: Pick | null | undefined; -}): ClientRectLike { - const startClientX = (containerRect?.left ?? 0) + marquee.startX; - const startClientY = (containerRect?.top ?? 0) + marquee.startY; - return { - left: Math.min(startClientX, point.clientX), - right: Math.max(startClientX, point.clientX), - top: Math.min(startClientY, point.clientY), - bottom: Math.max(startClientY, point.clientY), - }; -} - -export function selectUploadedAssetsInRect({ - assets, - assetTargets, - selectionRect, -}: { - assets: EditorAsset[]; - assetTargets: AssetHitTarget[]; - selectionRect: ClientRectLike; -}) { - const uploadedAssetIds = new Set( - assets - .filter((asset) => asset.sourceKind === 'uploaded') - .map((asset) => asset.id), - ); - return new Set( - assetTargets - .filter( - (target) => - uploadedAssetIds.has(target.assetId) && - doRectsIntersect(target.rect, selectionRect), - ) - .map((target) => target.assetId), - ); -} - export function groupAssetsByFolder( assetFolders: EditorAssetFolder[], assets: EditorAsset[], @@ -191,14 +84,20 @@ function normalizeSearchQuery(query: string) { return query.trim().toLowerCase(); } -function matchesSearchText(query: string, values: Array) { +function matchesSearchText( + query: string, + values: Array, +) { if (!query) { return true; } return values.some((value) => value?.toLowerCase().includes(query)); } -function startsWithSearchText(query: string, values: Array) { +function startsWithSearchText( + query: string, + values: Array, +) { if (!query) { return true; } @@ -290,10 +189,6 @@ export function filterGroupedAssetsBySearch( .filter((folder) => folder.assets.length > 0); } -export function getSelectableAssets(assets: EditorAsset[]) { - return assets.filter((asset) => asset.sourceKind === 'uploaded'); -} - export type AssetShowcaseSubmissionState = { canSubmit: boolean; label: string; @@ -370,16 +265,6 @@ export function resolveAssetShowcaseSubmissionState( }; } -export function areAllSelectableAssetsSelected( - selectableAssets: EditorAsset[], - selectedAssetIds: Set, -) { - return ( - selectableAssets.length > 0 && - selectableAssets.every((asset) => selectedAssetIds.has(asset.id)) - ); -} - export function renameAssetById( assets: EditorAsset[], assetId: string, @@ -485,7 +370,9 @@ export function renameAssetFolderById( export function resolveDefaultAssetFolder(assetFolders: EditorAssetFolder[]) { return ( - assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0] ?? null + assetFolders.find((folder) => folder.systemDefault) ?? + assetFolders[0] ?? + null ); } @@ -500,7 +387,10 @@ export function resolveUploadAssetFolder(assetFolders: EditorAssetFolder[]) { export function resolvePreferredAssetUploadFolder( assetFolders: EditorAssetFolder[], ) { - return resolveUploadAssetFolder(assetFolders) ?? resolveDefaultAssetFolder(assetFolders); + return ( + resolveUploadAssetFolder(assetFolders) ?? + resolveDefaultAssetFolder(assetFolders) + ); } export function deleteAssetFolderLocally({ @@ -527,36 +417,13 @@ export function deleteAssetFolderLocally({ }; } -export function toggleAssetSelection( - selectedAssetIds: Set, - assetId: string, -) { - const nextIds = new Set(selectedAssetIds); - if (nextIds.has(assetId)) { - nextIds.delete(assetId); - } else { - nextIds.add(assetId); - } - return nextIds; -} - -export function resolveAllAssetSelection({ - allSelectableAssetsSelected, - selectableAssets, -}: { - allSelectableAssetsSelected: boolean; - selectableAssets: EditorAsset[]; -}) { - return allSelectableAssetsSelected - ? new Set() - : new Set(selectableAssets.map((asset) => asset.id)); -} - export function removeSelectedAssets( assets: EditorAsset[], selectedAssetIds: Set, ) { - const deletedAssets = assets.filter((asset) => selectedAssetIds.has(asset.id)); + const deletedAssets = assets.filter((asset) => + selectedAssetIds.has(asset.id), + ); return { assets: assets.filter((asset) => !selectedAssetIds.has(asset.id)), deletedAssets, diff --git a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx index 1b5bdab8d..ac8d673b8 100644 --- a/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx +++ b/src/components/image-editor/ImageCanvasAssetLibraryPanelView.tsx @@ -1,6 +1,7 @@ import { Check, CheckSquare, + Download, Folder, Square, Trash2, @@ -12,9 +13,11 @@ import type { RefObject, SetStateAction, } from 'react'; +import { useEffect, useState } from 'react'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar'; +import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; import { PlatformTextField } from '../common/PlatformTextField'; import { ImageCanvasAssetFolderSectionView } from './ImageCanvasAssetFolderSectionView'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; @@ -25,6 +28,10 @@ import type { EditorAssetFolder, UploadTarget, } from './ImageCanvasEditorTypes'; +import { + type AssetSelectionInteraction, + countHiddenSelectedAssets, +} from './useImageCanvasAssetSelection'; export type GroupedEditorAssetFolder = EditorAssetFolder & { assets: EditorAsset[]; @@ -49,13 +56,15 @@ export type ImageCanvasAssetLibraryPanelViewProps = { assetFolders: EditorAssetFolder[]; isAssetSelectionMode: boolean; selectedAssetIds: Set; + visibleAssetIds: string[]; assetMoveDropFolderId: string | null; pinnedAssetMoveFolderId: string | null; creatingFolder: boolean; newFolderName: string; renamingFolder: { folderId: string; value: string } | null; renamingAsset: { assetId: string; value: string } | null; - allSelectableAssetsSelected: boolean; + allVisibleAssetsSelected: boolean; + isExportingSelectedAssets: boolean; assetMarquee: AssetMarqueeState | null; setCreatingFolder: Dispatch>; setNewFolderName: Dispatch>; @@ -68,18 +77,20 @@ export type ImageCanvasAssetLibraryPanelViewProps = { setActiveUploadFolderId: Dispatch>; setUploadDropTarget: Dispatch>; setAssetPointerDrag: Dispatch>; - setSelectedAssetIds: Dispatch>>; - onAssetMarqueePointerDown: ( + onAssetMarqueePointerDown: (event: ReactPointerEvent) => void; + onAssetMarqueePointerMove: (event: ReactPointerEvent) => void; + onAssetMarqueePointerUp: (event: ReactPointerEvent) => void; + onAssetMarqueePointerCancel: ( event: ReactPointerEvent, ) => void; - onAssetMarqueePointerMove: ( - event: ReactPointerEvent, - ) => void; - onAssetMarqueePointerUp: ( + onAssetMarqueeLostPointerCapture: ( event: ReactPointerEvent, ) => void; updateAssetMoveDropFolder: (folderId: string | null) => void; - addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void; + addUploadedFiles: ( + files: FileList | File[], + options?: UploadFilesOptions, + ) => void; requestUpload: (target: UploadTarget, options?: UploadRequestOptions) => void; moveAssetToFolder: (assetId: string, folderId: string) => void; commitNewAssetFolder: () => void | Promise; @@ -91,11 +102,15 @@ export type ImageCanvasAssetLibraryPanelViewProps = { commitAssetRename: (asset: EditorAsset) => void; deleteUploadedAsset: (asset: EditorAsset) => void; submitAssetToShowcase: (asset: EditorAsset) => void; - toggleAssetSelected: (assetId: string) => void; + toggleAssetSelected: ( + assetId: string, + interaction: AssetSelectionInteraction, + ) => void; addAssetLayer: (asset: EditorAsset) => void; onDownloadAsset: (asset: EditorAsset) => void; - toggleAllAssetsSelected: () => void; - deleteSelectedAssets: () => void; + toggleAllAssetsSelected: (visibleAssetIds: string[]) => void; + downloadSelectedAssets: () => void | Promise; + deleteSelectedAssets: (assetIds?: ReadonlySet) => void; closeAssetSelectionMode: () => void; }; @@ -107,13 +122,15 @@ export function ImageCanvasAssetLibraryPanelView({ assetFolders, isAssetSelectionMode, selectedAssetIds, + visibleAssetIds, assetMoveDropFolderId, pinnedAssetMoveFolderId, creatingFolder, newFolderName, renamingFolder, renamingAsset, - allSelectableAssetsSelected, + allVisibleAssetsSelected, + isExportingSelectedAssets, assetMarquee, setCreatingFolder, setNewFolderName, @@ -122,10 +139,11 @@ export function ImageCanvasAssetLibraryPanelView({ setActiveUploadFolderId, setUploadDropTarget, setAssetPointerDrag, - setSelectedAssetIds, onAssetMarqueePointerDown, onAssetMarqueePointerMove, onAssetMarqueePointerUp, + onAssetMarqueePointerCancel, + onAssetMarqueeLostPointerCapture, updateAssetMoveDropFolder, addUploadedFiles, requestUpload, @@ -143,148 +161,246 @@ export function ImageCanvasAssetLibraryPanelView({ addAssetLayer, onDownloadAsset, toggleAllAssetsSelected, + downloadSelectedAssets, deleteSelectedAssets, closeAssetSelectionMode, }: ImageCanvasAssetLibraryPanelViewProps) { + const [hiddenSelectionDeleteSnapshot, setHiddenSelectionDeleteSnapshot] = + useState<{ + assetIds: Set; + visibleAssetIds: string[]; + hiddenAssetCount: number; + } | null>(null); + let selectionToggleActionText = '全选'; + if (allVisibleAssetsSelected) { + selectionToggleActionText = '取消全选'; + } + let selectionToggleLabel = selectionToggleActionText; + if (selectedAssetIds.size > 0) { + selectionToggleLabel = `${selectionToggleActionText} · 已选 ${selectedAssetIds.size}`; + } + const hiddenSelectedAssetCount = countHiddenSelectedAssets( + selectedAssetIds, + visibleAssetIds, + ); + + useEffect(() => { + if (!hiddenSelectionDeleteSnapshot) { + return; + } + const selectedIdsUnchanged = + hiddenSelectionDeleteSnapshot.assetIds.size === selectedAssetIds.size && + [...hiddenSelectionDeleteSnapshot.assetIds].every((assetId) => + selectedAssetIds.has(assetId), + ); + const visibleIdsUnchanged = + hiddenSelectionDeleteSnapshot.visibleAssetIds.length === + visibleAssetIds.length && + hiddenSelectionDeleteSnapshot.visibleAssetIds.every( + (assetId, index) => assetId === visibleAssetIds[index], + ); + if ( + !isAssetSelectionMode || + !selectedIdsUnchanged || + !visibleIdsUnchanged + ) { + setHiddenSelectionDeleteSnapshot(null); + } + }, [ + hiddenSelectionDeleteSnapshot, + isAssetSelectionMode, + selectedAssetIds, + visibleAssetIds, + ]); + + const requestDeleteSelectedAssets = () => { + if (hiddenSelectedAssetCount > 0) { + setHiddenSelectionDeleteSnapshot({ + assetIds: new Set(selectedAssetIds), + visibleAssetIds: [...visibleAssetIds], + hiddenAssetCount: hiddenSelectedAssetCount, + }); + return; + } + deleteSelectedAssets(); + }; + return ( -
- {pinnedAssetMoveFolderId ? ( - - ) : null} - {creatingFolder ? ( -
{ - event.preventDefault(); - void commitNewAssetFolder(); - }} - > - setNewFolderName(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.preventDefault(); +
+
+ {pinnedAssetMoveFolderId ? ( + + ) : null} + {creatingFolder ? ( + { + event.preventDefault(); + void commitNewAssetFolder(); + }} + > + setNewFolderName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + setCreatingFolder(false); + setNewFolderName(''); + } + }} + /> + + { setCreatingFolder(false); setNewFolderName(''); - } + }} + /> + + ) : null} + {groupedAssets.map((folder) => ( + + ))} + {assetMarquee ? ( + {isAssetSelectionMode ? ( toggleAllAssetsSelected(visibleAssetIds)} > - {allSelectableAssetsSelected ? ( + {allVisibleAssetsSelected ? ( ) : ( )} - {selectedAssetIds.size > 0 - ? `${allSelectableAssetsSelected ? '取消全选' : '全选'} · 已选 ${selectedAssetIds.size}` - : '全选'} + {selectionToggleLabel} void downloadSelectedAssets()} + > + + {isExportingSelectedAssets ? '下载中' : '下载'} + + 删除 + 取消 ) : null} - {assetMarquee ? ( - ); } diff --git a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx index becd864a2..cb5244150 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.test.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.test.tsx @@ -4,7 +4,10 @@ import { fireEvent, render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ImageCanvasAssetRowView } from './ImageCanvasAssetRowView'; +import { + ImageCanvasAssetRowView, + type ImageCanvasAssetRowViewProps, +} from './ImageCanvasAssetRowView'; import type { EditorAsset } from './ImageCanvasEditorTypes'; const useResolvedAssetReadUrlMock = vi.hoisted(() => vi.fn()); @@ -94,7 +97,7 @@ function renderAssetRow({ renamingAsset?: { assetId: string; value: string } | null; suppressAssetClick?: boolean; addAssetLayer?: (asset: EditorAsset) => void; - toggleAssetSelected?: (assetId: string) => void; + toggleAssetSelected?: ImageCanvasAssetRowViewProps['toggleAssetSelected']; commitAssetRename?: (asset: EditorAsset) => void; deleteUploadedAsset?: (asset: EditorAsset) => void; submitAssetToShowcase?: (asset: EditorAsset) => void; @@ -106,11 +109,11 @@ function renderAssetRow({ suppressAssetClickRef: { current: suppressAssetClick }, isAssetSelectionMode, selectedAssetIds, + visibleAssetIds: [asset.id], renamingAsset, setRenamingAsset: vi.fn(), setUploadDropTarget: vi.fn(), setAssetPointerDrag: vi.fn(), - setSelectedAssetIds: vi.fn(), updateAssetMoveDropFolder: vi.fn(), addUploadedFiles: vi.fn(), moveAssetToFolder: vi.fn(), @@ -123,8 +126,10 @@ function renderAssetRow({ onDownloadAsset, }; - render(); - return props; + return { + props, + ...render(), + }; } describe('ImageCanvasAssetRowView', () => { @@ -182,6 +187,25 @@ describe('ImageCanvasAssetRowView', () => { expect(screen.queryByRole('menu')).toBeNull(); }); + it('closes the right click menu when selection mode becomes active', () => { + const { props, rerender } = renderAssetRow(); + + fireEvent.contextMenu( + screen.getByRole('button', { name: '添加账号素材A' }), + { + clientX: 120, + clientY: 80, + }, + ); + expect(screen.getByRole('menu')).toBeTruthy(); + + rerender( + , + ); + + expect(screen.queryByRole('menu')).toBeNull(); + }); + it('selects the asset instead of adding it in selection mode', () => { const addAssetLayer = vi.fn(); const toggleAssetSelected = vi.fn(); @@ -193,8 +217,76 @@ describe('ImageCanvasAssetRowView', () => { fireEvent.click(screen.getByRole('button', { name: '选择素材账号素材A' })); - expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1'); + expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', { + visibleAssetIds: ['asset-1'], + shiftKey: false, + }); expect(addAssetLayer).not.toHaveBeenCalled(); + expect( + screen.queryByRole('button', { name: '下载素材账号素材A' }), + ).toBeNull(); + }); + + it('disables rows that are outside the shared selectable-asset boundary', () => { + const toggleAssetSelected = vi.fn(); + renderAssetRow({ + asset: createAsset({ sourceKind: 'built-in' }), + isAssetSelectionMode: true, + toggleAssetSelected, + }); + + const button = screen.getByRole('button', { + name: '素材账号素材A不可选择', + }) as HTMLButtonElement; + expect(button.disabled).toBe(true); + fireEvent.click(screen.getByText('账号素材A')); + expect(toggleAssetSelected).not.toHaveBeenCalled(); + }); + + it('forwards only the shift modifier used by selection semantics', () => { + const toggleAssetSelected = vi.fn(); + renderAssetRow({ isAssetSelectionMode: true, toggleAssetSelected }); + fireEvent.click(screen.getByText('账号素材A'), { + ctrlKey: true, + shiftKey: true, + }); + + expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', { + visibleAssetIds: ['asset-1'], + shiftKey: true, + }); + }); + + it('blocks internal moves and external file drops in selection mode', () => { + const { container, props } = renderAssetRow({ + isAssetSelectionMode: true, + }); + const row = container.querySelector( + '[data-asset-id="asset-1"]', + ); + if (!row) { + throw new Error('asset row should exist'); + } + const fileTransfer = { + types: ['Files'], + files: [new File(['image'], '新增素材.png', { type: 'image/png' })], + dropEffect: 'copy', + getData: vi.fn(() => ''), + }; + fireEvent.dragOver(row, { dataTransfer: fileTransfer }); + fireEvent.drop(row, { dataTransfer: fileTransfer }); + + const assetTransfer = { + types: ['application/x-genarrative-editor-asset'], + files: [], + dropEffect: 'move', + getData: vi.fn(() => 'asset-other'), + }; + fireEvent.dragOver(row, { dataTransfer: assetTransfer }); + fireEvent.drop(row, { dataTransfer: assetTransfer }); + + expect(props.addUploadedFiles).not.toHaveBeenCalled(); + expect(props.moveAssetToFolder).not.toHaveBeenCalled(); }); it('renders rename input and commits with Enter', () => { diff --git a/src/components/image-editor/ImageCanvasAssetRowView.tsx b/src/components/image-editor/ImageCanvasAssetRowView.tsx index 9f27b5d2f..77441bcf0 100644 --- a/src/components/image-editor/ImageCanvasAssetRowView.tsx +++ b/src/components/image-editor/ImageCanvasAssetRowView.tsx @@ -1,5 +1,10 @@ import { Check, Download, Pencil, Video, X } from 'lucide-react'; -import type { Dispatch, SetStateAction } from 'react'; +import type { + Dispatch, + MouseEvent as ReactMouseEvent, + ReactNode, + SetStateAction, +} from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; @@ -24,6 +29,10 @@ import { getCanvasMediaPreviewClassName, getCanvasMediaPreviewMarker, } from './ImageCanvasMediaModel'; +import { + type AssetSelectionInteraction, + isSelectableAsset, +} from './useImageCanvasAssetSelection'; const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png'; @@ -33,13 +42,13 @@ export type ImageCanvasAssetRowViewProps = { suppressAssetClickRef: { current: boolean }; isAssetSelectionMode: boolean; selectedAssetIds: Set; + visibleAssetIds: string[]; renamingAsset: { assetId: string; value: string } | null; setRenamingAsset: Dispatch< SetStateAction<{ assetId: string; value: string } | null> >; setUploadDropTarget: Dispatch>; setAssetPointerDrag: Dispatch>; - setSelectedAssetIds: Dispatch>>; updateAssetMoveDropFolder: (folderId: string | null) => void; addUploadedFiles: ( files: FileList | File[], @@ -50,7 +59,10 @@ export type ImageCanvasAssetRowViewProps = { commitAssetRename: (asset: EditorAsset) => void; deleteUploadedAsset: (asset: EditorAsset) => void; submitAssetToShowcase: (asset: EditorAsset) => void; - toggleAssetSelected: (assetId: string) => void; + toggleAssetSelected: ( + assetId: string, + interaction: AssetSelectionInteraction, + ) => void; addAssetLayer: (asset: EditorAsset) => void; onDownloadAsset: (asset: EditorAsset) => void; }; @@ -61,11 +73,11 @@ export function ImageCanvasAssetRowView({ suppressAssetClickRef, isAssetSelectionMode, selectedAssetIds, + visibleAssetIds, renamingAsset, setRenamingAsset, setUploadDropTarget, setAssetPointerDrag, - setSelectedAssetIds, updateAssetMoveDropFolder, addUploadedFiles, moveAssetToFolder, @@ -85,6 +97,7 @@ export function ImageCanvasAssetRowView({ const isRenaming = renamingAsset?.assetId === asset.id; const isUploadingAsset = asset.uploadStatus === 'uploading'; const isFailedUpload = asset.uploadStatus === 'failed'; + const canSelectAsset = isSelectableAsset(asset); const showcaseReviewStatus = asset.showcaseReviewStatus ?? null; const showcaseSubmissionState = resolveAssetShowcaseSubmissionState(asset); const canSubmitShowcaseAsset = showcaseSubmissionState.canSubmit; @@ -95,6 +108,38 @@ export function ImageCanvasAssetRowView({ setMenuPosition(null); }, []); + useEffect(() => { + if (isAssetSelectionMode) { + closeMenu(); + } + }, [closeMenu, isAssetSelectionMode]); + + const selectAssetFromEvent = useCallback( + (event: ReactMouseEvent) => { + if ( + !isAssetSelectionMode || + !canSelectAsset || + suppressAssetClickRef.current + ) { + return; + } + closeMenu(); + toggleAssetSelected(asset.id, { + visibleAssetIds, + shiftKey: event.shiftKey, + }); + }, + [ + asset.id, + canSelectAsset, + closeMenu, + isAssetSelectionMode, + suppressAssetClickRef, + toggleAssetSelected, + visibleAssetIds, + ], + ); + useEffect(() => { if (!menuPosition) { return undefined; @@ -158,6 +203,16 @@ export function ImageCanvasAssetRowView({
) : undefined; + let primaryLabel = `添加${asset.label}`; + if (isUploadingAsset) { + primaryLabel = `上传中${asset.label}`; + } else if (isFailedUpload) { + primaryLabel = `上传失败${asset.label}`; + } else if (isAssetSelectionMode) { + primaryLabel = canSelectAsset + ? `选择素材${asset.label}` + : `素材${asset.label}不可选择`; + } const titleNode = isRenaming ? ( ) : undefined; - const actions = isUploadingAsset ? ( -
- {asset.uploadMessage ?? '上传中'} - {Math.round(uploadProgress)}% -
- ) : isRenaming ? ( -
- commitAssetRename(asset)} - /> - setRenamingAsset(null)} - /> -
- ) : ( -
- { - closeMenu(); - onDownloadAsset(asset); - }} - /> - { - closeMenu(); - startRenamingAsset(asset); - }} - /> -
- ); + let actions: ReactNode; + if (isAssetSelectionMode) { + actions = undefined; + } else if (isUploadingAsset) { + actions = ( +
+ {asset.uploadMessage ?? '上传中'} + {Math.round(uploadProgress)}% +
+ ); + } else if (isRenaming) { + actions = ( +
+ commitAssetRename(asset)} + /> + setRenamingAsset(null)} + /> +
+ ); + } else { + actions = ( +
+ { + closeMenu(); + onDownloadAsset(asset); + }} + /> + { + closeMenu(); + startRenamingAsset(asset); + }} + /> +
+ ); + } return (
{ + if (isAssetSelectionMode) { + event.preventDefault(); + event.stopPropagation(); + return; + } if (isUploadingAsset || isFailedUpload || isRenaming) { return; } @@ -244,15 +314,8 @@ export function ImageCanvasAssetRowView({ title={asset.label} detail={mediaDetail} imageAlt={`素材:${asset.label}`} - primaryLabel={ - isUploadingAsset - ? `上传中${asset.label}` - : isFailedUpload - ? `上传失败${asset.label}` - : isAssetSelectionMode - ? `选择素材${asset.label}` - : `添加${asset.label}` - } + primaryLabel={primaryLabel} + primaryDisabled={isAssetSelectionMode && !canSelectAsset} onPrimaryClick={() => { if (isUploadingAsset || isFailedUpload) { return; @@ -262,12 +325,11 @@ export function ImageCanvasAssetRowView({ return; } if (isAssetSelectionMode) { - toggleAssetSelected(asset.id); return; } addAssetLayer(asset); }} - selected={selectedAssetIds.has(asset.id)} + selected={isAssetSelectionMode && selectedAssetIds.has(asset.id)} rowClassName={[ 'image-canvas-editor__asset-row', isUploadingAsset ? 'image-canvas-editor__asset-row--uploading' : '', @@ -295,7 +357,12 @@ export function ImageCanvasAssetRowView({ ) } actions={actions} - draggable={!isRenaming && !isUploadingAsset && !isFailedUpload} + draggable={ + !isAssetSelectionMode && + !isRenaming && + !isUploadingAsset && + !isFailedUpload + } previewOverlay={ isUploadingAsset ? (
@@ -325,7 +392,12 @@ export function ImageCanvasAssetRowView({ ) : undefined } onDragStart={(event) => { - if (isRenaming || isUploadingAsset || isFailedUpload) { + if ( + isAssetSelectionMode || + isRenaming || + isUploadingAsset || + isFailedUpload + ) { event.preventDefault(); return; } @@ -344,16 +416,13 @@ export function ImageCanvasAssetRowView({ updateAssetMoveDropFolder(null); }} onPointerDown={(event) => { + if (isAssetSelectionMode) { + return; + } if (event.button !== 0) { return; } const target = event.target as HTMLElement; - if (isAssetSelectionMode) { - if (target.closest('button')) { - return; - } - return; - } if ( isRenaming || isUploadingAsset || @@ -390,16 +459,18 @@ export function ImageCanvasAssetRowView({ assetPointerDragRef.current = nextDrag; setAssetPointerDrag(nextDrag); }} - onPointerEnter={(event) => { - if (isAssetSelectionMode && event.buttons === 1) { - setSelectedAssetIds((currentIds) => { - const nextIds = new Set(currentIds); - nextIds.add(asset.id); - return nextIds; - }); - } - }} onDragOver={(event) => { + if (isAssetSelectionMode) { + if ( + hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE) || + hasDataTransferType(event.dataTransfer, 'Files') + ) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'none'; + } + return; + } if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) { event.preventDefault(); event.stopPropagation(); @@ -416,6 +487,18 @@ export function ImageCanvasAssetRowView({ } }} onDrop={(event) => { + if (isAssetSelectionMode) { + if ( + getDraggedAssetId(event.dataTransfer) || + event.dataTransfer.files.length > 0 + ) { + event.preventDefault(); + event.stopPropagation(); + setUploadDropTarget(null); + updateAssetMoveDropFolder(null); + } + return; + } const movingAssetId = getDraggedAssetId(event.dataTransfer); if (movingAssetId) { event.preventDefault(); diff --git a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx index 70f928fa1..929d3da37 100644 --- a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx @@ -80,10 +80,9 @@ vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ })); vi.mock('../../services/assetReadUrlService', async () => { - const actual = - await vi.importActual( - '../../services/assetReadUrlService', - ); + const actual = await vi.importActual< + typeof import('../../services/assetReadUrlService') + >('../../services/assetReadUrlService'); return { ...actual, getSignedAssetReadUrl: ( @@ -141,6 +140,7 @@ function createTestImageAsset({ imageSrc, objectKey, assetObjectId, + folderId = 'project', sourceType = 'uploaded', sourceResourceId, }: { @@ -149,6 +149,7 @@ function createTestImageAsset({ imageSrc?: string; objectKey?: string | null; assetObjectId?: string | null; + folderId?: string; sourceType?: 'uploaded' | 'generated' | 'mock_generated'; sourceResourceId?: string | null; }) { @@ -158,7 +159,7 @@ function createTestImageAsset({ : objectKey; return { assetId, - folderId: 'project', + folderId, label, imageSrc: imageSrc ?? (resolvedObjectKey ? `/${resolvedObjectKey}` : ''), objectKey: resolvedObjectKey, @@ -339,7 +340,7 @@ describe('ImageCanvasEditorView asset library integration', () => { ); await waitFor(() => { - expect(downloadName).toBe('拼图素材.webp'); + expect(downloadName).toMatch(/^拼图素材-\d{8}-\d{6}\.webp$/u); }); expect(downloadHref).toBe('blob:asset-library-download'); } finally { @@ -385,8 +386,9 @@ describe('ImageCanvasEditorView asset library integration', () => { ], }); const originalFetch = globalThis.fetch; - const fetchMock = vi.fn(async (..._args: Parameters) => - new Response(new Blob(['frame'], { type: 'image/png' })), + const fetchMock = vi.fn( + async (..._args: Parameters) => + new Response(new Blob(['frame'], { type: 'image/png' })), ); globalThis.fetch = fetchMock as typeof fetch; let downloadName = ''; @@ -415,7 +417,7 @@ describe('ImageCanvasEditorView asset library integration', () => { ); await waitFor(() => { - expect(downloadName).toBe('角色挥手-SpineJSON.zip'); + expect(downloadName).toMatch(/^角色挥手-SpineJSON-\d{8}-\d{6}\.zip$/u); }); const fetchedUrls = fetchMock.mock.calls.map(([url]) => url); expect(fetchedUrls).toContain('/generated/action/frame01.png'); @@ -590,6 +592,61 @@ describe('ImageCanvasEditorView asset library integration', () => { expect(createEditorAssetMock).not.toHaveBeenCalled(); }); + it('clears drag targets and blocks folder drops after entering asset selection mode', async () => { + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + { + folderId: 'folder-role', + label: '角色', + sortOrder: 100, + collapsed: false, + systemDefault: false, + }, + ], + assets: [ + createTestImageAsset({ assetId: 'asset-puzzle', label: '拼图素材' }), + ], + }); + render(); + + const roleFolder = await screen.findByRole('region', { name: '角色' }); + const internalTransfer = createDataTransferStub(); + internalTransfer.setData( + 'application/x-genarrative-editor-asset', + 'asset-puzzle', + ); + fireEvent.dragOver(roleFolder, { dataTransfer: internalTransfer }); + expect(roleFolder.className).toContain( + 'image-canvas-editor__asset-folder--move-target', + ); + + fireEvent.click(screen.getByRole('button', { name: '素材选择模式' })); + await waitFor(() => { + expect(roleFolder.className).not.toContain( + 'image-canvas-editor__asset-folder--move-target', + ); + }); + fireEvent.drop(roleFolder, { dataTransfer: internalTransfer }); + + const fileTransfer = createDataTransferStub(); + fileTransfer.types.push('Files'); + (fileTransfer.files as File[]).push( + new File(['image'], '新增素材.png', { type: 'image/png' }), + ); + fireEvent.dragOver(roleFolder, { dataTransfer: fileTransfer }); + fireEvent.drop(roleFolder, { dataTransfer: fileTransfer }); + + expect(updateEditorAssetMock).not.toHaveBeenCalled(); + expect(createEditorAssetMock).not.toHaveBeenCalled(); + }); + it('uploads multiple files as account-level assets without adding canvas layers', async () => { render(); @@ -641,13 +698,15 @@ describe('ImageCanvasEditorView asset library integration', () => { it('uploads MP3 and MP4 files through OSS before adding them to the asset library', async () => { const createdMediaElements: HTMLMediaElement[] = []; const originalCreateElement = document.createElement.bind(document); - vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => { - const element = originalCreateElement(tagName, options); - if (tagName === 'video' || tagName === 'audio') { - createdMediaElements.push(element as HTMLMediaElement); - } - return element; - }); + vi.spyOn(document, 'createElement').mockImplementation( + (tagName, options) => { + const element = originalCreateElement(tagName, options); + if (tagName === 'video' || tagName === 'audio') { + createdMediaElements.push(element as HTMLMediaElement); + } + return element; + }, + ); Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:editor-media'), @@ -686,8 +745,12 @@ describe('ImageCanvasEditorView asset library integration', () => { ); }); await waitFor(() => { - expect(screen.getByRole('button', { name: '添加胜利音效.mp3' })).toBeTruthy(); - expect(screen.getByRole('button', { name: '添加开场动画.mp4' })).toBeTruthy(); + expect( + screen.getByRole('button', { name: '添加胜利音效.mp3' }), + ).toBeTruthy(); + expect( + screen.getByRole('button', { name: '添加开场动画.mp4' }), + ).toBeTruthy(); }); expect(createEditorAssetMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -887,6 +950,176 @@ describe('ImageCanvasEditorView asset library integration', () => { ).toBeNull(); }); + it('adds visible ranges to the stable selection and clears state on exit', async () => { + const user = userEvent.setup(); + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }), + createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }), + createTestImageAsset({ assetId: 'asset-c', label: '账号素材C' }), + ], + }); + render(); + + await screen.findByRole('button', { name: '添加账号素材A' }); + await user.click(screen.getByRole('button', { name: '素材选择模式' })); + fireEvent.click(screen.getByText('账号素材A')); + fireEvent.click(screen.getByText('账号素材C'), { + ctrlKey: true, + }); + expect( + within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText( + /已选 2/u, + ), + ).toBeTruthy(); + + fireEvent.click(screen.getByText('账号素材B'), { + shiftKey: true, + }); + expect( + within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText( + /已选 3/u, + ), + ).toBeTruthy(); + + fireEvent.click(screen.getByText('账号素材A'), { + ctrlKey: true, + shiftKey: true, + }); + expect( + within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText( + /已选 3/u, + ), + ).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '素材选择模式' })); + expect(screen.queryByRole('toolbar', { name: '素材批量操作' })).toBeNull(); + expect(screen.getByRole('button', { name: '添加账号素材A' })).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '素材选择模式' })); + expect( + within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole( + 'button', + { name: '删除' }, + ), + ).toHaveProperty('disabled', true); + }); + + it('keeps hidden selections stable and applies visible actions as deltas', async () => { + const user = userEvent.setup(); + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + { + folderId: 'folder-other', + label: '其它素材', + sortOrder: 1, + collapsed: false, + systemDefault: false, + }, + ], + assets: [ + createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }), + createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }), + createTestImageAsset({ + assetId: 'asset-c', + label: '账号素材C', + folderId: 'folder-other', + }), + ], + }); + render(); + + await screen.findByRole('button', { name: '添加账号素材A' }); + await user.click(screen.getByRole('button', { name: '素材选择模式' })); + fireEvent.click(screen.getByText('账号素材C')); + fireEvent.click(screen.getByText('账号素材A')); + fireEvent.click(screen.getByText('账号素材B'), { shiftKey: true }); + + const toolbar = screen.getByRole('toolbar', { name: '素材批量操作' }); + expect(within(toolbar).getByText(/已选 3/u)).toBeTruthy(); + + fireEvent.change(screen.getByLabelText('搜索素材'), { + target: { value: '账号素材A' }, + }); + expect(screen.queryByText('账号素材B')).toBeNull(); + expect(within(toolbar).getByText(/已选 3/u)).toBeTruthy(); + + fireEvent.click(screen.getByText('账号素材A')); + expect(within(toolbar).getByText(/已选 2/u)).toBeTruthy(); + fireEvent.click(within(toolbar).getByRole('button', { name: /^全选/u })); + expect(within(toolbar).getByText(/已选 3/u)).toBeTruthy(); + fireEvent.click( + within(toolbar).getByRole('button', { name: /^取消全选/u }), + ); + expect(within(toolbar).getByText(/已选 2/u)).toBeTruthy(); + + fireEvent.change(screen.getByLabelText('搜索素材'), { + target: { value: '' }, + }); + await user.click(screen.getByRole('button', { name: '折叠其它素材' })); + expect(screen.getByRole('button', { name: '展开其它素材' })).toBeTruthy(); + const foldedAssetList = screen + .getByRole('button', { name: '选择素材账号素材C', hidden: true }) + .closest('.image-canvas-editor__asset-folder-list'); + expect(foldedAssetList).toHaveProperty('hidden', true); + expect(within(toolbar).getByText(/已选 2/u)).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '展开其它素材' })); + expect(foldedAssetList).toHaveProperty('hidden', false); + expect(within(toolbar).getByText(/已选 2/u)).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '折叠其它素材' })); + await user.click(within(toolbar).getByRole('button', { name: '删除' })); + const hiddenDeleteDialog = screen.getByRole('dialog', { + name: '删除全部已选素材', + }); + expect( + within(hiddenDeleteDialog).getByText( + '将删除 2 个已选素材,其中 1 个当前未显示。', + ), + ).toBeTruthy(); + expect(deleteEditorAssetMock).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText('搜索素材'), { + target: { value: '账号素材B' }, + }); + await waitFor(() => { + expect( + screen.queryByRole('dialog', { name: '删除全部已选素材' }), + ).toBeNull(); + }); + expect(deleteEditorAssetMock).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText('搜索素材'), { + target: { value: '' }, + }); + await user.click(within(toolbar).getByRole('button', { name: '删除' })); + const refreshedHiddenDeleteDialog = screen.getByRole('dialog', { + name: '删除全部已选素材', + }); + await user.click( + within(refreshedHiddenDeleteDialog).getByRole('button', { + name: '确认删除全部已选素材', + }), + ); + expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b'); + expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-c'); + expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-a'); + }); + it('removes canvas layers linked to deleted assets', async () => { const user = userEvent.setup(); loadEditorAssetLibraryMock.mockResolvedValueOnce({ @@ -1638,9 +1871,7 @@ describe('ImageCanvasEditorView asset library integration', () => { dataTransfer, }); - expect( - await screen.findByAltText('画布序列帧:角色奔跑'), - ).toBeTruthy(); + expect(await screen.findByAltText('画布序列帧:角色奔跑')).toBeTruthy(); expect( screen.getByRole('button', { name: '暂停角色奔跑序列帧播放' }), ).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasEditorPrimitives.tsx b/src/components/image-editor/ImageCanvasEditorPrimitives.tsx index c5d8f7c00..f1807447b 100644 --- a/src/components/image-editor/ImageCanvasEditorPrimitives.tsx +++ b/src/components/image-editor/ImageCanvasEditorPrimitives.tsx @@ -63,7 +63,8 @@ export type SidebarMediaItemProps = { imageAlt: string; selected?: boolean; primaryLabel: string; - onPrimaryClick: () => void; + primaryDisabled?: boolean; + onPrimaryClick: MouseEventHandler; thumbnailClassName: string; metaClassName: string; rowClassName: string; @@ -91,6 +92,7 @@ export function SidebarMediaItem({ imageAlt, selected = false, primaryLabel, + primaryDisabled = false, onPrimaryClick, thumbnailClassName, metaClassName, @@ -127,6 +129,7 @@ export function SidebarMediaItem({ className={primaryClassName} onClick={onPrimaryClick} aria-label={primaryLabel} + disabled={primaryDisabled} draggable={draggable} onDragStart={onDragStart} onDragEnd={onDragEnd} diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index a83bc9c28..a5f337aa4 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -68,9 +68,8 @@ function createSidebarProps(): ImageCanvasSidebarViewProps { newFolderName: '', renamingFolder: null, renamingAsset: null, - allSelectableAssetsSelected: false, + isExportingSelectedAssets: false, assetMarquee: null, - setIsAssetSelectionMode: vi.fn(), setCreatingFolder: vi.fn(), setNewFolderName: vi.fn(), setRenamingFolder: vi.fn(), @@ -78,12 +77,13 @@ function createSidebarProps(): ImageCanvasSidebarViewProps { setActiveUploadFolderId: vi.fn(), setUploadDropTarget: vi.fn(), setAssetPointerDrag: vi.fn(), - setSelectedAssetIds: vi.fn(), setImageContextMenu: vi.fn(), setContextMenu: vi.fn(), onAssetMarqueePointerDown: vi.fn(), onAssetMarqueePointerMove: vi.fn(), onAssetMarqueePointerUp: vi.fn(), + onAssetMarqueePointerCancel: vi.fn(), + onAssetMarqueeLostPointerCapture: vi.fn(), updateAssetMoveDropFolder: vi.fn(), addUploadedFiles: vi.fn(), requestUpload: vi.fn(), @@ -101,7 +101,9 @@ function createSidebarProps(): ImageCanvasSidebarViewProps { addAssetLayer: vi.fn(), onDownloadAsset: vi.fn(), toggleAllAssetsSelected: vi.fn(), + downloadSelectedAssets: vi.fn(), deleteSelectedAssets: vi.fn(), + openAssetSelectionMode: vi.fn(), closeAssetSelectionMode: vi.fn(), groupSelectedLayers: vi.fn(), selectSingleLayer: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index b772ba004..abb4efa65 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1253,7 +1253,9 @@ describe('ImageCanvasEditorView', () => { await waitFor(() => { expect(exportedBlob).toBeTruthy(); }); - expect(downloadName).toMatch(/^导出项目-画布素材-\d{8}\.zip$/u); + expect(downloadName).toMatch( + /^导出项目-画布素材-\d{8}-\d{6}\.zip$/u, + ); const zip = await JSZip.loadAsync(exportedBlob!); expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy(); @@ -1493,7 +1495,7 @@ describe('ImageCanvasEditorView', () => { fireEvent.click(screen.getByRole('button', { name: '下载按钮' })); await waitFor(() => { - expect(downloadName).toBe('拼图素材.webp'); + expect(downloadName).toMatch(/^拼图素材-\d{8}-\d{6}\.webp$/u); }); expect(downloadHref).toBe('blob:layer-download'); expect(globalThis.fetch).toHaveBeenCalledWith( diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 9f6e050fe..ce074dbbf 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -675,7 +675,6 @@ export function ImageCanvasEditorView({ assets, setAssets, groupedAssets, - allSelectableAssetsSelected, renamingAsset, setRenamingAsset, renamingFolder, @@ -687,9 +686,8 @@ export function ImageCanvasEditorView({ activeUploadFolderId, setActiveUploadFolderId, isAssetSelectionMode, - setIsAssetSelectionMode, selectedAssetIds, - setSelectedAssetIds, + selectedAssets, assetMarquee, assetPointerDrag, setAssetPointerDrag, @@ -711,17 +709,28 @@ export function ImageCanvasEditorView({ deleteSelectedAssets, moveAssetToFolder, closeAssetSelectionMode, + openAssetSelectionMode, handleAssetMarqueePointerDown, handleAssetMarqueePointerMove, handleAssetMarqueePointerUp, + handleAssetMarqueePointerCancel, + handleAssetMarqueeLostPointerCapture, refreshAssetLibrary, } = useImageCanvasAssetLibrary({ assetListRef, + assetPointerDragRef, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, openEditorLoginModal, onDeleteAssets: removeCanvasLayersLinkedToAssets, + onOpenSelectionMode: () => setUploadDropTarget(null), }); + useEffect(() => { + if (activeSidebarPanel !== 'assets' && isAssetSelectionMode) { + closeAssetSelectionMode(); + } + }, [activeSidebarPanel, closeAssetSelectionMode, isAssetSelectionMode]); + const pendingImageReferenceUploadCountRef = useRef(0); const isPendingReferenceUploadContextMutationLocked = useCallback( () => pendingImageReferenceUploadCountRef.current > 0, @@ -1357,6 +1366,7 @@ export function ImageCanvasEditorView({ assetExportStatus, isExportingAssets, exportCanvasAssets, + exportSelectedAssets, exportLayerImage, reportAssetError, } = useImageCanvasAssetExportWorkflow({ @@ -1370,6 +1380,13 @@ export function ImageCanvasEditorView({ }, [exportLayerImage], ); + const downloadSelectedAssets = useCallback(() => { + const selectedLayers = selectedAssets.map((asset, index) => ({ + ...createAssetActionLayer(asset), + zIndex: index, + })); + return exportSelectedAssets(selectedLayers); + }, [exportSelectedAssets, selectedAssets]); const { uploadInputRef, uploadTarget, @@ -2437,9 +2454,8 @@ export function ImageCanvasEditorView({ newFolderName, renamingFolder, renamingAsset, - allSelectableAssetsSelected, + isExportingSelectedAssets: isExportingAssets, assetMarquee, - setIsAssetSelectionMode, setCreatingFolder, setNewFolderName, setRenamingFolder, @@ -2447,12 +2463,13 @@ export function ImageCanvasEditorView({ setActiveUploadFolderId, setUploadDropTarget, setAssetPointerDrag, - setSelectedAssetIds, setImageContextMenu, setContextMenu, onAssetMarqueePointerDown: handleAssetMarqueePointerDown, onAssetMarqueePointerMove: handleAssetMarqueePointerMove, onAssetMarqueePointerUp: handleAssetMarqueePointerUp, + onAssetMarqueePointerCancel: handleAssetMarqueePointerCancel, + onAssetMarqueeLostPointerCapture: handleAssetMarqueeLostPointerCapture, updateAssetMoveDropFolder, addUploadedFiles, requestUpload, @@ -2470,8 +2487,10 @@ export function ImageCanvasEditorView({ addAssetLayer, onDownloadAsset: downloadAsset, toggleAllAssetsSelected, + downloadSelectedAssets, deleteSelectedAssets, closeAssetSelectionMode, + openAssetSelectionMode, groupSelectedLayers, selectSingleLayer, resolveContextMenuPosition, diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index fa60de457..9a1e95e04 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -21,6 +21,12 @@ import { } from './ImageCanvasGenerationModel'; describe('ImageCanvasExportModel', () => { + it('formats export timestamps through seconds', () => { + expect(formatExportDate(new Date(2026, 6, 27, 9, 8, 7))).toBe( + '20260727-090807', + ); + }); + it('builds a real animated GIF preview from sequence frames', () => { const gifBytes = buildAnimatedGifPreviewBytes({ width: 2, @@ -68,8 +74,8 @@ describe('ImageCanvasExportModel', () => { '角色 草图', ); expect(sanitizeExportFilePart(' ', 'fallback')).toBe('fallback'); - expect(formatExportDate(new Date('2026-06-17T01:02:03.000Z'))).toBe( - '20260617', + expect(formatExportDate(new Date(2026, 5, 17, 9, 2, 3))).toBe( + '20260617-090203', ); }); @@ -104,7 +110,26 @@ describe('ImageCanvasExportModel', () => { }, ], }), - ).toBe('image-sequence:sequence-task'); + ).toBe('image-sequence:task:sequence-task'); + expect( + getLayerExportKey({ + ...buildLayer(), + mediaType: 'image-sequence', + taskId: null, + sourceResourceId: null, + src: '', + imageSequenceFrames: [ + { + imageSrc: '', + objectKey: 'private/animation-a/frame-01.png', + width: 1024, + height: 1024, + }, + ], + }), + ).toBe( + 'image-sequence:frames:["object-key:private/animation-a/frame-01.png"]', + ); }); it('detects image extensions from content type before falling back to src', () => { @@ -307,7 +332,7 @@ describe('ImageCanvasExportModel', () => { try { const blob = await readLayerAssetBlob( buildLayer({ - src: '/generated-editor-videos/video.mp4', + src: '', mediaType: 'video', objectKey: 'generated/video.mp4', }), diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts index d82d968d7..dfdd5f479 100644 --- a/src/components/image-editor/ImageCanvasExportModel.ts +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -27,17 +27,30 @@ export function formatExportDate(date: Date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); - return `${year}${month}${day}`; + const hour = String(date.getHours()).padStart(2, '0'); + const minute = String(date.getMinutes()).padStart(2, '0'); + const second = String(date.getSeconds()).padStart(2, '0'); + return `${year}${month}${day}-${hour}${minute}${second}`; } export function getLayerExportKey(layer: CanvasLayer) { if (layer.mediaType === 'image-sequence') { - return `image-sequence:${ - layer.taskId || - layer.sourceResourceId || - layer.imageSequenceFrames?.map((frame) => frame.imageSrc).join('|') || - layer.src - }`; + if (layer.taskId) { + return `image-sequence:task:${layer.taskId}`; + } + if (layer.sourceResourceId) { + return `image-sequence:resource:${layer.sourceResourceId}`; + } + const frameIdentities = layer.imageSequenceFrames?.map((frame) => + frame.assetObjectId + ? `asset-object:${frame.assetObjectId}` + : frame.objectKey + ? `object-key:${frame.objectKey}` + : `source:${frame.imageSrc}`, + ); + return frameIdentities?.length + ? `image-sequence:frames:${JSON.stringify(frameIdentities)}` + : `image-sequence:source:${layer.src}`; } return ( layer.assetObjectId || diff --git a/src/components/image-editor/ImageCanvasSidebarView.test.tsx b/src/components/image-editor/ImageCanvasSidebarView.test.tsx index 0cc8e360f..5522ba6dc 100644 --- a/src/components/image-editor/ImageCanvasSidebarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSidebarView.test.tsx @@ -87,9 +87,8 @@ function createSidebarProps( newFolderName: '', renamingFolder: null, renamingAsset: null, - allSelectableAssetsSelected: false, + isExportingSelectedAssets: false, assetMarquee: null, - setIsAssetSelectionMode: vi.fn(), setCreatingFolder: vi.fn(), setNewFolderName: vi.fn(), setRenamingFolder: vi.fn(), @@ -97,12 +96,13 @@ function createSidebarProps( setActiveUploadFolderId: vi.fn(), setUploadDropTarget: vi.fn(), setAssetPointerDrag: vi.fn(), - setSelectedAssetIds: vi.fn(), setImageContextMenu: vi.fn(), setContextMenu: vi.fn(), onAssetMarqueePointerDown: vi.fn(), onAssetMarqueePointerMove: vi.fn(), onAssetMarqueePointerUp: vi.fn(), + onAssetMarqueePointerCancel: vi.fn(), + onAssetMarqueeLostPointerCapture: vi.fn(), updateAssetMoveDropFolder: vi.fn(), addUploadedFiles: vi.fn(), requestUpload: vi.fn(), @@ -120,7 +120,9 @@ function createSidebarProps( addAssetLayer: vi.fn(), onDownloadAsset: vi.fn(), toggleAllAssetsSelected: vi.fn(), + downloadSelectedAssets: vi.fn(), deleteSelectedAssets: vi.fn(), + openAssetSelectionMode: vi.fn(), closeAssetSelectionMode: vi.fn(), groupSelectedLayers: vi.fn(), selectSingleLayer: vi.fn(), @@ -269,6 +271,65 @@ describe('ImageCanvasSidebarView', () => { ).toBeNull(); }); + it('keeps the batch toolbar outside the scrolling list and downloads the stable selection', () => { + const downloadSelectedAssets = vi.fn(); + const closeAssetSelectionMode = vi.fn(); + const asset = createAsset(); + render( + , + ); + + const toolbar = screen.getByRole('toolbar', { name: '素材批量操作' }); + const sidebar = screen.getByRole('complementary', { name: '图片资源栏' }); + const assetList = document.querySelector( + '.image-canvas-editor__asset-list', + ); + expect(sidebar.className).toContain( + 'image-canvas-editor__sidebar--asset-selection', + ); + expect(toolbar.parentElement).toBe(assetList?.parentElement); + expect(assetList?.contains(toolbar)).toBe(false); + expect( + screen.queryByRole('button', { name: '下载素材账号素材A' }), + ).toBeNull(); + + fireEvent.click(within(toolbar).getByRole('button', { name: '下载' })); + expect(downloadSelectedAssets).toHaveBeenCalledWith(); + fireEvent.click(screen.getByRole('button', { name: '素材选择模式' })); + expect(closeAssetSelectionMode).toHaveBeenCalledTimes(1); + }); + + it('opens selection mode through the shared lifecycle', () => { + const openAssetSelectionMode = vi.fn(); + const folder = createFolder(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: '素材选择模式' })); + expect(openAssetSelectionMode).toHaveBeenCalledTimes(1); + }); + it('does not expose internal models or providers through asset search', () => { const folder = createFolder(); const internalAsset = createAsset({ diff --git a/src/components/image-editor/ImageCanvasSidebarView.tsx b/src/components/image-editor/ImageCanvasSidebarView.tsx index 2fb1e8043..7d3dc680b 100644 --- a/src/components/image-editor/ImageCanvasSidebarView.tsx +++ b/src/components/image-editor/ImageCanvasSidebarView.tsx @@ -1,8 +1,4 @@ -import { - CheckSquare, - FolderPlus, - Square, -} from 'lucide-react'; +import { CheckSquare, FolderPlus, Square } from 'lucide-react'; import type { Dispatch, PointerEvent as ReactPointerEvent, @@ -36,6 +32,10 @@ import type { UploadTarget, } from './ImageCanvasEditorTypes'; import { ImageCanvasLayerPanelView } from './ImageCanvasLayerPanelView'; +import { + type AssetSelectionInteraction, + getSelectableAssets, +} from './useImageCanvasAssetSelection'; export type { GroupedEditorAssetFolder, UploadFilesOptions }; @@ -58,9 +58,8 @@ export type ImageCanvasSidebarViewProps = { newFolderName: string; renamingFolder: { folderId: string; value: string } | null; renamingAsset: { assetId: string; value: string } | null; - allSelectableAssetsSelected: boolean; + isExportingSelectedAssets: boolean; assetMarquee: AssetMarqueeState | null; - setIsAssetSelectionMode: Dispatch>; setCreatingFolder: Dispatch>; setNewFolderName: Dispatch>; setRenamingFolder: Dispatch< @@ -72,20 +71,22 @@ export type ImageCanvasSidebarViewProps = { setActiveUploadFolderId: Dispatch>; setUploadDropTarget: Dispatch>; setAssetPointerDrag: Dispatch>; - setSelectedAssetIds: Dispatch>>; setImageContextMenu: Dispatch>; setContextMenu: Dispatch>; - onAssetMarqueePointerDown: ( + onAssetMarqueePointerDown: (event: ReactPointerEvent) => void; + onAssetMarqueePointerMove: (event: ReactPointerEvent) => void; + onAssetMarqueePointerUp: (event: ReactPointerEvent) => void; + onAssetMarqueePointerCancel: ( event: ReactPointerEvent, ) => void; - onAssetMarqueePointerMove: ( - event: ReactPointerEvent, - ) => void; - onAssetMarqueePointerUp: ( + onAssetMarqueeLostPointerCapture: ( event: ReactPointerEvent, ) => void; updateAssetMoveDropFolder: (folderId: string | null) => void; - addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void; + addUploadedFiles: ( + files: FileList | File[], + options?: UploadFilesOptions, + ) => void; requestUpload: (target: UploadTarget, options?: UploadRequestOptions) => void; moveAssetToFolder: (assetId: string, folderId: string) => void; commitNewAssetFolder: () => void | Promise; @@ -97,11 +98,16 @@ export type ImageCanvasSidebarViewProps = { commitAssetRename: (asset: EditorAsset) => void; deleteUploadedAsset: (asset: EditorAsset) => void; submitAssetToShowcase: (asset: EditorAsset) => void; - toggleAssetSelected: (assetId: string) => void; + toggleAssetSelected: ( + assetId: string, + interaction: AssetSelectionInteraction, + ) => void; addAssetLayer: (asset: EditorAsset) => void; onDownloadAsset: (asset: EditorAsset) => void; - toggleAllAssetsSelected: () => void; - deleteSelectedAssets: () => void; + toggleAllAssetsSelected: (visibleAssetIds: string[]) => void; + downloadSelectedAssets: () => void | Promise; + deleteSelectedAssets: (assetIds?: ReadonlySet) => void; + openAssetSelectionMode: () => void; closeAssetSelectionMode: () => void; groupSelectedLayers: () => void; selectSingleLayer: (layerId: string | null) => void; @@ -134,9 +140,8 @@ export function ImageCanvasSidebarView({ newFolderName, renamingFolder, renamingAsset, - allSelectableAssetsSelected, + isExportingSelectedAssets, assetMarquee, - setIsAssetSelectionMode, setCreatingFolder, setNewFolderName, setRenamingFolder, @@ -144,12 +149,13 @@ export function ImageCanvasSidebarView({ setActiveUploadFolderId, setUploadDropTarget, setAssetPointerDrag, - setSelectedAssetIds, setImageContextMenu, setContextMenu, onAssetMarqueePointerDown, onAssetMarqueePointerMove, onAssetMarqueePointerUp, + onAssetMarqueePointerCancel, + onAssetMarqueeLostPointerCapture, updateAssetMoveDropFolder, addUploadedFiles, requestUpload, @@ -167,7 +173,9 @@ export function ImageCanvasSidebarView({ addAssetLayer, onDownloadAsset, toggleAllAssetsSelected, + downloadSelectedAssets, deleteSelectedAssets, + openAssetSelectionMode, closeAssetSelectionMode, groupSelectedLayers, selectSingleLayer, @@ -188,6 +196,19 @@ export function ImageCanvasSidebarView({ ), [filteredGroupedAssets], ); + const visibleAssetIds = useMemo( + () => + filteredGroupedAssets.flatMap((folder) => + folder.collapsed + ? [] + : getSelectableAssets(folder.assets).map((asset) => asset.id), + ), + [filteredGroupedAssets], + ); + const allVisibleAssetsSelected = + visibleAssetIds.length > 0 && + visibleAssetIds.every((assetId) => selectedAssetIds.has(assetId)); + const normalizedLayerSearchQuery = layerSearchQuery.trim().toLowerCase(); const filteredLayers = useMemo( () => @@ -211,7 +232,17 @@ export function ImageCanvasSidebarView({ } return ( -