实现画布素材多选与批量下载

重写素材库的普通点击、Ctrl/Cmd 追加和 Shift 连续范围选择。

清理退出选择模式后的选中状态,并让整行素材统一响应选择操作。

支持单素材直接下载和多素材 ZIP 下载,文件名精确到秒。

固定批量操作栏并修复移动端素材列表可操作空间。

补充选择、导出、侧栏交互测试以及对应技术文档。
This commit is contained in:
2026-07-27 16:04:15 +08:00
parent a2ee879fc8
commit aab0b7849b
22 changed files with 1056 additions and 261 deletions
@@ -214,6 +214,7 @@
- 影响范围:`server-rs/crates/api-server/src/state.rs``server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/api-server/src/character_animation_assets.rs`、后端架构文档和图片画布技术文档。
- 验证方式:运行 `cargo test -p api-server editor_bgfilter_retries_once_before_fallback --manifest-path server-rs/Cargo.toml``cargo test -p api-server editor_character_animation_frames_use_three_stage_matting_fallback --manifest-path server-rs/Cargo.toml``cargo test -p api-server editor_canvas_screen_background_generation_uses_bgfilter_postprocess --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-07-16 SpacetimeDB 备份采用逐文件基线、CAS 增量与安全历史清理
- 背景:SpacetimeDB standalone 2.6.0 不自动删除已被 snapshot 覆盖的历史 commitlog 与旧 snapshot;反复压缩整个 `/stdb` 会重复占用磁盘、停机和 OSS 带宽。上游 issue #5542 的 contributor 明确说明,不触碰最新 snapshot 与重启所需 commitlog suffix 时,可在运行中移动或删除这些历史文件。
@@ -820,7 +821,9 @@
- 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 错误回传给 H5WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。
> 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 剪贴板复制表达,避免旧壳或裁剪壳露出不可用入口。
@@ -4484,3 +4487,9 @@
- 决策:普通手机号认证请求统一使用可选 `countryCode` 与必填 `purePhoneNumber`,省略国家码时默认中国大陆 `86`,直接替换旧 `phone` 字段。前端把浏览器 E.164 自动填充值拆成这两个字段;后端先验证国家码,再复用纯手机号规范化并生成 E.164 存储。
- 微信边界:小程序客户端仍只上传 `wechatPhoneCode``platform-auth` 必须要求微信成功响应中的 `phoneNumber``countryCode``purePhoneNumber` 均存在且非空,但只使用后两项执行国家码校验和 E.164 构造。腾讯官方仅说明境外 `phoneNumber` 会带区号,并未承诺 E.164 格式,中国号码示例中它与纯号码相同,因此不得校验 `phoneNumber == +{countryCode}{purePhoneNumber}`。微信字段缺失时失败关闭,不能使用普通请求的 `86` 默认值。
- 数据边界:认证投影与 SpacetimeDB 的 `phone_number_e164` 保持不变,不新增国家码或纯号码列,也不需要 schema 迁移或 bindings 生成。
## 2026-07-27 图片画布左侧素材库统一可见范围多选与批量下载
- 范围边界:本次只重写左侧素材库选择模式,不改变中央画布舞台和图层列表的选择、框选或下载语义。可选择集合固定为当前搜索结果中已展开、上传完成且媒体地址有效的素材;搜索或折叠变化后立即清理不可见选择。
- 输入语义:桌面普通点击替换选择,`Ctrl / Cmd + 点击` 切换追加,`Shift + 点击` 按当前可见顺序选择连续区间,`Ctrl / Cmd + Shift + 点击` 追加连续区间;触摸和笔输入单击切换选择。退出选择模式、关闭素材栏或切到图层栏统一清空选择、锚点和框选状态,非选择模式不显示历史选中高亮。
- 工具栏与导出:批量工具栏作为素材滚动列表的固定非滚动底栏,提供可见范围全选 / 取消全选、下载、删除和取消;选择模式隐藏单行下载 / 重命名并禁用行拖拽和右键菜单。移动端选择模式使用独立的侧栏高度状态,并让素材列表恢复纵向滚动,避免普通模式 `14rem` 高度上限被固定底栏、标题和搜索区吃完。一个选中素材直接下载,多个选中素材复用画布素材导出管线生成 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,根目录为 `项目名-选中素材/`;单素材、序列帧和两类集合 ZIP 的下载名统一包含到秒的本地时间戳。素材卡整行是统一选择命中区,标题和空白区不得绕过 Ctrl / Cmd / Shift 选择处理。
@@ -28,6 +28,8 @@
- 锁定、分组、翻转等状态不影响图片文件导出,但写入元数据。
- 空画布时按钮置灰,或点击后显示轻提示。
左侧素材库选择模式同时支持导出当前可见范围内的选中素材:只选中一个普通素材时直接下载原文件;选中多个素材时沿用画布素材 ZIP 的读取、去重、媒体分目录、序列帧、元数据、部分失败和浏览器下载能力,生成 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,包内根目录为 `项目名-选中素材/`。单素材、单序列帧、选中素材 ZIP 和画布素材 ZIP 的下载文件名都必须包含到秒的本地时间戳,避免同一天重复导出时重名。该入口不改变中央画布和图层列表的选择 / 下载语义。
暂不实现:
- 画布整体截图 PNG。
@@ -40,7 +42,7 @@
导出文件名:
```text
项目名-画布素材-YYYYMMDD.zip
项目名-画布素材-YYYYMMDD-HHmmss.zip
```
包内结构:
@@ -72,7 +74,7 @@
普通序列帧 ZIP 结构:
```text
角色动作-Sequence.zip
角色动作-Sequence-YYYYMMDD-HHmmss.zip
├─ frames/
│ ├─ frame-01.png
│ └─ frame-02.png
@@ -84,7 +86,7 @@
Spine JSON ZIP 结构:
```text
角色动作-SpineJSON.zip
角色动作-SpineJSON-YYYYMMDD-HHmmss.zip
├─ frames/
│ ├─ frame-01.png
│ └─ frame-02.png
@@ -197,6 +199,8 @@ assetObjectId > objectKey > sourceAssetId > src
- 动作图层右键菜单把 `导出为` 作为一级入口,二级菜单提供 `序列帧导出(zip``Spine 导出(zip`
- 单图层普通序列帧 ZIP 包含 `frames/``preview.gif``metadata.json``manifest.txt`,且不包含 `skeleton.json`
- 序列帧导出的 `skeleton.json` 可被独立验证器解析并预览。
- 左侧素材库只选一个素材时直接下载该素材,不创建集合 ZIP。
- 左侧素材库选择多个素材时只把选中素材写入 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,不混入未选素材,并继续覆盖部分失败和全部失败边界。
## Spine JSON 验证器
@@ -225,7 +229,6 @@ npm run spine-export-validator:build
## 后续扩展
- 导出当前选中素材。
- 导出画布快照 PNG。
- 导出可恢复工程包。
- 导入工程包恢复画布。
@@ -146,7 +146,8 @@
- 音频 / 视频素材卡和画布媒体图层必须提供稳定的非文字视觉预览:优先使用 `thumbnailSrc` / 视频 `poster`,没有真实首帧或音频封面时使用由媒体类型、素材名和地址派生的确定性视觉底图。视频图层使用原生 `<video controls preload="metadata" playsInline>` 播放,外层图层仍承接选择和拖拽语义;音频图层播放前继续通过 `/api/assets/read-url` 换签。画布素材导出按 `mediaType` 保留真实媒体格式:图片进入 `images/`,音频 / 视频进入 `media/`,文件扩展名从响应 MIME、`objectKey` 或源 URL 推断,不得把音频 / 视频导出成 PNG。
- 画布素材 ZIP 的 `metadata.json` 只保存前端信息弹窗和导出文件列表可见的展示快照:项目标题、导出时间、图层标题、文件路径、类型、生成输入、模型显示名、Task 短 ID、Object 显示值、Resolution / 时长和导出错误。导出的生成输入只保留用户实际填写或选择的内容;系统默认兜底提示词、固定工作流提示词、内置图标描述、UI 提取素材固定提示词等内置提示词即使存在于历史 `generationInputs`,也不得写入导出元数据。不得把 `projectId``layerId``resourceId``sourceAssetId``sourceResourceId`、原始 `prompt` / `actualPrompt` / `provider` 或画布坐标、锁定、隐藏等布局状态写入导出元数据;Object 字段仅沿用信息弹窗当前可见值。
- `生成角色动作` 的完成结果按序列帧素材处理:图层主 `src` 使用 `frames[0].imageSrc``mediaType` 固定为 `image-sequence``assetKind` 固定为 `character-animation`,完整帧列表写入 `imageSequenceFrames``previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。下载和 ZIP 导出必须因此得到序列帧 ZIP / frames 目录,不能回退为预览视频或首帧 PNG;后端抽帧后逐帧保存带绿幕源图,上传时消费并释放原帧字节,再用该对象的 600 秒 OSS URL 完成透明化并落盘透明帧素材。
- 素材面板支持按素材名、文件夹名、生成信息、模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材
- 素材面板支持按素材名、文件夹名、生成信息、模型、任务和媒体类型搜索。选择模式只作用于左侧素材库当前搜索结果中已展开、上传完成且媒体地址有效的可见素材,素材缩略图、标题、元数据和卡片空白区统一作为整行选择命中区:桌面鼠标 / 键盘普通点击替换选择,`Ctrl / Cmd + 点击` 切换追加,`Shift + 点击` 按当前可见顺序选择连续区间,`Ctrl / Cmd + Shift + 点击` 追加区间;触摸和笔输入单击采用切换选择。搜索或折叠变化后必须剔除已不可见选择,退出选择模式、关闭素材栏或切换到图层栏时必须同时清空选择锚点、选中集合和框选状态,非选择模式不得残留选中高亮
- 素材选择模式的批量工具栏是素材列表滚动区之外的固定底栏,始终显示当前已选数量、当前可见素材全选 / 取消全选、下载、删除和取消;工具栏自身禁止横向溢出和滚动,空间不足时按钮在工具栏内部换行。不得把工具栏作为滚动列表末项或依赖 `position: sticky` 的自然位置。移动端进入素材选择模式时必须提高素材侧栏高度并允许素材列表纵向滚动,为固定底栏之上至少保留一张素材卡的可操作空间,不得沿用普通模式 `14rem` 上限压扁列表。选择模式隐藏单素材下载 / 重命名动作并禁用素材拖拽和右键菜单,避免批量意图回退成单素材操作。
- 图层面板支持按图层名、生成信息、模型、任务和媒体类型搜索;支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。
- 鼠标滚轮默认按设备原始 `deltaX / deltaY` 二维平移画布视口;当按住 Shift 且设备上报 `deltaX = 0` 时,视口输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型仍只消费归一化后的 X / Y。按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`
@@ -11,6 +11,7 @@ import {
import type { Dispatch, SetStateAction } from 'react';
import { PlatformTextField } from '../common/PlatformTextField';
import type { AssetSelectionInteraction } from './ImageCanvasAssetLibraryModel';
import type {
GroupedEditorAssetFolder,
UploadFilesOptions,
@@ -36,6 +37,7 @@ type ImageCanvasAssetFolderSectionViewProps = {
suppressAssetClickRef: { current: boolean };
isAssetSelectionMode: boolean;
selectedAssetIds: Set<string>;
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<SetStateAction<string>>;
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
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,
@@ -241,11 +248,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}
@@ -11,6 +11,7 @@ import {
groupAssetsByFolder,
moveAssetMarqueeToPointer,
moveAssetToFolderLocally,
reconcileAssetSelection,
removeAssetById,
removeSelectedAssets,
renameAssetById,
@@ -18,19 +19,18 @@ import {
replaceLocalAssetFolder,
resolveAllAssetSelection,
resolveAssetFolderIdFromPoint,
resolveAssetSelection,
resolveAssetShowcaseSubmissionState,
resolveDefaultAssetFolder,
resolvePinnedAssetMoveFolderId,
resolvePreferredAssetUploadFolder,
resolveUploadAssetFolder,
resolveVisibleAssetSelectionToggle,
selectUploadedAssetsInRect,
toggleAssetFolderCollapsed,
toggleAssetSelection,
} from './ImageCanvasAssetLibraryModel';
import type {
EditorAsset,
EditorAssetFolder,
} from './ImageCanvasEditorTypes';
import type { EditorAsset, EditorAssetFolder } from './ImageCanvasEditorTypes';
function createFolder(
overrides: Partial<EditorAssetFolder> = {},
@@ -91,9 +91,9 @@ describe('ImageCanvasAssetLibraryModel', () => {
});
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 }),
@@ -185,15 +185,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',
@@ -241,9 +241,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([
@@ -290,6 +290,117 @@ describe('ImageCanvasAssetLibraryModel', () => {
expect(removal.deletedAssets.map((asset) => asset.id)).toEqual(['asset-b']);
});
it('applies desktop single, additive and visible range selection semantics', () => {
const visibleAssetIds = ['asset-a', 'asset-b', 'asset-c', 'asset-d'];
const initial = {
selectedAssetIds: new Set(['asset-a']),
anchorAssetId: 'asset-a',
};
const replaced = resolveAssetSelection({
...initial,
visibleAssetIds,
targetAssetId: 'asset-c',
});
expect([...replaced.selectedAssetIds]).toEqual(['asset-c']);
const appended = resolveAssetSelection({
...replaced,
visibleAssetIds,
targetAssetId: 'asset-a',
ctrlKey: true,
});
expect([...appended.selectedAssetIds]).toEqual(['asset-c', 'asset-a']);
expect([
...resolveAssetSelection({
...appended,
visibleAssetIds,
targetAssetId: 'asset-a',
metaKey: true,
}).selectedAssetIds,
]).toEqual(['asset-c']);
const range = resolveAssetSelection({
selectedAssetIds: new Set(['asset-a', 'asset-d']),
anchorAssetId: 'asset-a',
visibleAssetIds,
targetAssetId: 'asset-c',
shiftKey: true,
});
expect([...range.selectedAssetIds]).toEqual([
'asset-a',
'asset-b',
'asset-c',
]);
expect([
...resolveAssetSelection({
selectedAssetIds: new Set(['asset-d']),
anchorAssetId: 'asset-b',
visibleAssetIds,
targetAssetId: 'asset-c',
shiftKey: true,
ctrlKey: true,
}).selectedAssetIds,
]).toEqual(['asset-d', 'asset-b', 'asset-c']);
});
it('toggles touch and pen targets without replacing prior selection', () => {
const touchSelection = resolveAssetSelection({
selectedAssetIds: new Set(['asset-a']),
anchorAssetId: 'asset-a',
visibleAssetIds: ['asset-a', 'asset-b'],
targetAssetId: 'asset-b',
pointerKind: 'touch',
});
expect([...touchSelection.selectedAssetIds]).toEqual([
'asset-a',
'asset-b',
]);
expect([
...resolveAssetSelection({
...touchSelection,
visibleAssetIds: ['asset-a', 'asset-b'],
targetAssetId: 'asset-a',
pointerKind: 'pen',
}).selectedAssetIds,
]).toEqual(['asset-b']);
});
it('reconciles hidden selections and toggles only the visible asset set', () => {
expect(
reconcileAssetSelection({
selectedAssetIds: new Set(['asset-a', 'asset-hidden']),
anchorAssetId: 'asset-hidden',
visibleAssetIds: ['asset-a', 'asset-b'],
}),
).toEqual({
selectedAssetIds: new Set(['asset-a']),
anchorAssetId: null,
});
expect([
...resolveVisibleAssetSelectionToggle({
selectedAssetIds: new Set(['asset-hidden']),
visibleAssetIds: ['asset-a', 'asset-b'],
}),
]).toEqual(['asset-hidden', 'asset-a', 'asset-b']);
expect([
...resolveVisibleAssetSelectionToggle({
selectedAssetIds: new Set(['asset-hidden', 'asset-a', 'asset-b']),
visibleAssetIds: ['asset-a', 'asset-b'],
}),
]).toEqual(['asset-hidden']);
});
it('excludes unfinished and empty uploaded assets from selection', () => {
expect(
getSelectableAssets([
createAsset({ id: 'ready' }),
createAsset({ id: 'uploading', uploadStatus: 'uploading' }),
createAsset({ id: 'failed', uploadStatus: 'failed' }),
createAsset({ id: 'empty', src: ' ' }),
]).map((asset) => asset.id),
).toEqual(['ready']);
});
it('moves assets between folders locally', () => {
expect(
moveAssetToFolderLocally([createAsset()], 'asset-a', 'folder-role')[0],
@@ -161,9 +161,7 @@ export function selectUploadedAssetsInRect({
selectionRect: ClientRectLike;
}) {
const uploadedAssetIds = new Set(
assets
.filter((asset) => asset.sourceKind === 'uploaded')
.map((asset) => asset.id),
getSelectableAssets(assets).map((asset) => asset.id),
);
return new Set(
assetTargets
@@ -190,14 +188,20 @@ function normalizeSearchQuery(query: string) {
return query.trim().toLowerCase();
}
function matchesSearchText(query: string, values: Array<string | null | undefined>) {
function matchesSearchText(
query: string,
values: Array<string | null | undefined>,
) {
if (!query) {
return true;
}
return values.some((value) => value?.toLowerCase().includes(query));
}
function startsWithSearchText(query: string, values: Array<string | null | undefined>) {
function startsWithSearchText(
query: string,
values: Array<string | null | undefined>,
) {
if (!query) {
return true;
}
@@ -285,7 +289,118 @@ export function filterGroupedAssetsBySearch(
}
export function getSelectableAssets(assets: EditorAsset[]) {
return assets.filter((asset) => asset.sourceKind === 'uploaded');
return assets.filter(
(asset) =>
asset.sourceKind === 'uploaded' &&
asset.uploadStatus !== 'uploading' &&
asset.uploadStatus !== 'failed' &&
asset.src.trim().length > 0,
);
}
export type AssetSelectionPointerKind = 'keyboard' | 'mouse' | 'pen' | 'touch';
export type AssetSelectionInteraction = {
visibleAssetIds: string[];
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
pointerKind?: AssetSelectionPointerKind;
};
export type AssetSelectionState = {
selectedAssetIds: Set<string>;
anchorAssetId: string | null;
};
function dedupeAssetIds(assetIds: string[]) {
return Array.from(new Set(assetIds));
}
export function reconcileAssetSelection({
selectedAssetIds,
anchorAssetId,
visibleAssetIds,
}: AssetSelectionState & { visibleAssetIds: string[] }): AssetSelectionState {
const visibleIds = new Set(visibleAssetIds);
const nextSelectedAssetIds = new Set(
[...selectedAssetIds].filter((assetId) => visibleIds.has(assetId)),
);
return {
selectedAssetIds: nextSelectedAssetIds,
anchorAssetId:
anchorAssetId && visibleIds.has(anchorAssetId) ? anchorAssetId : null,
};
}
export function resolveAssetSelection({
selectedAssetIds,
anchorAssetId,
visibleAssetIds,
targetAssetId,
shiftKey = false,
ctrlKey = false,
metaKey = false,
pointerKind = 'mouse',
}: AssetSelectionState &
AssetSelectionInteraction & { targetAssetId: string }): AssetSelectionState {
const orderedAssetIds = dedupeAssetIds(visibleAssetIds);
const targetIndex = orderedAssetIds.indexOf(targetAssetId);
if (targetIndex < 0) {
return reconcileAssetSelection({
selectedAssetIds,
anchorAssetId,
visibleAssetIds: orderedAssetIds,
});
}
const anchorIndex = anchorAssetId
? orderedAssetIds.indexOf(anchorAssetId)
: -1;
const shouldAppend = ctrlKey || metaKey;
if (shiftKey && anchorIndex >= 0) {
const rangeStart = Math.min(anchorIndex, targetIndex);
const rangeEnd = Math.max(anchorIndex, targetIndex);
const rangeAssetIds = orderedAssetIds.slice(rangeStart, rangeEnd + 1);
return {
selectedAssetIds: shouldAppend
? new Set([...selectedAssetIds, ...rangeAssetIds])
: new Set(rangeAssetIds),
anchorAssetId,
};
}
if (shouldAppend || pointerKind === 'touch' || pointerKind === 'pen') {
return {
selectedAssetIds: toggleAssetSelection(selectedAssetIds, targetAssetId),
anchorAssetId: targetAssetId,
};
}
return {
selectedAssetIds: new Set([targetAssetId]),
anchorAssetId: targetAssetId,
};
}
export function resolveVisibleAssetSelectionToggle({
selectedAssetIds,
visibleAssetIds,
}: {
selectedAssetIds: Set<string>;
visibleAssetIds: string[];
}) {
const orderedAssetIds = dedupeAssetIds(visibleAssetIds);
const allVisibleAssetsSelected =
orderedAssetIds.length > 0 &&
orderedAssetIds.every((assetId) => selectedAssetIds.has(assetId));
if (allVisibleAssetsSelected) {
const visibleIds = new Set(orderedAssetIds);
return new Set(
[...selectedAssetIds].filter((assetId) => !visibleIds.has(assetId)),
);
}
return new Set([...selectedAssetIds, ...orderedAssetIds]);
}
export type AssetShowcaseSubmissionState = {
@@ -479,7 +594,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
);
}
@@ -494,7 +611,10 @@ export function resolveUploadAssetFolder(assetFolders: EditorAssetFolder[]) {
export function resolvePreferredAssetUploadFolder(
assetFolders: EditorAssetFolder[],
) {
return resolveUploadAssetFolder(assetFolders) ?? resolveDefaultAssetFolder(assetFolders);
return (
resolveUploadAssetFolder(assetFolders) ??
resolveDefaultAssetFolder(assetFolders)
);
}
export function deleteAssetFolderLocally({
@@ -550,7 +670,9 @@ export function removeSelectedAssets(
assets: EditorAsset[],
selectedAssetIds: Set<string>,
) {
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,
@@ -1,6 +1,7 @@
import {
Check,
CheckSquare,
Download,
Folder,
Square,
Trash2,
@@ -17,6 +18,7 @@ import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasAssetFolderSectionView } from './ImageCanvasAssetFolderSectionView';
import type { AssetSelectionInteraction } from './ImageCanvasAssetLibraryModel';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type {
AssetMarqueeState,
@@ -49,13 +51,15 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
assetFolders: EditorAssetFolder[];
isAssetSelectionMode: boolean;
selectedAssetIds: Set<string>;
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<SetStateAction<boolean>>;
setNewFolderName: Dispatch<SetStateAction<string>>;
@@ -68,18 +72,14 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
setActiveUploadFolderId: Dispatch<SetStateAction<string>>;
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
onAssetMarqueePointerDown: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerMove: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerUp: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => 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<void>;
@@ -91,10 +91,14 @@ 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;
toggleAllAssetsSelected: (visibleAssetIds: string[]) => void;
downloadSelectedAssets: (visibleAssetIds: string[]) => void | Promise<void>;
deleteSelectedAssets: () => void;
closeAssetSelectionMode: () => void;
};
@@ -107,13 +111,15 @@ export function ImageCanvasAssetLibraryPanelView({
assetFolders,
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds,
assetMoveDropFolderId,
pinnedAssetMoveFolderId,
creatingFolder,
newFolderName,
renamingFolder,
renamingAsset,
allSelectableAssetsSelected,
allVisibleAssetsSelected,
isExportingSelectedAssets,
assetMarquee,
setCreatingFolder,
setNewFolderName,
@@ -122,7 +128,6 @@ export function ImageCanvasAssetLibraryPanelView({
setActiveUploadFolderId,
setUploadDropTarget,
setAssetPointerDrag,
setSelectedAssetIds,
onAssetMarqueePointerDown,
onAssetMarqueePointerMove,
onAssetMarqueePointerUp,
@@ -143,10 +148,12 @@ export function ImageCanvasAssetLibraryPanelView({
addAssetLayer,
onDownloadAsset,
toggleAllAssetsSelected,
downloadSelectedAssets,
deleteSelectedAssets,
closeAssetSelectionMode,
}: ImageCanvasAssetLibraryPanelViewProps) {
return (
<div className="image-canvas-editor__asset-library">
<div
ref={assetListRef}
className="image-canvas-editor__asset-list"
@@ -162,8 +169,9 @@ export function ImageCanvasAssetLibraryPanelView({
>
<Folder className="h-4 w-4" />
<span>
{assetFolders.find((folder) => folder.id === pinnedAssetMoveFolderId)
?.label ?? '目标文件夹'}
{assetFolders.find(
(folder) => folder.id === pinnedAssetMoveFolderId,
)?.label ?? '目标文件夹'}
</span>
</div>
) : null}
@@ -191,7 +199,11 @@ export function ImageCanvasAssetLibraryPanelView({
}
}}
/>
<EditorIconButton type="submit" label="保存素材文件夹" icon={Check} />
<EditorIconButton
type="submit"
label="保存素材文件夹"
icon={Check}
/>
<EditorIconButton
label="取消新建素材文件夹"
icon={X}
@@ -210,6 +222,7 @@ export function ImageCanvasAssetLibraryPanelView({
suppressAssetClickRef={suppressAssetClickRef}
isAssetSelectionMode={isAssetSelectionMode}
selectedAssetIds={selectedAssetIds}
visibleAssetIds={visibleAssetIds}
assetMoveDropFolderId={assetMoveDropFolderId}
renamingFolder={renamingFolder}
renamingAsset={renamingAsset}
@@ -218,7 +231,6 @@ export function ImageCanvasAssetLibraryPanelView({
setActiveUploadFolderId={setActiveUploadFolderId}
setUploadDropTarget={setUploadDropTarget}
setAssetPointerDrag={setAssetPointerDrag}
setSelectedAssetIds={setSelectedAssetIds}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
requestUpload={requestUpload}
@@ -236,55 +248,73 @@ export function ImageCanvasAssetLibraryPanelView({
onDownloadAsset={onDownloadAsset}
/>
))}
{assetMarquee ? (
<div
className="image-canvas-editor__asset-marquee"
aria-hidden="true"
style={{
left: Math.min(assetMarquee.startX, assetMarquee.currentX),
top: Math.min(assetMarquee.startY, assetMarquee.currentY),
width: Math.abs(assetMarquee.currentX - assetMarquee.startX),
height: Math.abs(assetMarquee.currentY - assetMarquee.startY),
}}
/>
) : null}
</div>
{isAssetSelectionMode ? (
<PlatformBatchActionToolbar
className="image-canvas-editor__asset-batch-toolbar"
className="static! left-auto! bottom-auto! grid! w-full! max-w-none! transform-none! grid-cols-2! gap-1.5! overflow-hidden! rounded-none! border-x-0! border-b-0! px-2.5! pt-2.5! pb-[calc(0.55rem+env(safe-area-inset-bottom,0px))]! shadow-[0_-8px_22px_rgba(15,23,42,0.08)]!"
label="素材批量操作"
>
<PlatformActionButton
className="col-span-3 min-h-[2.35rem]! w-full! min-w-0! max-w-full! gap-1.5! overflow-hidden! px-2! py-1.5! text-xs!"
tone="secondary"
size="sm"
onClick={toggleAllAssetsSelected}
disabled={visibleAssetIds.length === 0 || isExportingSelectedAssets}
onClick={() => toggleAllAssetsSelected(visibleAssetIds)}
>
{allSelectableAssetsSelected ? (
{allVisibleAssetsSelected ? (
<CheckSquare className="h-4 w-4" />
) : (
<Square className="h-4 w-4" />
)}
<span className="min-w-0 truncate">
{selectedAssetIds.size > 0
? `${allSelectableAssetsSelected ? '取消全选' : '全选'} · 已选 ${selectedAssetIds.size}`
? `${allVisibleAssetsSelected ? '取消全选' : '全选'} · 已选 ${selectedAssetIds.size}`
: '全选'}
</span>
</PlatformActionButton>
<PlatformActionButton
className="min-h-[2.35rem]! w-full! min-w-0! max-w-full! gap-1.5! overflow-hidden! px-2! py-1.5! text-xs!"
tone="secondary"
size="sm"
disabled={selectedAssetIds.size === 0 || isExportingSelectedAssets}
onClick={() => void downloadSelectedAssets(visibleAssetIds)}
>
<Download className="h-4 w-4" />
{isExportingSelectedAssets ? '下载中' : '下载'}
</PlatformActionButton>
<PlatformActionButton
className="min-h-[2.35rem]! w-full! min-w-0! max-w-full! gap-1.5! overflow-hidden! px-2! py-1.5! text-xs!"
tone="warning"
size="sm"
disabled={selectedAssetIds.size === 0}
disabled={selectedAssetIds.size === 0 || isExportingSelectedAssets}
onClick={deleteSelectedAssets}
>
<Trash2 className="h-4 w-4" />
</PlatformActionButton>
<PlatformActionButton
className="min-h-[2.35rem]! w-full! min-w-0! max-w-full! gap-1.5! overflow-hidden! px-2! py-1.5! text-xs!"
tone="secondary"
size="sm"
onClick={closeAssetSelectionMode}
>
<X />
</PlatformActionButton>
</PlatformBatchActionToolbar>
) : null}
{assetMarquee ? (
<div
className="image-canvas-editor__asset-marquee"
aria-hidden="true"
style={{
left: Math.min(assetMarquee.startX, assetMarquee.currentX),
top: Math.min(assetMarquee.startY, assetMarquee.currentY),
width: Math.abs(assetMarquee.currentX - assetMarquee.startX),
height: Math.abs(assetMarquee.currentY - assetMarquee.startY),
}}
/>
) : null}
</div>
);
}
@@ -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(),
@@ -193,8 +196,35 @@ describe('ImageCanvasAssetRowView', () => {
fireEvent.click(screen.getByRole('button', { name: '选择素材账号素材A' }));
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1');
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', {
visibleAssetIds: ['asset-1'],
shiftKey: false,
ctrlKey: false,
metaKey: false,
pointerKind: 'keyboard',
});
expect(addAssetLayer).not.toHaveBeenCalled();
expect(
screen.queryByRole('button', { name: '下载素材账号素材A' }),
).toBeNull();
});
it('forwards modifier keys to selection', () => {
const toggleAssetSelected = vi.fn();
renderAssetRow({ isAssetSelectionMode: true, toggleAssetSelected });
fireEvent.click(screen.getByText('账号素材A'), {
ctrlKey: true,
shiftKey: true,
});
expect(toggleAssetSelected).toHaveBeenCalledWith(
'asset-1',
expect.objectContaining({
ctrlKey: true,
shiftKey: true,
pointerKind: 'keyboard',
}),
);
});
it('renders rename input and commits with Enter', () => {
@@ -1,10 +1,17 @@
import { Check, Download, Pencil, Video, X } from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import type {
Dispatch,
MouseEvent as ReactMouseEvent,
SetStateAction,
} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { PlatformTextField } from '../common/PlatformTextField';
import { resolveAssetShowcaseSubmissionState } from './ImageCanvasAssetLibraryModel';
import {
type AssetSelectionInteraction,
resolveAssetShowcaseSubmissionState,
} from './ImageCanvasAssetLibraryModel';
import type { UploadFilesOptions } from './ImageCanvasAssetLibraryPanelView';
import {
ASSET_DRAG_MIME_TYPE,
@@ -33,13 +40,13 @@ export type ImageCanvasAssetRowViewProps = {
suppressAssetClickRef: { current: boolean };
isAssetSelectionMode: boolean;
selectedAssetIds: Set<string>;
visibleAssetIds: string[];
renamingAsset: { assetId: string; value: string } | null;
setRenamingAsset: Dispatch<
SetStateAction<{ assetId: string; value: string } | null>
>;
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
updateAssetMoveDropFolder: (folderId: string | null) => void;
addUploadedFiles: (
files: FileList | File[],
@@ -50,7 +57,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 +71,11 @@ export function ImageCanvasAssetRowView({
suppressAssetClickRef,
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds,
renamingAsset,
setRenamingAsset,
setUploadDropTarget,
setAssetPointerDrag,
setSelectedAssetIds,
updateAssetMoveDropFolder,
addUploadedFiles,
moveAssetToFolder,
@@ -78,6 +88,8 @@ export function ImageCanvasAssetRowView({
onDownloadAsset,
}: ImageCanvasAssetRowViewProps) {
const rowRef = useRef<HTMLDivElement | null>(null);
const selectionPointerKindRef =
useRef<AssetSelectionInteraction['pointerKind']>('keyboard');
const [menuPosition, setMenuPosition] = useState<{
x: number;
y: number;
@@ -95,6 +107,38 @@ export function ImageCanvasAssetRowView({
setMenuPosition(null);
}, []);
const selectAssetFromEvent = useCallback(
(event: ReactMouseEvent<HTMLElement>) => {
if (
!isAssetSelectionMode ||
isUploadingAsset ||
isFailedUpload ||
suppressAssetClickRef.current
) {
return;
}
closeMenu();
toggleAssetSelected(asset.id, {
visibleAssetIds,
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
pointerKind: selectionPointerKindRef.current,
});
selectionPointerKindRef.current = 'keyboard';
},
[
asset.id,
closeMenu,
isAssetSelectionMode,
isFailedUpload,
isUploadingAsset,
suppressAssetClickRef,
toggleAssetSelected,
visibleAssetIds,
],
);
useEffect(() => {
if (!menuPosition) {
return undefined;
@@ -184,7 +228,7 @@ export function ImageCanvasAssetRowView({
}}
/>
) : undefined;
const actions = isUploadingAsset ? (
const actions = isAssetSelectionMode ? undefined : isUploadingAsset ? (
<div className="image-canvas-editor__asset-upload-status">
<span>{asset.uploadMessage ?? '上传中'}</span>
<strong>{Math.round(uploadProgress)}%</strong>
@@ -231,7 +275,13 @@ export function ImageCanvasAssetRowView({
<div
ref={rowRef}
data-asset-id={asset.id}
onClick={selectAssetFromEvent}
onContextMenu={(event) => {
if (isAssetSelectionMode) {
event.preventDefault();
event.stopPropagation();
return;
}
if (isUploadingAsset || isFailedUpload || isRenaming) {
return;
}
@@ -262,12 +312,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 +344,12 @@ export function ImageCanvasAssetRowView({
)
}
actions={actions}
draggable={!isRenaming && !isUploadingAsset && !isFailedUpload}
draggable={
!isAssetSelectionMode &&
!isRenaming &&
!isUploadingAsset &&
!isFailedUpload
}
previewOverlay={
isUploadingAsset ? (
<div className="image-canvas-editor__asset-upload-overlay">
@@ -344,16 +398,17 @@ export function ImageCanvasAssetRowView({
updateAssetMoveDropFolder(null);
}}
onPointerDown={(event) => {
if (isAssetSelectionMode) {
selectionPointerKindRef.current =
event.pointerType === 'touch' || event.pointerType === 'pen'
? event.pointerType
: 'mouse';
return;
}
if (event.button !== 0) {
return;
}
const target = event.target as HTMLElement;
if (isAssetSelectionMode) {
if (target.closest('button')) {
return;
}
return;
}
if (
isRenaming ||
isUploadingAsset ||
@@ -390,15 +445,6 @@ 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 (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) {
event.preventDefault();
@@ -77,10 +77,9 @@ vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
}));
vi.mock('../../services/assetReadUrlService', async () => {
const actual =
await vi.importActual<typeof import('../../services/assetReadUrlService')>(
'../../services/assetReadUrlService',
);
const actual = await vi.importActual<
typeof import('../../services/assetReadUrlService')
>('../../services/assetReadUrlService');
return {
...actual,
getSignedAssetReadUrl: (
@@ -335,7 +334,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 {
@@ -558,13 +557,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) => {
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'),
@@ -603,8 +604,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({
@@ -804,6 +809,70 @@ describe('ImageCanvasEditorView asset library integration', () => {
).toBeNull();
});
it('supports visible range and additive 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(<ImageCanvasEditorView />);
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(
/ 2/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('removes canvas layers linked to deleted assets', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
@@ -946,7 +1015,8 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(screen.queryByAltText('画布图片:生成资源A')).toBeNull();
});
await waitFor(() => {
const savedLayoutWithoutDeletedLayer = saveEditorProjectLayoutMock.mock.calls
const savedLayoutWithoutDeletedLayer =
saveEditorProjectLayoutMock.mock.calls
.map((call) => call[1])
.find((layout) =>
layout.layers.every(
@@ -964,8 +1034,10 @@ describe('ImageCanvasEditorView asset library integration', () => {
createEditorProjectResourceMock.mockResolvedValueOnce({
resourceId: 'resource-added-asset-a',
projectId: 'editor-project-default',
imageSrc: '/generated-character-drafts/editor/asset-library/image/asset-a.png',
objectKey: 'generated-character-drafts/editor/asset-library/image/asset-a.png',
imageSrc:
'/generated-character-drafts/editor/asset-library/image/asset-a.png',
objectKey:
'generated-character-drafts/editor/asset-library/image/asset-a.png',
assetObjectId: 'assetobj-asset-a',
width: 320,
height: 240,
@@ -1108,7 +1180,6 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(within(batchToolbar).getByText(/ 2/u)).toBeTruthy();
});
it('drops an image file on the canvas as a new canvas layer', async () => {
render(<ImageCanvasEditorView />);
await waitFor(() => {
@@ -1140,9 +1211,9 @@ describe('ImageCanvasEditorView asset library integration', () => {
);
expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy();
expect(
screen.getByRole('button', { name: '打开素材' }).getAttribute(
'aria-pressed',
),
screen
.getByRole('button', { name: '打开素材' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen
@@ -1409,5 +1480,4 @@ describe('ImageCanvasEditorView asset library integration', () => {
});
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
});
@@ -63,7 +63,7 @@ export type SidebarMediaItemProps = {
imageAlt: string;
selected?: boolean;
primaryLabel: string;
onPrimaryClick: () => void;
onPrimaryClick: MouseEventHandler<HTMLButtonElement>;
thumbnailClassName: string;
metaClassName: string;
rowClassName: string;
@@ -49,9 +49,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(),
@@ -59,7 +58,6 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
setActiveUploadFolderId: vi.fn(),
setUploadDropTarget: vi.fn(),
setAssetPointerDrag: vi.fn(),
setSelectedAssetIds: vi.fn(),
setImageContextMenu: vi.fn(),
setContextMenu: vi.fn(),
onAssetMarqueePointerDown: vi.fn(),
@@ -82,7 +80,10 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
addAssetLayer: vi.fn(),
onDownloadAsset: vi.fn(),
toggleAllAssetsSelected: vi.fn(),
reconcileSelectedAssets: vi.fn(),
downloadSelectedAssets: vi.fn(),
deleteSelectedAssets: vi.fn(),
openAssetSelectionMode: vi.fn(),
closeAssetSelectionMode: vi.fn(),
groupSelectedLayers: vi.fn(),
selectSingleLayer: vi.fn(),
@@ -670,7 +670,6 @@ export function ImageCanvasEditorView({
assets,
setAssets,
groupedAssets,
allSelectableAssetsSelected,
renamingAsset,
setRenamingAsset,
renamingFolder,
@@ -682,9 +681,7 @@ export function ImageCanvasEditorView({
activeUploadFolderId,
setActiveUploadFolderId,
isAssetSelectionMode,
setIsAssetSelectionMode,
selectedAssetIds,
setSelectedAssetIds,
assetMarquee,
assetPointerDrag,
setAssetPointerDrag,
@@ -703,9 +700,11 @@ export function ImageCanvasEditorView({
deleteAssetFolder,
toggleAssetSelected,
toggleAllAssetsSelected,
reconcileSelectedAssets,
deleteSelectedAssets,
moveAssetToFolder,
closeAssetSelectionMode,
openAssetSelectionMode,
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
@@ -717,6 +716,12 @@ export function ImageCanvasEditorView({
onDeleteAssets: removeCanvasLayersLinkedToAssets,
});
useEffect(() => {
if (activeSidebarPanel !== 'assets' && isAssetSelectionMode) {
closeAssetSelectionMode();
}
}, [activeSidebarPanel, closeAssetSelectionMode, isAssetSelectionMode]);
const handleActivateCanvasGenerationDialog = useCallback(() => {
closeGenerationTransientStateRef.current();
setSelectedLayerId(null);
@@ -1243,6 +1248,7 @@ export function ImageCanvasEditorView({
assetExportStatus,
isExportingAssets,
exportCanvasAssets,
exportSelectedAssets,
exportLayerImage,
} = useImageCanvasAssetExportWorkflow({
layers,
@@ -1255,6 +1261,26 @@ export function ImageCanvasEditorView({
},
[exportLayerImage],
);
const downloadSelectedAssets = useCallback(
(visibleAssetIds: string[]) => {
const selectedIds = new Set(selectedAssetIds);
const selectedLayers = visibleAssetIds
.filter((assetId) => selectedIds.has(assetId))
.map((assetId, index) => {
const asset = assets.find((item) => item.id === assetId);
if (!asset) {
return null;
}
return {
...createAssetActionLayer(asset),
zIndex: index,
};
})
.filter((layer): layer is CanvasLayer => layer !== null);
return exportSelectedAssets(selectedLayers);
},
[assets, exportSelectedAssets, selectedAssetIds],
);
const {
uploadInputRef,
uploadTarget,
@@ -2105,9 +2131,8 @@ export function ImageCanvasEditorView({
newFolderName,
renamingFolder,
renamingAsset,
allSelectableAssetsSelected,
isExportingSelectedAssets: isExportingAssets,
assetMarquee,
setIsAssetSelectionMode,
setCreatingFolder,
setNewFolderName,
setRenamingFolder,
@@ -2115,7 +2140,6 @@ export function ImageCanvasEditorView({
setActiveUploadFolderId,
setUploadDropTarget,
setAssetPointerDrag,
setSelectedAssetIds,
setImageContextMenu,
setContextMenu,
onAssetMarqueePointerDown: handleAssetMarqueePointerDown,
@@ -2138,8 +2162,11 @@ export function ImageCanvasEditorView({
addAssetLayer,
onDownloadAsset: downloadAsset,
toggleAllAssetsSelected,
reconcileSelectedAssets,
downloadSelectedAssets,
deleteSelectedAssets,
closeAssetSelectionMode,
openAssetSelectionMode,
groupSelectedLayers,
selectSingleLayer,
resolveContextMenuPosition,
@@ -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',
);
});
@@ -26,7 +26,10 @@ 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) {
@@ -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,7 +96,6 @@ function createSidebarProps(
setActiveUploadFolderId: vi.fn(),
setUploadDropTarget: vi.fn(),
setAssetPointerDrag: vi.fn(),
setSelectedAssetIds: vi.fn(),
setImageContextMenu: vi.fn(),
setContextMenu: vi.fn(),
onAssetMarqueePointerDown: vi.fn(),
@@ -120,7 +118,10 @@ function createSidebarProps(
addAssetLayer: vi.fn(),
onDownloadAsset: vi.fn(),
toggleAllAssetsSelected: vi.fn(),
reconcileSelectedAssets: vi.fn(),
downloadSelectedAssets: vi.fn(),
deleteSelectedAssets: vi.fn(),
openAssetSelectionMode: vi.fn(),
closeAssetSelectionMode: vi.fn(),
groupSelectedLayers: vi.fn(),
selectSingleLayer: vi.fn(),
@@ -269,6 +270,68 @@ describe('ImageCanvasSidebarView', () => {
).toBeNull();
});
it('keeps the batch toolbar outside the scrolling list and downloads visible selections', () => {
const downloadSelectedAssets = vi.fn();
const closeAssetSelectionMode = vi.fn();
const asset = createAsset();
render(
<ImageCanvasSidebarView
{...createSidebarProps({
isAssetSelectionMode: true,
selectedAssetIds: new Set([asset.id]),
downloadSelectedAssets,
closeAssetSelectionMode,
})}
/>,
);
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(['asset-1']);
fireEvent.click(screen.getByRole('button', { name: '素材选择模式' }));
expect(closeAssetSelectionMode).toHaveBeenCalledTimes(1);
});
it('opens selection mode through the shared lifecycle and reconciles search visibility', () => {
const openAssetSelectionMode = vi.fn();
const reconcileSelectedAssets = vi.fn();
const folder = createFolder();
render(
<ImageCanvasSidebarView
{...createSidebarProps({
isAssetSelectionMode: false,
openAssetSelectionMode,
reconcileSelectedAssets,
groupedAssets: [
{
...folder,
assets: [
createAsset({ id: 'asset-a', label: '角色' }),
createAsset({ id: 'asset-b', label: '背景' }),
],
},
],
})}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材选择模式' }));
expect(openAssetSelectionMode).toHaveBeenCalledTimes(1);
expect(reconcileSelectedAssets).not.toHaveBeenCalled();
});
it('renders the layer panel with grouped and locked layer details', () => {
render(
<ImageCanvasSidebarView
@@ -1,19 +1,17 @@
import {
CheckSquare,
FolderPlus,
Square,
} from 'lucide-react';
import { CheckSquare, FolderPlus, Square } from 'lucide-react';
import type {
Dispatch,
PointerEvent as ReactPointerEvent,
RefObject,
SetStateAction,
} from 'react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { PlatformTextField } from '../common/PlatformTextField';
import {
type AssetSelectionInteraction,
filterGroupedAssetsBySearch,
getSelectableAssets,
layerMatchesSearch,
layerStartsWithSearch,
} from './ImageCanvasAssetLibraryModel';
@@ -58,9 +56,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<SetStateAction<boolean>>;
setCreatingFolder: Dispatch<SetStateAction<boolean>>;
setNewFolderName: Dispatch<SetStateAction<string>>;
setRenamingFolder: Dispatch<
@@ -72,20 +69,16 @@ export type ImageCanvasSidebarViewProps = {
setActiveUploadFolderId: Dispatch<SetStateAction<string>>;
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
setContextMenu: Dispatch<SetStateAction<CanvasContextMenuState | null>>;
onAssetMarqueePointerDown: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerMove: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerUp: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => 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<void>;
@@ -97,11 +90,17 @@ 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;
toggleAllAssetsSelected: (visibleAssetIds: string[]) => void;
reconcileSelectedAssets: (visibleAssetIds: string[]) => void;
downloadSelectedAssets: (visibleAssetIds: string[]) => void | Promise<void>;
deleteSelectedAssets: () => void;
openAssetSelectionMode: () => void;
closeAssetSelectionMode: () => void;
groupSelectedLayers: () => void;
selectSingleLayer: (layerId: string | null) => void;
@@ -134,9 +133,8 @@ export function ImageCanvasSidebarView({
newFolderName,
renamingFolder,
renamingAsset,
allSelectableAssetsSelected,
isExportingSelectedAssets,
assetMarquee,
setIsAssetSelectionMode,
setCreatingFolder,
setNewFolderName,
setRenamingFolder,
@@ -144,7 +142,6 @@ export function ImageCanvasSidebarView({
setActiveUploadFolderId,
setUploadDropTarget,
setAssetPointerDrag,
setSelectedAssetIds,
setImageContextMenu,
setContextMenu,
onAssetMarqueePointerDown,
@@ -167,7 +164,10 @@ export function ImageCanvasSidebarView({
addAssetLayer,
onDownloadAsset,
toggleAllAssetsSelected,
reconcileSelectedAssets,
downloadSelectedAssets,
deleteSelectedAssets,
openAssetSelectionMode,
closeAssetSelectionMode,
groupSelectedLayers,
selectSingleLayer,
@@ -188,6 +188,24 @@ 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));
useEffect(() => {
if (isAssetSelectionMode) {
reconcileSelectedAssets(visibleAssetIds);
}
}, [isAssetSelectionMode, reconcileSelectedAssets, visibleAssetIds]);
const normalizedLayerSearchQuery = layerSearchQuery.trim().toLowerCase();
const filteredLayers = useMemo(
() =>
@@ -211,7 +229,17 @@ export function ImageCanvasSidebarView({
}
return (
<aside className="image-canvas-editor__sidebar" aria-label="图片资源栏">
<aside
className={[
'image-canvas-editor__sidebar',
activeSidebarPanel === 'assets' && isAssetSelectionMode
? 'image-canvas-editor__sidebar--asset-selection'
: '',
]
.filter(Boolean)
.join(' ')}
aria-label="图片资源栏"
>
<div className="image-canvas-editor__sidebar-header">
<div className="min-w-0">
<h2 className="image-canvas-editor__sidebar-title">
@@ -231,8 +259,10 @@ export function ImageCanvasSidebarView({
title="选择"
icon={isAssetSelectionMode ? CheckSquare : Square}
pressed={isAssetSelectionMode}
onClick={() =>
setIsAssetSelectionMode((currentMode) => !currentMode)
onClick={
isAssetSelectionMode
? closeAssetSelectionMode
: openAssetSelectionMode
}
/>
<EditorIconButton
@@ -260,9 +290,13 @@ export function ImageCanvasSidebarView({
aria-label={
activeSidebarPanel === 'assets' ? '搜索素材' : '搜索画布图层'
}
placeholder={activeSidebarPanel === 'assets' ? '搜索素材' : '搜索图层'}
placeholder={
activeSidebarPanel === 'assets' ? '搜索素材' : '搜索图层'
}
value={
activeSidebarPanel === 'assets' ? assetSearchQuery : layerSearchQuery
activeSidebarPanel === 'assets'
? assetSearchQuery
: layerSearchQuery
}
size="xs"
density="compact"
@@ -286,13 +320,15 @@ export function ImageCanvasSidebarView({
assetFolders={assetFolders}
isAssetSelectionMode={isAssetSelectionMode}
selectedAssetIds={selectedAssetIds}
visibleAssetIds={visibleAssetIds}
assetMoveDropFolderId={assetMoveDropFolderId}
pinnedAssetMoveFolderId={pinnedAssetMoveFolderId}
creatingFolder={creatingFolder}
newFolderName={newFolderName}
renamingFolder={renamingFolder}
renamingAsset={renamingAsset}
allSelectableAssetsSelected={allSelectableAssetsSelected}
allVisibleAssetsSelected={allVisibleAssetsSelected}
isExportingSelectedAssets={isExportingSelectedAssets}
assetMarquee={assetMarquee}
setCreatingFolder={setCreatingFolder}
setNewFolderName={setNewFolderName}
@@ -301,7 +337,6 @@ export function ImageCanvasSidebarView({
setActiveUploadFolderId={setActiveUploadFolderId}
setUploadDropTarget={setUploadDropTarget}
setAssetPointerDrag={setAssetPointerDrag}
setSelectedAssetIds={setSelectedAssetIds}
onAssetMarqueePointerDown={onAssetMarqueePointerDown}
onAssetMarqueePointerMove={onAssetMarqueePointerMove}
onAssetMarqueePointerUp={onAssetMarqueePointerUp}
@@ -322,6 +357,7 @@ export function ImageCanvasSidebarView({
addAssetLayer={addAssetLayer}
onDownloadAsset={onDownloadAsset}
toggleAllAssetsSelected={toggleAllAssetsSelected}
downloadSelectedAssets={downloadSelectedAssets}
deleteSelectedAssets={deleteSelectedAssets}
closeAssetSelectionMode={closeAssetSelectionMode}
/>
@@ -71,6 +71,18 @@ function ExportWorkflowHarness({ layers }: { layers: CanvasLayer[] }) {
>
</button>
<button
type="button"
onClick={() => void workflow.exportSelectedAssets(layers.slice(0, 1))}
>
</button>
<button
type="button"
onClick={() => void workflow.exportSelectedAssets(layers.slice(0, 2))}
>
</button>
<button
type="button"
onClick={() =>
@@ -98,6 +110,59 @@ describe('useImageCanvasAssetExportWorkflow', () => {
vi.restoreAllMocks();
});
it('downloads one selected asset directly and packages multiple selected assets', async () => {
const downloads: Array<{ blob: Blob; name: string }> = [];
let currentBlob: Blob | null = null;
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => {
currentBlob = blob;
return `blob:selected-${downloads.length}`;
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
if (currentBlob) {
downloads.push({ blob: currentBlob, name: this.download });
}
},
);
try {
render(
<ExportWorkflowHarness
layers={[
createLayer('asset-a', { title: '素材A', zIndex: 0 }),
createLayer('asset-b', { title: '素材B', zIndex: 1 }),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '下载一个选中素材' }));
await waitFor(() => expect(downloads).toHaveLength(1));
expect(downloads[0]?.name).toMatch(/^A-\d{8}-\d{6}\.png$/u);
fireEvent.click(screen.getByRole('button', { name: '下载多个选中素材' }));
await waitFor(() => expect(downloads).toHaveLength(2));
expect(downloads[1]?.name).toMatch(
/^--\d{8}-\d{6}\.zip$/u,
);
const zip = await JSZip.loadAsync(downloads[1]!.blob);
expect(zip.file('导出项目-选中素材/images/001-素材A.png')).toBeTruthy();
expect(zip.file('导出项目-选中素材/images/002-素材B.png')).toBeTruthy();
expect(screen.getByTestId('status').textContent).toBe(
'success:选中素材已导出',
);
} finally {
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('exports canvas assets into a zip with metadata and failed image records', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
@@ -228,7 +293,7 @@ describe('useImageCanvasAssetExportWorkflow', () => {
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();
@@ -398,7 +463,7 @@ describe('useImageCanvasAssetExportWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '导出单图' }));
await waitFor(() => {
expect(downloadName).toBe('单图 导出.png');
expect(downloadName).toMatch(/^ -\d{8}-\d{6}\.png$/u);
});
expect(downloadHref).toBe('blob:direct-layer-export');
expect(downloadedBlob).toBeTruthy();
@@ -431,7 +496,7 @@ describe('useImageCanvasAssetExportWorkflow', () => {
);
await waitFor(() => {
expect(downloadName).toBe('视频导出.webm');
expect(downloadName).toMatch(/^-\d{8}-\d{6}\.webm$/u);
});
render(<ExportWorkflowHarness layers={[]} />);
@@ -505,7 +570,9 @@ describe('useImageCanvasAssetExportWorkflow', () => {
await waitFor(() => {
expect(exportedBlob).toBeTruthy();
});
expect(downloadName).toBe('角色动作 跑步-SpineJSON.zip');
expect(downloadName).toMatch(
/^ -SpineJSON-\d{8}-\d{6}\.zip$/u,
);
const zip = await JSZip.loadAsync(exportedBlob!);
expect(zip.file('frames/frame-01.png')).toBeTruthy();
expect(zip.file('frames/frame-02.png')).toBeTruthy();
@@ -613,7 +680,9 @@ describe('useImageCanvasAssetExportWorkflow', () => {
await waitFor(() => {
expect(exportedBlob).toBeTruthy();
});
expect(downloadName).toBe('角色动作 跑步-Sequence.zip');
expect(downloadName).toMatch(
/^ -Sequence-\d{8}-\d{6}\.zip$/u,
);
const zip = await JSZip.loadAsync(exportedBlob!);
expect(zip.file('frames/frame-01.png')).toBeTruthy();
expect(zip.file('frames/frame-02.png')).toBeTruthy();
@@ -275,7 +275,7 @@ export function useImageCanvasAssetExportWorkflow({
const [isExportingAssets, setIsExportingAssets] = useState(false);
const exportLayerImage = useCallback(
(
async (
layer: CanvasLayer | null,
options: { mode?: ImageSequenceExportMode } = {},
) => {
@@ -284,13 +284,14 @@ export function useImageCanvasAssetExportWorkflow({
return;
}
if (layer.mediaType === 'image-sequence') {
void buildImageSequenceZip(layer, exportMode)
.then((zipBlob) => {
try {
const zipBlob = await buildImageSequenceZip(layer, exportMode);
const modeName =
exportMode === 'spine-json' ? 'SpineJSON' : 'Sequence';
const exportStamp = formatExportDate(new Date());
const downloaded = triggerBrowserDownload(
zipBlob,
`${sanitizeExportFilePart(layer.title, '角色动作')}-${modeName}.zip`,
`${sanitizeExportFilePart(layer.title, '角色动作')}-${modeName}-${exportStamp}.zip`,
);
if (!downloaded) {
setAssetExportStatus({
@@ -298,18 +299,17 @@ export function useImageCanvasAssetExportWorkflow({
message: '当前浏览器不支持素材下载',
});
}
})
.catch(() => {
} catch {
setAssetExportStatus({
tone: 'error',
message: '序列帧导出失败',
});
});
}
return;
}
void readLayerAssetBlob(layer)
.then((blob) => {
try {
const blob = await readLayerAssetBlob(layer);
const extension = getLayerAssetExtensionFromTypeOrSrc(
layer.mediaType ?? 'image',
blob.type,
@@ -317,7 +317,7 @@ export function useImageCanvasAssetExportWorkflow({
);
const downloaded = triggerBrowserDownload(
blob,
`${sanitizeExportFilePart(layer.title, 'canvas-layer')}.${extension}`,
`${sanitizeExportFilePart(layer.title, 'canvas-layer')}-${formatExportDate(new Date())}.${extension}`,
);
if (!downloaded) {
setAssetExportStatus({
@@ -325,28 +325,38 @@ export function useImageCanvasAssetExportWorkflow({
message: '当前浏览器不支持素材下载',
});
}
})
.catch(() => {
} catch {
setAssetExportStatus({
tone: 'error',
message: '素材导出失败',
});
});
}
},
[],
);
const exportCanvasAssets = useCallback(async () => {
const exportAssetCollection = useCallback(
async ({
targetLayers,
archiveLabel,
emptyMessage,
successMessage,
}: {
targetLayers: CanvasLayer[];
archiveLabel: string;
emptyMessage: string;
successMessage: string;
}) => {
if (isExportingAssets) {
return;
}
const exportableLayers = layers
const exportableLayers = targetLayers
.filter((layer) => layer.src.trim().length > 0)
.sort((left, right) => left.zIndex - right.zIndex);
if (!exportableLayers.length) {
setAssetExportStatus({
tone: 'info',
message: '当前画布没有可导出的素材',
message: emptyMessage,
});
return;
}
@@ -357,7 +367,7 @@ export function useImageCanvasAssetExportWorkflow({
try {
const exportedAt = new Date();
const projectName = sanitizeExportFilePart(projectTitle, '未命名画布');
const rootFolderName = `${projectName}-画布素材`;
const rootFolderName = `${projectName}-${archiveLabel}`;
const zip = new JSZip();
const rootFolder = zip.folder(rootFolderName) ?? zip;
const imagesFolder = rootFolder.folder('images') ?? rootFolder;
@@ -409,7 +419,10 @@ export function useImageCanvasAssetExportWorkflow({
frameArrayIndex,
frameBlob.type,
);
framesFolder.file(fileName, await blobToUint8Array(frameBlob));
framesFolder.file(
fileName,
await blobToUint8Array(frameBlob),
);
exportedFrames.push({
frameIndex: frame.frameIndex || frameArrayIndex + 1,
fileName,
@@ -447,7 +460,10 @@ export function useImageCanvasAssetExportWorkflow({
sequenceFolder.file(
'skeleton.json',
JSON.stringify(
buildSpineImageSequenceJson({ layer, frames: exportedFrames }),
buildSpineImageSequenceJson({
layer,
frames: exportedFrames,
}),
null,
2,
),
@@ -466,7 +482,9 @@ export function useImageCanvasAssetExportWorkflow({
layer.durationSeconds
? `时长:${layer.durationSeconds}s`
: null,
failedFrames.length ? `失败帧数:${failedFrames.length}` : null,
failedFrames.length
? `失败帧数:${failedFrames.length}`
: null,
]
.filter(Boolean)
.join('\n'),
@@ -583,7 +601,7 @@ export function useImageCanvasAssetExportWorkflow({
URL.revokeObjectURL(downloadUrl);
setAssetExportStatus({
tone: failedImages.length ? 'error' : 'success',
message: failedImages.length ? '部分素材未能导出' : '画布素材已导出',
message: failedImages.length ? '部分素材未能导出' : successMessage,
});
} catch {
setAssetExportStatus({
@@ -593,12 +611,61 @@ export function useImageCanvasAssetExportWorkflow({
} finally {
setIsExportingAssets(false);
}
}, [isExportingAssets, layers, projectTitle]);
},
[isExportingAssets, projectTitle],
);
const exportCanvasAssets = useCallback(
() =>
exportAssetCollection({
targetLayers: layers,
archiveLabel: '画布素材',
emptyMessage: '当前画布没有可导出的素材',
successMessage: '画布素材已导出',
}),
[exportAssetCollection, layers],
);
const exportSelectedAssets = useCallback(
async (selectedLayers: CanvasLayer[]) => {
if (isExportingAssets) {
return;
}
const exportableLayers = selectedLayers.filter(
(layer) => layer.src.trim().length > 0,
);
if (!exportableLayers.length) {
setAssetExportStatus({
tone: 'info',
message: '请先选择可下载的素材',
});
return;
}
if (exportableLayers.length === 1) {
setIsExportingAssets(true);
setAssetExportStatus(null);
try {
await exportLayerImage(exportableLayers[0] ?? null);
} finally {
setIsExportingAssets(false);
}
return;
}
await exportAssetCollection({
targetLayers: exportableLayers,
archiveLabel: '选中素材',
emptyMessage: '请先选择可下载的素材',
successMessage: '选中素材已导出',
});
},
[exportAssetCollection, exportLayerImage, isExportingAssets],
);
return {
assetExportStatus,
isExportingAssets,
exportCanvasAssets,
exportSelectedAssets,
exportLayerImage,
};
}
@@ -36,7 +36,9 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
};
});
function createUploadedAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
function createUploadedAsset(
overrides: Partial<EditorAsset> = {},
): EditorAsset {
return {
id: 'asset-a',
label: '素材A',
@@ -94,7 +96,17 @@ function AssetLibraryHarness({
key={asset.id || `asset-${index}`}
type="button"
data-asset-id={asset.id}
onClick={() => assetLibrary.toggleAssetSelected(asset.id)}
onClick={(event) =>
assetLibrary.toggleAssetSelected(asset.id, {
visibleAssetIds: assetLibrary.selectableAssets.map(
(item) => item.id,
),
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
pointerKind: 'mouse',
})
}
>
{asset.label}
</button>
@@ -126,11 +138,19 @@ function AssetLibraryHarness({
{assetLibrary.activeUploadFolderId}
</span>
<span data-testid="all-selected">
{String(assetLibrary.allSelectableAssetsSelected)}
{String(
assetLibrary.selectableAssets.length > 0 &&
assetLibrary.selectableAssets.every((asset) =>
assetLibrary.selectedAssetIds.has(asset.id),
),
)}
</span>
<span data-testid="selected-assets">
{[...assetLibrary.selectedAssetIds].join('|')}
</span>
<span data-testid="selection-mode">
{String(assetLibrary.isAssetSelectionMode)}
</span>
<span data-testid="new-folder-name">{assetLibrary.newFolderName}</span>
<button
type="button"
@@ -179,12 +199,25 @@ function AssetLibraryHarness({
>
submit asset to showcase
</button>
<button type="button" onClick={assetLibrary.toggleAllAssetsSelected}>
<button
type="button"
onClick={() =>
assetLibrary.toggleAllAssetsSelected(
assetLibrary.selectableAssets.map((asset) => asset.id),
)
}
>
toggle all
</button>
<button type="button" onClick={assetLibrary.deleteSelectedAssets}>
delete selected
</button>
<button type="button" onClick={assetLibrary.openAssetSelectionMode}>
open selection
</button>
<button type="button" onClick={assetLibrary.closeAssetSelectionMode}>
close selection
</button>
</div>
);
}
@@ -292,9 +325,7 @@ describe('useImageCanvasAssetLibrary', () => {
}),
);
render(
<AssetLibraryHarness openEditorLoginModal={openEditorLoginModal} />,
);
render(<AssetLibraryHarness openEditorLoginModal={openEditorLoginModal} />);
await waitFor(() => {
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
@@ -330,9 +361,7 @@ describe('useImageCanvasAssetLibrary', () => {
});
act(() => screen.getByRole('button', { name: 'commit folder' }).click());
await waitFor(() => {
expect(screen.getByTestId('folders').textContent).toContain(
'folder-',
);
expect(screen.getByTestId('folders').textContent).toContain('folder-');
});
act(() => {
resolveCreateFolder({
@@ -427,18 +456,31 @@ describe('useImageCanvasAssetLibrary', () => {
expect(screen.getByTestId('all-selected').textContent).toBe('true');
act(() => screen.getByRole('button', { name: '素材B' }).click());
await waitFor(() => {
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-a');
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-b');
});
act(() => screen.getByRole('button', { name: 'delete selected' }).click());
expect(screen.getByTestId('assets').textContent).toBe(
'asset-b:素材B:project:unset',
'asset-a:素材A:project:unset',
);
expect(onDeleteAssets).toHaveBeenCalledWith([
expect.objectContaining({ id: 'asset-a' }),
expect.objectContaining({ id: 'asset-b' }),
]);
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-b');
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b');
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-a');
});
it('clears selected status when selection mode closes', async () => {
render(<AssetLibraryHarness />);
await screen.findByText('素材A');
act(() => screen.getByRole('button', { name: 'open selection' }).click());
act(() => screen.getByRole('button', { name: '素材A' }).click());
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-a');
act(() => screen.getByRole('button', { name: 'close selection' }).click());
expect(screen.getByTestId('selection-mode').textContent).toBe('false');
expect(screen.getByTestId('selected-assets').textContent).toBe('');
});
it('submits a generated asset to showcase review', async () => {
@@ -6,6 +6,7 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -20,7 +21,7 @@ import {
updateEditorAssetFolder,
} from '../../services/image-editor/editorProjectClient';
import {
areAllSelectableAssetsSelected,
type AssetSelectionInteraction,
createAssetMarqueeFromPointer,
createAssetMarqueeSelectionRect,
createLocalAssetFolder,
@@ -29,20 +30,21 @@ import {
groupAssetsByFolder,
moveAssetMarqueeToPointer,
moveAssetToFolderLocally,
reconcileAssetSelection,
removeAssetById,
removeSelectedAssets,
renameAssetById,
renameAssetFolderById,
replaceLocalAssetFolder,
resolveAllAssetSelection,
resolveAssetFolderIdFromPoint,
resolveAssetSelection,
resolveAssetShowcaseSubmissionState,
resolveDefaultAssetFolder,
resolvePinnedAssetMoveFolderId,
resolveVisibleAssetSelectionToggle,
selectUploadedAssetsInRect,
shouldStartAssetMarquee,
toggleAssetFolderCollapsed,
toggleAssetSelection,
} from './ImageCanvasAssetLibraryModel';
import {
EDITOR_ASSET_FOLDERS,
@@ -81,7 +83,8 @@ const ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR =
function readAssetFolderHitTargets(listElement: ParentNode | null) {
return [
...(listElement?.querySelectorAll<HTMLElement>(ASSET_FOLDER_SELECTOR) ?? []),
...(listElement?.querySelectorAll<HTMLElement>(ASSET_FOLDER_SELECTOR) ??
[]),
]
.map((element) => {
const folderId = element.dataset.assetFolderId;
@@ -140,6 +143,8 @@ export function useImageCanvasAssetLibrary({
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(
() => new Set(),
);
const assetSelectionAnchorRef = useRef<string | null>(null);
const marqueeBaseSelectionRef = useRef<Set<string>>(new Set());
const [assetMarquee, setAssetMarquee] = useState<AssetMarqueeState | null>(
null,
);
@@ -156,14 +161,7 @@ export function useImageCanvasAssetLibrary({
() => groupAssetsByFolder(assetFolders, assets),
[assetFolders, assets],
);
const selectableAssets = useMemo(
() => getSelectableAssets(assets),
[assets],
);
const allSelectableAssetsSelected = areAllSelectableAssetsSelected(
selectableAssets,
selectedAssetIds,
);
const selectableAssets = useMemo(() => getSelectableAssets(assets), [assets]);
const applyAssetLibrarySnapshot = useCallback(
(
@@ -200,11 +198,7 @@ export function useImageCanvasAssetLibrary({
openEditorLoginModal();
}
}
}, [
applyAssetLibrarySnapshot,
canAccessProtectedData,
openEditorLoginModal,
]);
}, [applyAssetLibrarySnapshot, canAccessProtectedData, openEditorLoginModal]);
useEffect(() => {
if (!canAccessProtectedData) {
@@ -326,7 +320,8 @@ export function useImageCanvasAssetLibrary({
label,
assetFolders.length + 100,
);
setAssetFolders((currentFolders) =>
setAssetFolders(
(currentFolders) =>
replaceLocalAssetFolder({
folders: currentFolders,
assets: [],
@@ -334,7 +329,8 @@ export function useImageCanvasAssetLibrary({
persistedFolder: folder,
}).folders,
);
setAssets((currentAssets) =>
setAssets(
(currentAssets) =>
replaceLocalAssetFolder({
folders: [],
assets: currentAssets,
@@ -365,8 +361,7 @@ export function useImageCanvasAssetLibrary({
[onDeleteAssets],
);
const submitAssetToShowcase = useCallback(
(asset: EditorAsset) => {
const submitAssetToShowcase = useCallback((asset: EditorAsset) => {
if (!resolveAssetShowcaseSubmissionState(asset).canSubmit) {
return;
}
@@ -392,7 +387,8 @@ export function useImageCanvasAssetLibrary({
? {
...currentAsset,
publicShowcaseEnabled: false,
showcaseId: showcaseAsset.showcaseId ?? showcaseAsset.resourceId,
showcaseId:
showcaseAsset.showcaseId ?? showcaseAsset.resourceId,
showcaseReviewStatus: showcaseAsset.reviewStatus ?? 'pending',
showcaseDisplayEnabled: showcaseAsset.displayEnabled ?? false,
showcaseLikeCount: showcaseAsset.likeCount ?? 0,
@@ -418,9 +414,7 @@ export function useImageCanvasAssetLibrary({
),
);
});
},
[],
);
}, []);
const startRenamingFolder = useCallback((folder: EditorAssetFolder) => {
setRenamingFolder({
@@ -458,7 +452,8 @@ export function useImageCanvasAssetLibrary({
if (!defaultFolder) {
return;
}
setAssetFolders((currentFolders) =>
setAssetFolders(
(currentFolders) =>
deleteAssetFolderLocally({
folders: currentFolders,
assets: [],
@@ -466,7 +461,8 @@ export function useImageCanvasAssetLibrary({
defaultFolderId: defaultFolder.id,
}).folders,
);
setAssets((currentAssets) =>
setAssets(
(currentAssets) =>
deleteAssetFolderLocally({
folders: [],
assets: currentAssets,
@@ -487,30 +483,56 @@ export function useImageCanvasAssetLibrary({
[assetFolders],
);
const toggleAssetSelected = useCallback((assetId: string) => {
const toggleAssetSelected = useCallback(
(assetId: string, interaction: AssetSelectionInteraction) => {
setSelectedAssetIds((currentIds) => {
const nextSelection = resolveAssetSelection({
selectedAssetIds: currentIds,
anchorAssetId: assetSelectionAnchorRef.current,
targetAssetId: assetId,
...interaction,
});
assetSelectionAnchorRef.current = nextSelection.anchorAssetId;
return nextSelection.selectedAssetIds;
});
},
[],
);
const toggleAllAssetsSelected = useCallback((visibleAssetIds: string[]) => {
setSelectedAssetIds((currentIds) =>
toggleAssetSelection(currentIds, assetId),
resolveVisibleAssetSelectionToggle({
selectedAssetIds: currentIds,
visibleAssetIds,
}),
);
}, []);
const toggleAllAssetsSelected = useCallback(() => {
setSelectedAssetIds(
resolveAllAssetSelection({
allSelectableAssetsSelected,
selectableAssets,
}),
);
}, [allSelectableAssetsSelected, selectableAssets]);
const reconcileSelectedAssets = useCallback((visibleAssetIds: string[]) => {
setSelectedAssetIds((currentIds) => {
const nextSelection = reconcileAssetSelection({
selectedAssetIds: currentIds,
anchorAssetId: assetSelectionAnchorRef.current,
visibleAssetIds,
});
assetSelectionAnchorRef.current = nextSelection.anchorAssetId;
return nextSelection.selectedAssetIds;
});
}, []);
const deleteSelectedAssets = useCallback(() => {
const ids = [...selectedAssetIds];
const deletedAssets = removeSelectedAssets(assets, selectedAssetIds)
.deletedAssets;
const deletedAssets = removeSelectedAssets(
assets,
selectedAssetIds,
).deletedAssets;
setAssets(
(currentAssets) => removeSelectedAssets(currentAssets, selectedAssetIds).assets,
(currentAssets) =>
removeSelectedAssets(currentAssets, selectedAssetIds).assets,
);
onDeleteAssets?.(deletedAssets);
setSelectedAssetIds(new Set());
assetSelectionAnchorRef.current = null;
ids.forEach((assetId) => {
void deleteEditorAsset(assetId);
});
@@ -535,9 +557,21 @@ export function useImageCanvasAssetLibrary({
const closeAssetSelectionMode = useCallback(() => {
setIsAssetSelectionMode(false);
setSelectedAssetIds(new Set());
assetSelectionAnchorRef.current = null;
marqueeBaseSelectionRef.current = new Set();
setAssetMarquee(null);
}, []);
const openAssetSelectionMode = useCallback(() => {
assetSelectionAnchorRef.current = null;
marqueeBaseSelectionRef.current = new Set();
setRenamingAsset(null);
setRenamingFolder(null);
setSelectedAssetIds(new Set());
setAssetMarquee(null);
setIsAssetSelectionMode(true);
}, []);
const updateAssetSelectionFromMarquee = useCallback(
(selectionRect: {
left: number;
@@ -545,12 +579,13 @@ export function useImageCanvasAssetLibrary({
top: number;
bottom: number;
}) => {
setSelectedAssetIds(
selectUploadedAssetsInRect({
const marqueeSelection = selectUploadedAssetsInRect({
assets,
assetTargets: readAssetHitTargets(assetListRef.current),
selectionRect,
}),
});
setSelectedAssetIds(
new Set([...marqueeBaseSelectionRef.current, ...marqueeSelection]),
);
},
[assetListRef, assets],
@@ -579,9 +614,12 @@ export function useImageCanvasAssetLibrary({
containerRect: assetListRef.current?.getBoundingClientRect(),
}),
);
setSelectedAssetIds(new Set());
marqueeBaseSelectionRef.current =
event.ctrlKey || event.metaKey ? new Set(selectedAssetIds) : new Set();
assetSelectionAnchorRef.current = null;
setSelectedAssetIds(new Set(marqueeBaseSelectionRef.current));
},
[assetListRef, isAssetSelectionMode],
[assetListRef, isAssetSelectionMode, selectedAssetIds],
);
const handleAssetMarqueePointerMove = useCallback(
@@ -632,7 +670,6 @@ export function useImageCanvasAssetLibrary({
setAssets,
groupedAssets,
selectableAssets,
allSelectableAssetsSelected,
renamingAsset,
setRenamingAsset,
renamingFolder,
@@ -644,9 +681,7 @@ export function useImageCanvasAssetLibrary({
activeUploadFolderId,
setActiveUploadFolderId,
isAssetSelectionMode,
setIsAssetSelectionMode,
selectedAssetIds,
setSelectedAssetIds,
assetMarquee,
setAssetMarquee,
assetPointerDrag,
@@ -666,9 +701,11 @@ export function useImageCanvasAssetLibrary({
deleteAssetFolder,
toggleAssetSelected,
toggleAllAssetsSelected,
reconcileSelectedAssets,
deleteSelectedAssets,
moveAssetToFolder,
closeAssetSelectionMode,
openAssetSelectionMode,
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
+22 -7
View File
@@ -4126,6 +4126,18 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
padding: 0.65rem;
}
.image-canvas-editor__asset-library {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.image-canvas-editor__asset-library > .image-canvas-editor__asset-list {
flex: 1;
}
.image-canvas-editor__asset-folder {
display: grid;
gap: 0.45rem;
@@ -4328,13 +4340,6 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
background: var(--image-canvas-brand-accent);
}
.image-canvas-editor__asset-batch-toolbar {
position: sticky;
bottom: 0;
justify-content: center;
margin-top: 0.35rem;
}
.image-canvas-editor__asset-marquee {
position: absolute;
z-index: 4;
@@ -8845,6 +8850,11 @@ button.image-canvas-editor__reference-chip:disabled {
border-bottom: 1px solid #d9dee8;
}
.image-canvas-editor__sidebar--asset-selection {
height: clamp(19rem, 62dvh, 21rem);
max-height: clamp(19rem, 62dvh, 21rem);
}
.image-canvas-editor__asset-list {
grid-auto-flow: column;
grid-auto-columns: minmax(15rem, 78vw);
@@ -8852,6 +8862,11 @@ button.image-canvas-editor__reference-chip:disabled {
overflow-y: hidden;
}
.image-canvas-editor__sidebar--asset-selection
.image-canvas-editor__asset-list {
overflow-y: auto;
}
.image-canvas-editor__layers-list {
grid-auto-flow: column;
grid-auto-columns: minmax(12.5rem, 70vw);