修复素材多选与导出边界问题

修复触摸滚动、滚动框选坐标和抬手/取消语义
统一不可选素材行的交互与无障碍状态
支持仅帧来源的序列帧导出并统一导出互斥锁
补充定向回归测试并同步素材选择与导出文档
This commit is contained in:
2026-08-07 12:11:17 +08:00
parent ef42374b44
commit d1a9b36104
16 changed files with 408 additions and 47 deletions
@@ -6014,8 +6014,8 @@
## 2026-07-27 图片画布左侧素材库统一稳定多选与批量操作
- 范围边界:本次只重写左侧素材库选择模式,不改变中央画布舞台和图层列表的选择、框选或下载语义。选择集合以全部上传完成且媒体地址有效的素材为有效性边界,不因搜索、折叠或展开变化而收缩;只有素材被删除、进入上传中 / 失败态或媒体地址失效时才清理对应选择和范围锚点。
- 输入语义:鼠标、键盘、触摸和笔输入单击都只切换当前素材,不替换其它已选素材;`Shift + 点击` 按当前可见顺序把连续区间增量加入现有选择,锚点当前不可见时退化为切换目标素材并建立新锚点。当前搜索结果的全选 / 取消全选只增量增删已展开的可见素材并保留其它选择,同时清空上次单项选择的范围锚点。退出选择模式、关闭素材栏或切到图层栏统一清空选择、锚点和框选状态,非选择模式不显示历史选中高亮。
- 工具栏与导出:批量工具栏作为素材滚动列表的固定非滚动底栏,展示跨搜索与折叠状态保留的全部已选数量,并提供当前可见范围全选 / 取消全选、下载、删除和取消。下载消费完整选中集合;删除完整选中集合时,如果其中存在当前未显示素材,必须先用危险确认弹窗明确展示全部删除数量和未显示数量,用户确认前不得执行删除。选择模式隐藏单行下载 / 重命名并禁用行拖拽和右键菜单。移动端选择模式使用独立的侧栏高度状态,并让素材列表恢复纵向滚动,避免普通模式 `14rem` 高度上限被固定底栏、标题和搜索区吃完。一个选中素材直接下载,多个选中素材复用画布素材导出管线生成 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,根目录为 `项目名-选中素材/`;单素材、序列帧和两类集合 ZIP 的下载名统一包含到秒的本地时间戳。素材卡整行是统一选择命中区,标题和空白区不得绕过单项切换或 Shift 范围处理。
- 输入语义:鼠标、键盘、触摸和笔输入单击都只切换当前素材,不替换其它已选素材;`Shift + 点击` 按当前可见顺序把连续区间增量加入现有选择,锚点当前不可见时退化为切换目标素材并建立新锚点。当前搜索结果的全选 / 取消全选只增量增删已展开的可见素材并保留其它选择,同时清空上次单项选择的范围锚点。退出选择模式、关闭素材栏或切到图层栏统一清空选择、锚点和框选状态,非选择模式不显示历史选中高亮。触摸素材卡仍可单击切换,但触摸列表空白区域必须保留纵向滚动,不启动框选;鼠标 / 笔框选使用素材列表内容坐标承接滚动偏移,并以 `pointerup` 的最终坐标提交,`pointercancel` 只取消框选。
- 工具栏与导出:批量工具栏作为素材滚动列表的固定非滚动底栏,展示跨搜索与折叠状态保留的全部已选数量,并提供当前可见范围全选 / 取消全选、下载、删除和取消。下载消费完整选中集合;删除完整选中集合时,如果其中存在当前未显示素材,必须先用危险确认弹窗明确展示全部删除数量和未显示数量,用户确认前不得执行删除。选择模式隐藏单行下载 / 重命名并禁用行拖拽和右键菜单;内置、上传未完成、上传失败或无可读来源的行显示为不可选择,不暴露虚假的可用按钮。移动端选择模式使用独立的侧栏高度状态,并让素材列表恢复纵向滚动,避免普通模式 `14rem` 高度上限被固定底栏、标题和搜索区吃完。一个选中素材直接下载,多个选中素材复用画布素材导出管线生成 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,根目录为 `项目名-选中素材/`;单素材、序列帧和两类集合 ZIP 的下载名统一包含到秒的本地时间戳。素材卡整行是统一选择命中区,标题和空白区不得绕过单项切换或 Shift 范围处理。序列帧层的可导出性以至少一帧具有可读 `imageSrc / objectKey` 为准,不依赖层级 `src / objectKey`;单项、选中集合和整画布导出共用一个前端互斥锁,避免下载和状态提示互相覆盖。
## 2026-07-29 抽取通用多 Agent Runtime 公共内核第一阶段
@@ -28,7 +28,7 @@
- 锁定、分组、翻转等状态不影响图片文件导出,但写入元数据。
- 空画布时按钮置灰,或点击后显示轻提示。
左侧素材库选择模式同时支持导出当前可见范围内的选中素材:只选中一个普通素材时直接下载原文件;选中多个素材时沿用画布素材 ZIP 的读取、去重、媒体分目录、序列帧、元数据、部分失败和浏览器下载能力,生成 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,包内根目录为 `项目名-选中素材/`。单素材、单序列帧、选中素材 ZIP 和画布素材 ZIP 的下载文件名都必须包含到秒的本地时间戳,避免同一天重复导出时重名。该入口不改变中央画布和图层列表的选择 / 下载语义。
左侧素材库选择模式同时支持导出当前可见范围内的选中素材:只选中一个普通素材时直接下载原文件;选中多个素材时沿用画布素材 ZIP 的读取、去重、媒体分目录、序列帧、元数据、部分失败和浏览器下载能力,生成 `项目名-选中素材-YYYYMMDD-HHmmss.zip`,包内根目录为 `项目名-选中素材/`。单素材、单序列帧、选中素材 ZIP 和画布素材 ZIP 的下载文件名都必须包含到秒的本地时间戳,避免同一天重复导出时重名。序列帧层只要至少一帧具有可读 `imageSrc / objectKey` 即可进入导出计划,不要求层级 `src / objectKey`;单层、选中素材和画布集合导出必须共用同一互斥状态,避免并发下载覆盖进度与结果提示。该入口不改变中央画布和图层列表的选择 / 下载语义。
持久素材只要 `src``objectKey` 任一有效即可进入点击、Shift、框选和全选范围。`src` 为空但保留私有 `objectKey` 的素材,导出时复用统一资源读取链路换签;浏览器无法直读签名 URL 时继续回退同源 `/api/assets/read-bytes` 字节代理,不得因缺少临时展示地址而过滤。
@@ -199,7 +199,7 @@
- 画布素材 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-*` 自包含动作布局仍可读取。
- 素材面板支持按素材名、文件夹名、生成信息、用户可见模型、任务和媒体类型搜索,并支持选择模式框选。选择模式的选中集合以全部上传完成且媒体地址有效的可选素材为有效性边界,不因搜索、折叠或展开变化而收缩;只有素材被删除、进入上传中 / 失败态或媒体地址失效时才剔除对应选择及范围锚点。素材缩略图、标题、元数据和卡片空白区统一作为整行选择命中区:鼠标、键盘、触摸和笔输入单击都只切换当前素材,不替换其它已选素材;`Shift + 点击` 按当前可见顺序把连续区间增量加入现有选择,锚点当前不可见时退化为切换目标素材并建立新锚点。当前搜索结果的全选 / 取消全选只增量增删当前已展开的可见素材并保留其它选择,同时清空上次单项选择的范围锚点。退出选择模式、关闭素材栏或切换到图层栏时必须同时清空选择锚点、选中集合和框选状态,非选择模式不得残留选中高亮。
- 素材面板支持按素材名、文件夹名、生成信息、用户可见模型、任务和媒体类型搜索,并支持选择模式框选。选择模式的选中集合以全部上传完成且媒体地址有效的可选素材为有效性边界,不因搜索、折叠或展开变化而收缩;只有素材被删除、进入上传中 / 失败态或媒体地址失效时才剔除对应选择及范围锚点。素材缩略图、标题、元数据和卡片空白区统一作为整行选择命中区:鼠标、键盘、触摸和笔输入单击都只切换当前素材,不替换其它已选素材;`Shift + 点击` 按当前可见顺序把连续区间增量加入现有选择,锚点当前不可见时退化为切换目标素材并建立新锚点。当前搜索结果的全选 / 取消全选只增量增删当前已展开的可见素材并保留其它选择,同时清空上次单项选择的范围锚点。触摸素材卡仍可切换选中态,但触摸列表空白区必须继续滚动,不能捕获为框选;鼠标 / 笔框选坐标必须包含列表滚动偏移,`pointerup` 提交最后坐标,`pointercancel` 只取消。内置、上传未完成、上传失败或无可读来源的素材行不得暴露可用的选择按钮。退出选择模式、关闭素材栏或切换到图层栏时必须同时清空选择锚点、选中集合和框选状态,非选择模式不得残留选中高亮。
- 素材选择模式的批量工具栏是素材列表滚动区之外的固定底栏,始终显示跨搜索与折叠状态保留的全部已选数量、当前可见素材全选 / 取消全选、下载、删除和取消;下载和删除作用于完整选中集合,不得只消费当前可见 ID。删除集合中存在当前未显示素材时,必须先用危险确认弹窗明确展示全部删除数量和未显示数量,确认前不得执行删除。工具栏自身禁止横向溢出和滚动,空间不足时按钮在工具栏内部换行。不得把工具栏作为滚动列表末项或依赖 `position: sticky` 的自然位置。移动端进入素材选择模式时必须提高素材侧栏高度并允许素材列表纵向滚动,为固定底栏之上至少保留一张素材卡的可操作空间,不得沿用普通模式 `14rem` 上限压扁列表。选择模式隐藏单素材下载 / 重命名动作并禁用素材拖拽和右键菜单,避免批量意图回退成单素材操作。Provider 与 `isEditorInternalProcessingModel(...)` 识别的内部处理模型不得进入搜索索引,避免出现隐藏字段可命中或命中但无可见匹配字段;原始审计元数据只保留给服务端和后台管理读取,普通用户(包括素材 owner)不得通过响应取得。
- 图层面板支持按图层名、生成信息、用户可见模型、任务和媒体类型搜索;Provider 与内部处理模型同样不得进入搜索索引。普通用户图层快照也不得携带这些内部字段。支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。
@@ -80,6 +80,9 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
onAssetMarqueePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerCancel: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueeLostPointerCapture: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
@@ -139,6 +142,7 @@ export function ImageCanvasAssetLibraryPanelView({
onAssetMarqueePointerDown,
onAssetMarqueePointerMove,
onAssetMarqueePointerUp,
onAssetMarqueePointerCancel,
onAssetMarqueeLostPointerCapture,
updateAssetMoveDropFolder,
addUploadedFiles,
@@ -194,7 +198,7 @@ export function ImageCanvasAssetLibraryPanelView({
onPointerDown={onAssetMarqueePointerDown}
onPointerMove={onAssetMarqueePointerMove}
onPointerUp={onAssetMarqueePointerUp}
onPointerCancel={onAssetMarqueePointerUp}
onPointerCancel={onAssetMarqueePointerCancel}
onLostPointerCapture={onAssetMarqueeLostPointerCapture}
>
{pinnedAssetMoveFolderId ? (
@@ -230,6 +230,22 @@ describe('ImageCanvasAssetRowView', () => {
).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 modifier keys to selection', () => {
const toggleAssetSelected = vi.fn();
renderAssetRow({ isAssetSelectionMode: true, toggleAssetSelected });
@@ -29,7 +29,10 @@ import {
getCanvasMediaPreviewClassName,
getCanvasMediaPreviewMarker,
} from './ImageCanvasMediaModel';
import type { AssetSelectionInteraction } from './useImageCanvasAssetSelection';
import {
type AssetSelectionInteraction,
isSelectableAsset,
} from './useImageCanvasAssetSelection';
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
@@ -96,6 +99,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;
@@ -119,8 +123,7 @@ export function ImageCanvasAssetRowView({
selectionPointerKindRef.current = 'keyboard';
if (
!isAssetSelectionMode ||
isUploadingAsset ||
isFailedUpload ||
!canSelectAsset ||
suppressAssetClickRef.current
) {
return;
@@ -136,10 +139,9 @@ export function ImageCanvasAssetRowView({
},
[
asset.id,
canSelectAsset,
closeMenu,
isAssetSelectionMode,
isFailedUpload,
isUploadingAsset,
suppressAssetClickRef,
toggleAssetSelected,
visibleAssetIds,
@@ -316,9 +318,12 @@ export function ImageCanvasAssetRowView({
: isFailedUpload
? `上传失败${asset.label}`
: isAssetSelectionMode
? `选择素材${asset.label}`
? canSelectAsset
? `选择素材${asset.label}`
: `素材${asset.label}不可选择`
: `添加${asset.label}`
}
primaryDisabled={isAssetSelectionMode && !canSelectAsset}
onPrimaryClick={() => {
if (isUploadingAsset || isFailedUpload) {
return;
@@ -63,6 +63,7 @@ export type SidebarMediaItemProps = {
imageAlt: string;
selected?: boolean;
primaryLabel: string;
primaryDisabled?: boolean;
onPrimaryClick: MouseEventHandler<HTMLButtonElement>;
thumbnailClassName: string;
metaClassName: 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}
@@ -82,6 +82,7 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
onAssetMarqueePointerDown: vi.fn(),
onAssetMarqueePointerMove: vi.fn(),
onAssetMarqueePointerUp: vi.fn(),
onAssetMarqueePointerCancel: vi.fn(),
onAssetMarqueeLostPointerCapture: vi.fn(),
updateAssetMoveDropFolder: vi.fn(),
addUploadedFiles: vi.fn(),
@@ -743,6 +743,7 @@ export function ImageCanvasEditorView({
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
handleAssetMarqueePointerCancel,
handleAssetMarqueeLostPointerCapture,
refreshAssetLibrary,
} = useImageCanvasAssetLibrary({
@@ -2480,6 +2481,7 @@ export function ImageCanvasEditorView({
onAssetMarqueePointerDown: handleAssetMarqueePointerDown,
onAssetMarqueePointerMove: handleAssetMarqueePointerMove,
onAssetMarqueePointerUp: handleAssetMarqueePointerUp,
onAssetMarqueePointerCancel: handleAssetMarqueePointerCancel,
onAssetMarqueeLostPointerCapture: handleAssetMarqueeLostPointerCapture,
updateAssetMoveDropFolder,
addUploadedFiles,
@@ -101,6 +101,7 @@ function createSidebarProps(
onAssetMarqueePointerDown: vi.fn(),
onAssetMarqueePointerMove: vi.fn(),
onAssetMarqueePointerUp: vi.fn(),
onAssetMarqueePointerCancel: vi.fn(),
onAssetMarqueeLostPointerCapture: vi.fn(),
updateAssetMoveDropFolder: vi.fn(),
addUploadedFiles: vi.fn(),
@@ -76,6 +76,9 @@ export type ImageCanvasSidebarViewProps = {
onAssetMarqueePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerCancel: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueeLostPointerCapture: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
@@ -151,6 +154,7 @@ export function ImageCanvasSidebarView({
onAssetMarqueePointerDown,
onAssetMarqueePointerMove,
onAssetMarqueePointerUp,
onAssetMarqueePointerCancel,
onAssetMarqueeLostPointerCapture,
updateAssetMoveDropFolder,
addUploadedFiles,
@@ -339,6 +343,7 @@ export function ImageCanvasSidebarView({
onAssetMarqueePointerDown={onAssetMarqueePointerDown}
onAssetMarqueePointerMove={onAssetMarqueePointerMove}
onAssetMarqueePointerUp={onAssetMarqueePointerUp}
onAssetMarqueePointerCancel={onAssetMarqueePointerCancel}
onAssetMarqueeLostPointerCapture={onAssetMarqueeLostPointerCapture}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
@@ -74,6 +74,15 @@ function ExportWorkflowHarness({ layers }: { layers: CanvasLayer[] }) {
>
</button>
<button
type="button"
onClick={() => {
void workflow.exportCanvasAssets();
void workflow.exportLayerImage(layers[0] ?? null);
}}
>
</button>
<button
type="button"
onClick={() => workflow.exportLayerImage(layers[0] ?? null)}
@@ -301,6 +310,115 @@ describe('useImageCanvasAssetExportWorkflow', () => {
}
});
it('uses the same mutex for collection and direct layer exports', async () => {
const originalFetch = globalThis.fetch;
let resolveFetch: (response: Response) => void = () => {};
const fetchMock = vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
);
globalThis.fetch = fetchMock as typeof fetch;
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:shared-mutex-export'),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
try {
render(
<ExportWorkflowHarness
layers={[createLayer('slow-export', { src: '/slow-export.png' })]}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '同时导出画布和单图' }),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
resolveFetch(new Response(new Blob(['image'], { type: 'image/png' })));
await waitFor(() => {
expect(screen.getByTestId('status').textContent).toBe(
'success:画布素材已导出',
);
});
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
} finally {
globalThis.fetch = originalFetch;
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('exports frame-only image sequences from selected and canvas entry points', 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:frame-only-${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('frame-only-sequence', {
title: '仅帧序列',
src: '',
objectKey: null,
mediaType: 'image-sequence',
imageSequenceFrames: [
{
imageSrc: 'data:image/png;base64,c2VxdWVuY2U=',
width: 100,
height: 80,
},
],
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '下载一个选中素材' }));
await waitFor(() => expect(downloads).toHaveLength(1));
expect(downloads[0]?.name).toMatch(
/^-SpineJSON-\d{8}-\d{6}\.zip$/u,
);
fireEvent.click(screen.getByRole('button', { name: '导出画布素材' }));
await waitFor(() => expect(downloads).toHaveLength(2));
const zip = await JSZip.loadAsync(downloads[1]!.blob);
expect(
zip.file(
'导出项目-画布素材/sequences/001-仅帧序列/frames/frame-01.png',
),
).toBeTruthy();
} finally {
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('reads layers and sequence frames with bounded concurrency and stable zip order', async () => {
const originalFetch = globalThis.fetch;
const pendingResponses = new Map<string, () => void>();
@@ -41,6 +41,15 @@ type UseImageCanvasAssetExportWorkflowOptions = {
const GIF_PREVIEW_MAX_EDGE = 320;
const ASSET_EXPORT_READ_CONCURRENCY = 4;
function hasReadableLayerExportSource(layer: CanvasLayer) {
if (layer.mediaType === 'image-sequence') {
return getLayerImageSequenceFrames(layer).some((frame) =>
hasReadableAssetSource(frame.imageSrc, frame.objectKey),
);
}
return hasReadableAssetSource(layer.src, layer.objectKey);
}
type AssetExportPlan = {
key: string;
layer: CanvasLayer;
@@ -341,7 +350,7 @@ export function useImageCanvasAssetExportWorkflow({
const [isExportingAssets, setIsExportingAssets] = useState(false);
const isExportingAssetsRef = useRef(false);
const exportLayerImage = useCallback(
const exportLayerImageUnlocked = useCallback(
async (
layer: CanvasLayer | null,
options: { mode?: ImageSequenceExportMode } = {},
@@ -408,6 +417,27 @@ export function useImageCanvasAssetExportWorkflow({
[],
);
const exportLayerImage = useCallback(
async (
layer: CanvasLayer | null,
options: { mode?: ImageSequenceExportMode } = {},
) => {
if (!layer || isExportingAssetsRef.current) {
return false;
}
isExportingAssetsRef.current = true;
setIsExportingAssets(true);
setAssetExportStatus(null);
try {
return await exportLayerImageUnlocked(layer, options);
} finally {
isExportingAssetsRef.current = false;
setIsExportingAssets(false);
}
},
[exportLayerImageUnlocked],
);
const exportAssetCollection = useCallback(
async ({
targetLayers,
@@ -424,7 +454,7 @@ export function useImageCanvasAssetExportWorkflow({
return;
}
const exportableLayers = targetLayers
.filter((layer) => hasReadableAssetSource(layer.src, layer.objectKey))
.filter(hasReadableLayerExportSource)
.sort((left, right) => left.zIndex - right.zIndex);
if (!exportableLayers.length) {
setAssetExportStatus({
@@ -764,8 +794,8 @@ export function useImageCanvasAssetExportWorkflow({
if (isExportingAssetsRef.current) {
return;
}
const exportableLayers = selectedLayers.filter((layer) =>
hasReadableAssetSource(layer.src, layer.objectKey),
const exportableLayers = selectedLayers.filter(
hasReadableLayerExportSource,
);
if (!exportableLayers.length) {
setAssetExportStatus({
@@ -775,22 +805,12 @@ export function useImageCanvasAssetExportWorkflow({
return;
}
if (exportableLayers.length === 1) {
isExportingAssetsRef.current = true;
setIsExportingAssets(true);
setAssetExportStatus(null);
try {
const downloaded = await exportLayerImage(
exportableLayers[0] ?? null,
);
if (downloaded) {
setAssetExportStatus({
tone: 'success',
message: '选中素材已导出',
});
}
} finally {
isExportingAssetsRef.current = false;
setIsExportingAssets(false);
const downloaded = await exportLayerImage(exportableLayers[0] ?? null);
if (downloaded) {
setAssetExportStatus({
tone: 'success',
message: '选中素材已导出',
});
}
return;
}
@@ -139,6 +139,7 @@ export function useImageCanvasAssetLibrary({
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
handleAssetMarqueePointerCancel,
handleAssetMarqueeLostPointerCapture,
} = useImageCanvasAssetSelection({
assetListRef,
@@ -544,6 +545,7 @@ export function useImageCanvasAssetLibrary({
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
handleAssetMarqueePointerCancel,
handleAssetMarqueeLostPointerCapture,
refreshAssetLibrary,
};
@@ -115,6 +115,41 @@ describe('useImageCanvasAssetSelection', () => {
]).toEqual(['asset-a', 'asset-b', 'object-key-only']);
});
it('keeps marquee geometry aligned with a scrolling asset list', () => {
const container = { scrollLeft: 40, scrollTop: 60 };
const marquee = createAssetMarqueeFromPointer({
pointerId: 9,
point: { clientX: 180, clientY: 220 },
containerRect: { left: 100, top: 200 },
container,
});
expect(marquee).toMatchObject({
startX: 120,
startY: 80,
currentX: 120,
currentY: 80,
});
container.scrollLeft = 50;
container.scrollTop = 70;
expect(
moveAssetMarqueeToPointer({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
container,
}),
).toMatchObject({ currentX: 70, currentY: 75 });
expect(
createAssetMarqueeSelectionRect({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
container,
}),
).toEqual({ left: 120, right: 170, top: 205, bottom: 210 });
});
it('supports click, shift and select-all for object-key-only assets', () => {
const assets = [
createAsset({
@@ -288,6 +323,105 @@ describe('useImageCanvasAssetSelection', () => {
expect(result.current.selectedAssetIds.size).toBe(0);
});
it('leaves touch background gestures available for list scrolling', () => {
const assetList = document.createElement('div');
const setPointerCapture = vi.fn();
Object.assign(assetList, { setPointerCapture });
const { result } = renderHook(() =>
useImageCanvasAssetSelection({
assetListRef: { current: assetList },
assets: [createAsset()],
}),
);
const preventDefault = vi.fn();
act(() => result.current.openAssetSelectionMode());
act(() =>
result.current.handleAssetMarqueePointerDown({
target: assetList,
pointerType: 'touch',
button: 0,
pointerId: 3,
clientX: 10,
clientY: 20,
ctrlKey: false,
metaKey: false,
preventDefault,
} as unknown as ReactPointerEvent<HTMLDivElement>),
);
expect(result.current.assetMarquee).toBeNull();
expect(preventDefault).not.toHaveBeenCalled();
expect(setPointerCapture).not.toHaveBeenCalled();
});
it('commits the pointer-up endpoint but not a cancelled marquee', () => {
const assetList = document.createElement('div');
const assetTarget = document.createElement('div');
assetTarget.dataset.assetId = 'asset-a';
assetTarget.getBoundingClientRect = () =>
({ left: 40, right: 50, top: 40, bottom: 50 }) as DOMRect;
assetList.appendChild(assetTarget);
assetList.getBoundingClientRect = () =>
({ left: 0, right: 100, top: 0, bottom: 100 }) as DOMRect;
Object.assign(assetList, {
setPointerCapture: vi.fn(),
hasPointerCapture: vi.fn(() => true),
releasePointerCapture: vi.fn(),
});
const { result } = renderHook(() =>
useImageCanvasAssetSelection({
assetListRef: { current: assetList },
assets: [createAsset()],
}),
);
const pointerDown = (pointerId: number) =>
result.current.handleAssetMarqueePointerDown({
target: assetList,
pointerType: 'mouse',
button: 0,
pointerId,
clientX: 10,
clientY: 10,
ctrlKey: false,
metaKey: false,
preventDefault: vi.fn(),
} as unknown as ReactPointerEvent<HTMLDivElement>);
act(() => result.current.openAssetSelectionMode());
act(() => pointerDown(4));
act(() =>
result.current.handleAssetMarqueePointerUp({
pointerId: 4,
clientX: 50,
clientY: 50,
preventDefault: vi.fn(),
} as unknown as ReactPointerEvent<HTMLDivElement>),
);
expect([...result.current.selectedAssetIds]).toEqual(['asset-a']);
expect(result.current.assetMarquee).toBeNull();
act(() => pointerDown(5));
act(() =>
result.current.handleAssetMarqueePointerMove({
pointerId: 5,
clientX: 50,
clientY: 50,
preventDefault: vi.fn(),
} as unknown as ReactPointerEvent<HTMLDivElement>),
);
expect([...result.current.selectedAssetIds]).toEqual(['asset-a']);
act(() =>
result.current.handleAssetMarqueePointerCancel({
pointerId: 5,
clientX: 50,
clientY: 50,
} as unknown as ReactPointerEvent<HTMLDivElement>),
);
expect(result.current.selectedAssetIds.size).toBe(0);
expect(result.current.assetMarquee).toBeNull();
});
it('clears only the matching marquee when pointer capture is lost', () => {
const assetList = document.createElement('div');
const releasePointerCapture = vi.fn();
@@ -15,6 +15,8 @@ type ClientPoint = {
clientY: number;
};
type ScrollContainerLike = Pick<HTMLElement, 'scrollLeft' | 'scrollTop'>;
type AssetHitTarget = {
assetId: string;
rect: ClientRectLike;
@@ -35,16 +37,19 @@ type AssetSelectionState = {
anchorAssetId: string | null;
};
export function getSelectableAssets(assets: EditorAsset[]) {
return assets.filter(
(asset) =>
asset.sourceKind === 'uploaded' &&
asset.uploadStatus !== 'uploading' &&
asset.uploadStatus !== 'failed' &&
hasReadableAssetSource(asset.src, asset.objectKey),
export function isSelectableAsset(asset: EditorAsset) {
return (
asset.sourceKind === 'uploaded' &&
asset.uploadStatus !== 'uploading' &&
asset.uploadStatus !== 'failed' &&
hasReadableAssetSource(asset.src, asset.objectKey)
);
}
export function getSelectableAssets(assets: EditorAsset[]) {
return assets.filter(isSelectableAsset);
}
export function countHiddenSelectedAssets(
selectedAssetIds: Set<string>,
visibleAssetIds: string[],
@@ -79,13 +84,17 @@ export function createAssetMarqueeFromPointer({
pointerId,
point,
containerRect,
container,
}: {
pointerId: number;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
container?: ScrollContainerLike | null;
}): AssetMarqueeState {
const startX = point.clientX - (containerRect?.left ?? 0);
const startY = point.clientY - (containerRect?.top ?? 0);
const startX =
point.clientX - (containerRect?.left ?? 0) + (container?.scrollLeft ?? 0);
const startY =
point.clientY - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0);
return {
pointerId,
startX,
@@ -99,15 +108,19 @@ export function moveAssetMarqueeToPointer({
marquee,
point,
containerRect,
container,
}: {
marquee: AssetMarqueeState;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
container?: ScrollContainerLike | null;
}) {
return {
...marquee,
currentX: point.clientX - (containerRect?.left ?? 0),
currentY: point.clientY - (containerRect?.top ?? 0),
currentX:
point.clientX - (containerRect?.left ?? 0) + (container?.scrollLeft ?? 0),
currentY:
point.clientY - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0),
};
}
@@ -115,13 +128,17 @@ export function createAssetMarqueeSelectionRect({
marquee,
point,
containerRect,
container,
}: {
marquee: AssetMarqueeState;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
container?: ScrollContainerLike | null;
}): ClientRectLike {
const startClientX = (containerRect?.left ?? 0) + marquee.startX;
const startClientY = (containerRect?.top ?? 0) + marquee.startY;
const startClientX =
(containerRect?.left ?? 0) + marquee.startX - (container?.scrollLeft ?? 0);
const startClientY =
(containerRect?.top ?? 0) + marquee.startY - (container?.scrollTop ?? 0);
return {
left: Math.min(startClientX, point.clientX),
right: Math.max(startClientX, point.clientX),
@@ -362,6 +379,7 @@ export function useImageCanvasAssetSelection({
(event: ReactPointerEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
if (
event.pointerType === 'touch' ||
!shouldStartAssetMarquee({
isAssetSelectionMode,
button: event.button,
@@ -373,12 +391,14 @@ export function useImageCanvasAssetSelection({
return;
}
event.preventDefault();
assetListRef.current?.setPointerCapture?.(event.pointerId);
const container = assetListRef.current;
container?.setPointerCapture?.(event.pointerId);
setAssetMarquee(
createAssetMarqueeFromPointer({
pointerId: event.pointerId,
point: event,
containerRect: assetListRef.current?.getBoundingClientRect(),
containerRect: container?.getBoundingClientRect(),
container,
}),
);
marqueeBaseSelectionRef.current =
@@ -399,13 +419,15 @@ export function useImageCanvasAssetSelection({
return;
}
event.preventDefault();
const containerRect = assetListRef.current?.getBoundingClientRect();
const container = assetListRef.current;
const containerRect = container?.getBoundingClientRect();
setAssetMarquee((currentMarquee) =>
currentMarquee
? moveAssetMarqueeToPointer({
marquee: currentMarquee,
point: event,
containerRect,
container,
})
: null,
);
@@ -414,6 +436,7 @@ export function useImageCanvasAssetSelection({
marquee: assetMarquee,
point: event,
containerRect,
container,
}),
);
},
@@ -426,11 +449,37 @@ export function useImageCanvasAssetSelection({
return;
}
event.preventDefault();
const container = assetListRef.current;
updateAssetSelectionFromMarquee(
createAssetMarqueeSelectionRect({
marquee: assetMarquee,
point: event,
containerRect: container?.getBoundingClientRect(),
container,
}),
);
if (assetListRef.current?.hasPointerCapture?.(event.pointerId)) {
assetListRef.current.releasePointerCapture?.(event.pointerId);
}
setAssetMarquee(null);
},
[assetListRef, assetMarquee, updateAssetSelectionFromMarquee],
);
const handleAssetMarqueePointerCancel = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) {
return;
}
if (assetListRef.current?.hasPointerCapture?.(event.pointerId)) {
assetListRef.current.releasePointerCapture?.(event.pointerId);
}
setAssetSelection({
selectedAssetIds: new Set(marqueeBaseSelectionRef.current),
anchorAssetId: null,
});
setAssetMarquee(null);
},
[assetListRef, assetMarquee],
);
@@ -457,6 +506,7 @@ export function useImageCanvasAssetSelection({
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
handleAssetMarqueePointerCancel,
handleAssetMarqueeLostPointerCapture,
};
}