fix: 修复多选逻辑

重构多选逻辑到一个单独的hook
不在最终提交时过滤, 只有选择操作会影响最终结果集合
This commit is contained in:
2026-07-31 19:38:46 +08:00
parent 7cd9aa790c
commit 31900df2b8
18 changed files with 831 additions and 746 deletions
@@ -191,6 +191,14 @@
- 验证:`ImageCanvasEditorModel.test.ts` 覆盖素材库 source resource 保留,`useImageCanvasAssetCanvasBridge.test.tsx` 覆盖资源 ID 级联清理,`ImageCanvasEditorAssetsIntegration.test.tsx` 覆盖删除后保存的新 layout 不再包含被删图层。
- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts``src/components/image-editor/useImageCanvasAssetCanvasBridge.ts``src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`
## 图片画布素材选择有效性不要绑定搜索与折叠可见性
- 现象:批量选择多个素材后,搜索、折叠文件夹或展开文件夹会让已选数量下降、Shift 范围锚点丢失,后续批量下载或删除遗漏此前已选素材。
- 原因:搜索结果和文件夹展开状态只描述当前 UI 可见范围,不描述素材是否仍然有效;用 `visibleAssetIds` reconcile 全局选择会把暂时隐藏误判为素材失效。
- 处理:由唯一 `useImageCanvasAssetSelection` 持有选择集合、范围锚点、框选和全部选择 mutation;全局选择只按全部 `selectableAssetIds` 清理真正删除、上传未完成、上传失败或媒体地址无效的 ID。`visibleAssetIds` 只作为单项切换、Shift 可见区间和当前结果全选 / 取消全选的动作入参,批量下载与删除消费 hook 输出的完整 `selectedAssets`
- 验证:模型测试覆盖隐藏选择保留、可见范围增量和真正失效 ID 清理;图片画布素材集成测试覆盖搜索、折叠 / 展开后选中数量稳定及当前可见全选不影响隐藏选择。
- 关联:`src/components/image-editor/useImageCanvasAssetSelection.ts``src/components/image-editor/useImageCanvasAssetLibrary.ts``src/components/image-editor/ImageCanvasSidebarView.tsx``src/components/image-editor/ImageCanvasEditorView.tsx`
## 后台素材查询不要用 SQL 直查 editor_asset
- 现象:后台“素材查询”报 `HTTP 400no such table: editor_asset. If the table exists, it may be marked private.`
@@ -168,8 +168,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` 上限压扁列表。选择模式隐藏单素材下载 / 重命名动作并禁用素材拖拽和右键菜单,避免批量意图回退成单素材操作。Provider 与 `isEditorInternalProcessingModel(...)` 识别的内部处理模型不得进入搜索索引,避免出现隐藏字段可命中或命中但无可见匹配字段;原始审计元数据只保留给服务端和后台管理读取,普通用户(包括素材 owner)不得通过响应取得。
- 素材面板支持按素材名、文件夹名、生成信息、用户可见模型、任务和媒体类型搜索。选择模式的选中集合以全部上传完成且媒体地址有效的可素材为有效性边界,不因搜索、折叠或展开变化而收缩;只有素材被删除、进入上传中 / 失败态或媒体地址失效时才剔除对应选择及范围锚点。素材缩略图、标题、元数据和卡片空白区统一作为整行选择命中区:鼠标、键盘、触摸和笔输入单击都只切换当前素材,不替换其它已选素材;`Shift + 点击` 按当前可见顺序连续区间增量加入现有选择,锚点当前不可见时退化为切换目标素材并建立新锚点。当前搜索结果的全选 / 取消全选只增量增删当前已展开的可见素材并保留其它选择,同时清空上次单项选择的范围锚点退出选择模式、关闭素材栏或切换到图层栏时必须同时清空选择锚点、选中集合和框选状态,非选择模式不得残留选中高亮。
- 素材选择模式的批量工具栏是素材列表滚动区之外的固定底栏,始终显示跨搜索与折叠状态保留的全部已选数量、当前可见素材全选 / 取消全选、下载、删除和取消;下载和删除作用于完整选中集合,不得只消费当前可见 ID。工具栏自身禁止横向溢出和滚动,空间不足时按钮在工具栏内部换行。不得把工具栏作为滚动列表末项或依赖 `position: sticky` 的自然位置。移动端进入素材选择模式时必须提高素材侧栏高度并允许素材列表纵向滚动,为固定底栏之上至少保留一张素材卡的可操作空间,不得沿用普通模式 `14rem` 上限压扁列表。选择模式隐藏单素材下载 / 重命名动作并禁用素材拖拽和右键菜单,避免批量意图回退成单素材操作。Provider 与 `isEditorInternalProcessingModel(...)` 识别的内部处理模型不得进入搜索索引,避免出现隐藏字段可命中或命中但无可见匹配字段;原始审计元数据只保留给服务端和后台管理读取,普通用户(包括素材 owner)不得通过响应取得。
- 图层面板支持按图层名、生成信息、用户可见模型、任务和媒体类型搜索;Provider 与内部处理模型同样不得进入搜索索引。普通用户图层快照也不得携带这些内部字段。支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动;`pointermove` 更新必须通过 `requestAnimationFrame` 合帧,结束拖拽时 flush 最后一帧,避免高频 pointermove 直接压垮 React 渲染和项目持久化链路。
- 鼠标滚轮默认按设备原始 `deltaX / deltaY` 二维平移画布视口;当按住 Shift 且设备上报 `deltaX = 0` 时,视口输入适配层把 `deltaY` 映射为横向位移并将纵向位移置零,核心平移模型仍只消费归一化后的 X / Y。按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。缩放比例显示保持现有换算口径,最低可缩小到 `5%`
@@ -97,9 +97,12 @@
## 第十阶段模块
- `useImageCanvasAssetLibrary.ts`
- 承载账号级素材库状态模型:素材文件夹、素材列表、文件夹折叠 / 新建 / 重命名 / 删除、素材重命名 / 删除、素材选择模式、框选、多选删除、素材拖到文件夹和鉴权失败登录弹窗。
- 承载账号级素材库状态模型:素材文件夹、素材列表、文件夹折叠 / 新建 / 重命名 / 删除、素材重命名 / 删除、批量删除资源副作用、素材拖到文件夹和鉴权失败登录弹窗;不再持有选择集合、范围锚点或框选交互状态
- 主视图继续保留上传文件读取、上传占位卡片进度、拖到画布坐标、创建画布图层、工程资源持久化和画布图层清理;素材删除通过 `onDeleteAssets` 回调通知主视图清理关联图层。
- 该 hook 有独立单测覆盖素材库加载归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除,避免后续整理侧栏 JSX 时丢失素材库能力。
- `useImageCanvasAssetSelection.ts`
- 作为素材选择的唯一状态边界,统一持有选择模式、完整选中集合、范围锚点、可选素材有效性 reconcile、单项 / Shift / 当前可见全选增量、框选几何与框选生命周期,并向批量下载 / 删除只暴露已经按素材顺序解析的 `selectedAssets`
- 搜索和文件夹折叠只在侧栏产生 `visibleAssetIds` 并传入增量动作,不得直接修改或 reconcile 选择集合;选择行为测试集中在该 hook,素材库 model 不再维护第二套选择状态机。
## 第十一阶段模块
@@ -11,7 +11,6 @@ import {
import type { Dispatch, SetStateAction } from 'react';
import { PlatformTextField } from '../common/PlatformTextField';
import type { AssetSelectionInteraction } from './ImageCanvasAssetLibraryModel';
import type {
GroupedEditorAssetFolder,
UploadFilesOptions,
@@ -30,6 +29,7 @@ import type {
EditorAssetFolder,
UploadTarget,
} from './ImageCanvasEditorTypes';
import type { AssetSelectionInteraction } from './useImageCanvasAssetSelection';
type ImageCanvasAssetFolderSectionViewProps = {
folder: GroupedEditorAssetFolder;
@@ -1,38 +1,31 @@
import { describe, expect, it } from 'vitest';
import {
areAllSelectableAssetsSelected,
createAssetMarqueeFromPointer,
createAssetMarqueeSelectionRect,
createLocalAssetFolder,
deleteAssetFolderLocally,
filterGroupedAssetsBySearch,
getSelectableAssets,
groupAssetsByFolder,
layerMatchesSearch,
layerStartsWithSearch,
moveAssetMarqueeToPointer,
moveAssetToFolderLocally,
reconcileAssetSelection,
removeAssetById,
removeSelectedAssets,
renameAssetById,
renameAssetFolderById,
replaceLocalAssetFolder,
resolveAllAssetSelection,
resolveAssetFolderIdFromPoint,
resolveAssetSelection,
resolveAssetShowcaseSubmissionState,
resolveDefaultAssetFolder,
resolvePinnedAssetMoveFolderId,
resolvePreferredAssetUploadFolder,
resolveUploadAssetFolder,
resolveVisibleAssetSelectionToggle,
selectUploadedAssetsInRect,
toggleAssetFolderCollapsed,
toggleAssetSelection,
} from './ImageCanvasAssetLibraryModel';
import type {CanvasLayer, EditorAsset, EditorAssetFolder } from './ImageCanvasEditorTypes';
import type {
CanvasLayer,
EditorAsset,
EditorAssetFolder,
} from './ImageCanvasEditorTypes';
function createFolder(
overrides: Partial<EditorAssetFolder> = {},
@@ -104,10 +97,6 @@ describe('ImageCanvasAssetLibraryModel', () => {
{ id: 'project', assets: [{ id: 'asset-a' }, { id: 'built-in' }] },
{ id: 'folder-role', assets: [{ id: 'asset-b' }] },
]);
expect(getSelectableAssets(assets).map((asset) => asset.id)).toEqual([
'asset-a',
'asset-b',
]);
});
it('explains why assets cannot be submitted to showcase review', () => {
@@ -342,139 +331,16 @@ describe('ImageCanvasAssetLibraryModel', () => {
});
});
it('toggles selection, selects all uploaded assets and removes selected assets', () => {
it('removes selected assets', () => {
const assets = [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
];
const selected = toggleAssetSelection(new Set<string>(), 'asset-a');
expect([...selected]).toEqual(['asset-a']);
expect([...toggleAssetSelection(selected, 'asset-a')]).toEqual([]);
expect(areAllSelectableAssetsSelected(assets, new Set(['asset-a']))).toBe(
false,
);
const allSelected = resolveAllAssetSelection({
allSelectableAssetsSelected: false,
selectableAssets: assets,
});
expect([...allSelected]).toEqual(['asset-a', 'asset-b']);
const removal = removeSelectedAssets(assets, new Set(['asset-b']));
expect(removal.assets.map((asset) => asset.id)).toEqual(['asset-a']);
expect(removal.deletedAssets.map((asset) => asset.id)).toEqual(['asset-b']);
});
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],
@@ -548,71 +414,4 @@ describe('ImageCanvasAssetLibraryModel', () => {
}),
).toBeNull();
});
it('updates marquee geometry from pointer coordinates and normalizes reverse drags', () => {
const marquee = createAssetMarqueeFromPointer({
pointerId: 5,
point: { clientX: 180, clientY: 220 },
containerRect: { left: 100, top: 200 },
});
expect(marquee).toEqual({
pointerId: 5,
startX: 80,
startY: 20,
currentX: 80,
currentY: 20,
});
expect(
moveAssetMarqueeToPointer({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
}),
).toMatchObject({ currentX: 20, currentY: 5 });
expect(
createAssetMarqueeSelectionRect({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
}),
).toEqual({
left: 120,
right: 180,
top: 205,
bottom: 220,
});
});
it('selects only uploaded assets intersecting the marquee rectangle', () => {
const assets = [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
];
const selectedIds = selectUploadedAssetsInRect({
assets,
assetTargets: [
{
assetId: 'asset-a',
rect: { left: 10, right: 20, top: 10, bottom: 20 },
},
{
assetId: 'asset-b',
rect: { left: 30, right: 40, top: 30, bottom: 40 },
},
{
assetId: 'built-in',
rect: { left: 15, right: 25, top: 15, bottom: 25 },
},
{
assetId: 'missing',
rect: { left: 15, right: 25, top: 15, bottom: 25 },
},
],
selectionRect: { left: 20, right: 35, top: 20, bottom: 35 },
});
expect([...selectedIds]).toEqual(['asset-a', 'asset-b']);
});
});
@@ -1,6 +1,5 @@
import { isEditorInternalProcessingModel } from './ImageCanvasEditorModel';
import type {
AssetMarqueeState,
CanvasLayer,
EditorAsset,
EditorAssetFolder,
@@ -18,11 +17,6 @@ type AssetFolderHitTarget = {
rect: ClientRectLike;
};
type AssetHitTarget = {
assetId: string;
rect: ClientRectLike;
};
export type GroupedAssetFolder = EditorAssetFolder & {
assets: EditorAsset[];
};
@@ -38,15 +32,6 @@ function isPointInRect(point: ClientPoint, rect: ClientRectLike) {
);
}
function doRectsIntersect(rect: ClientRectLike, selectionRect: ClientRectLike) {
return (
rect.left <= selectionRect.right &&
rect.right >= selectionRect.left &&
rect.top <= selectionRect.bottom &&
rect.bottom >= selectionRect.top
);
}
export function resolveAssetFolderIdFromPoint({
point,
listRect,
@@ -85,96 +70,6 @@ export function resolvePinnedAssetMoveFolderId({
return folderId;
}
export function shouldStartAssetMarquee({
isAssetSelectionMode,
button,
isBlockedTarget,
}: {
isAssetSelectionMode: boolean;
button: number;
isBlockedTarget: boolean;
}) {
return isAssetSelectionMode && button === 0 && !isBlockedTarget;
}
export function createAssetMarqueeFromPointer({
pointerId,
point,
containerRect,
}: {
pointerId: number;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
}): AssetMarqueeState {
const startX = point.clientX - (containerRect?.left ?? 0);
const startY = point.clientY - (containerRect?.top ?? 0);
return {
pointerId,
startX,
startY,
currentX: startX,
currentY: startY,
};
}
export function moveAssetMarqueeToPointer({
marquee,
point,
containerRect,
}: {
marquee: AssetMarqueeState;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
}) {
return {
...marquee,
currentX: point.clientX - (containerRect?.left ?? 0),
currentY: point.clientY - (containerRect?.top ?? 0),
};
}
export function createAssetMarqueeSelectionRect({
marquee,
point,
containerRect,
}: {
marquee: AssetMarqueeState;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
}): ClientRectLike {
const startClientX = (containerRect?.left ?? 0) + marquee.startX;
const startClientY = (containerRect?.top ?? 0) + marquee.startY;
return {
left: Math.min(startClientX, point.clientX),
right: Math.max(startClientX, point.clientX),
top: Math.min(startClientY, point.clientY),
bottom: Math.max(startClientY, point.clientY),
};
}
export function selectUploadedAssetsInRect({
assets,
assetTargets,
selectionRect,
}: {
assets: EditorAsset[];
assetTargets: AssetHitTarget[];
selectionRect: ClientRectLike;
}) {
const uploadedAssetIds = new Set(
getSelectableAssets(assets).map((asset) => asset.id),
);
return new Set(
assetTargets
.filter(
(target) =>
uploadedAssetIds.has(target.assetId) &&
doRectsIntersect(target.rect, selectionRect),
)
.map((target) => target.assetId),
);
}
export function groupAssetsByFolder(
assetFolders: EditorAssetFolder[],
assets: EditorAsset[],
@@ -294,121 +189,6 @@ export function filterGroupedAssetsBySearch(
.filter((folder) => folder.assets.length > 0);
}
export function getSelectableAssets(assets: EditorAsset[]) {
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 = {
canSubmit: boolean;
label: string;
@@ -485,16 +265,6 @@ export function resolveAssetShowcaseSubmissionState(
};
}
export function areAllSelectableAssetsSelected(
selectableAssets: EditorAsset[],
selectedAssetIds: Set<string>,
) {
return (
selectableAssets.length > 0 &&
selectableAssets.every((asset) => selectedAssetIds.has(asset.id))
);
}
export function renameAssetById(
assets: EditorAsset[],
assetId: string,
@@ -647,31 +417,6 @@ export function deleteAssetFolderLocally({
};
}
export function toggleAssetSelection(
selectedAssetIds: Set<string>,
assetId: string,
) {
const nextIds = new Set(selectedAssetIds);
if (nextIds.has(assetId)) {
nextIds.delete(assetId);
} else {
nextIds.add(assetId);
}
return nextIds;
}
export function resolveAllAssetSelection({
allSelectableAssetsSelected,
selectableAssets,
}: {
allSelectableAssetsSelected: boolean;
selectableAssets: EditorAsset[];
}) {
return allSelectableAssetsSelected
? new Set<string>()
: new Set(selectableAssets.map((asset) => asset.id));
}
export function removeSelectedAssets(
assets: EditorAsset[],
selectedAssetIds: Set<string>,
@@ -18,7 +18,6 @@ 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,
@@ -27,6 +26,7 @@ import type {
EditorAssetFolder,
UploadTarget,
} from './ImageCanvasEditorTypes';
import type { AssetSelectionInteraction } from './useImageCanvasAssetSelection';
export type GroupedEditorAssetFolder = EditorAssetFolder & {
assets: EditorAsset[];
@@ -98,7 +98,7 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
toggleAllAssetsSelected: (visibleAssetIds: string[]) => void;
downloadSelectedAssets: (visibleAssetIds: string[]) => void | Promise<void>;
downloadSelectedAssets: () => void | Promise<void>;
deleteSelectedAssets: () => void;
closeAssetSelectionMode: () => void;
};
@@ -287,16 +287,14 @@ export function ImageCanvasAssetLibraryPanelView({
) : (
<Square className="h-4 w-4" />
)}
<span className="min-w-0 truncate">
{selectionToggleLabel}
</span>
<span className="min-w-0 truncate">{selectionToggleLabel}</span>
</PlatformActionButton>
<PlatformActionButton
className="image-canvas-editor__asset-batch-action"
tone="secondary"
size="sm"
disabled={selectedAssetIds.size === 0 || isExportingSelectedAssets}
onClick={() => void downloadSelectedAssets(visibleAssetIds)}
onClick={() => void downloadSelectedAssets()}
>
<Download className="h-4 w-4" />
{isExportingSelectedAssets ? '下载中' : '下载'}
@@ -9,10 +9,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { PlatformTextField } from '../common/PlatformTextField';
import {
type AssetSelectionInteraction,
resolveAssetShowcaseSubmissionState,
} from './ImageCanvasAssetLibraryModel';
import { resolveAssetShowcaseSubmissionState } from './ImageCanvasAssetLibraryModel';
import type { UploadFilesOptions } from './ImageCanvasAssetLibraryPanelView';
import {
ASSET_DRAG_MIME_TYPE,
@@ -32,6 +29,7 @@ import {
getCanvasMediaPreviewClassName,
getCanvasMediaPreviewMarker,
} from './ImageCanvasMediaModel';
import type { AssetSelectionInteraction } from './useImageCanvasAssetSelection';
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
@@ -138,6 +138,7 @@ function createTestImageAsset({
imageSrc,
objectKey,
assetObjectId,
folderId = 'project',
sourceType = 'uploaded',
sourceResourceId,
}: {
@@ -146,6 +147,7 @@ function createTestImageAsset({
imageSrc?: string;
objectKey?: string | null;
assetObjectId?: string | null;
folderId?: string;
sourceType?: 'uploaded' | 'generated' | 'mock_generated';
sourceResourceId?: string | null;
}) {
@@ -155,7 +157,7 @@ function createTestImageAsset({
: objectKey;
return {
assetId,
folderId: 'project',
folderId,
label,
imageSrc: imageSrc ?? (resolvedObjectKey ? `/${resolvedObjectKey}` : ''),
objectKey: resolvedObjectKey,
@@ -810,7 +812,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
).toBeNull();
});
it('supports visible range and additive selection and clears state on exit', async () => {
it('adds visible ranges to the stable selection and clears state on exit', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
@@ -847,7 +849,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
});
expect(
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText(
/ 2/u,
/ 3/u,
),
).toBeTruthy();
@@ -874,6 +876,78 @@ describe('ImageCanvasEditorView asset library integration', () => {
).toHaveProperty('disabled', true);
});
it('keeps hidden selections stable and applies visible actions as deltas', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'folder-other',
label: '其它素材',
sortOrder: 1,
collapsed: false,
systemDefault: false,
},
],
assets: [
createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }),
createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }),
createTestImageAsset({
assetId: 'asset-c',
label: '账号素材C',
folderId: 'folder-other',
}),
],
});
render(<ImageCanvasEditorView />);
await screen.findByRole('button', { name: '添加账号素材A' });
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
fireEvent.click(screen.getByText('账号素材C'));
fireEvent.click(screen.getByText('账号素材A'));
fireEvent.click(screen.getByText('账号素材B'), { shiftKey: true });
const toolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
expect(within(toolbar).getByText(/ 3/u)).toBeTruthy();
fireEvent.change(screen.getByLabelText('搜索素材'), {
target: { value: '账号素材A' },
});
expect(screen.queryByText('账号素材B')).toBeNull();
expect(within(toolbar).getByText(/ 3/u)).toBeTruthy();
fireEvent.click(screen.getByText('账号素材A'));
expect(within(toolbar).getByText(/ 2/u)).toBeTruthy();
fireEvent.click(
within(toolbar).getByRole('button', { name: /^/u }),
);
expect(within(toolbar).getByText(/ 3/u)).toBeTruthy();
fireEvent.click(
within(toolbar).getByRole('button', { name: /^/u }),
);
expect(within(toolbar).getByText(/ 2/u)).toBeTruthy();
fireEvent.change(screen.getByLabelText('搜索素材'), {
target: { value: '' },
});
await user.click(screen.getByRole('button', { name: '折叠其它素材' }));
expect(screen.getByRole('button', { name: '展开其它素材' })).toBeTruthy();
const foldedAssetList = screen
.getByRole('button', { name: '选择素材账号素材C', hidden: true })
.closest('.image-canvas-editor__asset-folder-list');
expect(foldedAssetList).toHaveProperty('hidden', true);
expect(within(toolbar).getByText(/ 2/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '展开其它素材' }));
expect(foldedAssetList).toHaveProperty('hidden', false);
expect(within(toolbar).getByText(/ 2/u)).toBeTruthy();
});
it('removes canvas layers linked to deleted assets', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
@@ -80,7 +80,6 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
addAssetLayer: vi.fn(),
onDownloadAsset: vi.fn(),
toggleAllAssetsSelected: vi.fn(),
reconcileSelectedAssets: vi.fn(),
downloadSelectedAssets: vi.fn(),
deleteSelectedAssets: vi.fn(),
openAssetSelectionMode: vi.fn(),
@@ -688,6 +688,7 @@ export function ImageCanvasEditorView({
setActiveUploadFolderId,
isAssetSelectionMode,
selectedAssetIds,
selectedAssets,
assetMarquee,
assetPointerDrag,
setAssetPointerDrag,
@@ -706,7 +707,6 @@ export function ImageCanvasEditorView({
deleteAssetFolder,
toggleAssetSelected,
toggleAllAssetsSelected,
reconcileSelectedAssets,
deleteSelectedAssets,
moveAssetToFolder,
closeAssetSelectionMode,
@@ -1398,27 +1398,13 @@ export function ImageCanvasEditorView({
},
[exportLayerImage],
);
const downloadSelectedAssets = useCallback(
(visibleAssetIds: string[]) => {
const selectedIds = new Set(selectedAssetIds);
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
const selectedLayers = visibleAssetIds
.filter((assetId) => selectedIds.has(assetId))
.map((assetId, index) => {
const asset = assetById.get(assetId);
if (!asset) {
return null;
}
return {
...createAssetActionLayer(asset),
zIndex: index,
};
})
.filter((layer): layer is CanvasLayer => layer !== null);
return exportSelectedAssets(selectedLayers);
},
[assets, exportSelectedAssets, selectedAssetIds],
);
const downloadSelectedAssets = useCallback(() => {
const selectedLayers = selectedAssets.map((asset, index) => ({
...createAssetActionLayer(asset),
zIndex: index,
}));
return exportSelectedAssets(selectedLayers);
}, [exportSelectedAssets, selectedAssets]);
const {
uploadInputRef,
uploadTarget,
@@ -2304,7 +2290,6 @@ export function ImageCanvasEditorView({
addAssetLayer,
onDownloadAsset: downloadAsset,
toggleAllAssetsSelected,
reconcileSelectedAssets,
downloadSelectedAssets,
deleteSelectedAssets,
closeAssetSelectionMode,
@@ -118,7 +118,6 @@ function createSidebarProps(
addAssetLayer: vi.fn(),
onDownloadAsset: vi.fn(),
toggleAllAssetsSelected: vi.fn(),
reconcileSelectedAssets: vi.fn(),
downloadSelectedAssets: vi.fn(),
deleteSelectedAssets: vi.fn(),
openAssetSelectionMode: vi.fn(),
@@ -270,7 +269,7 @@ describe('ImageCanvasSidebarView', () => {
).toBeNull();
});
it('keeps the batch toolbar outside the scrolling list and downloads visible selections', () => {
it('keeps the batch toolbar outside the scrolling list and downloads the stable selection', () => {
const downloadSelectedAssets = vi.fn();
const closeAssetSelectionMode = vi.fn();
const asset = createAsset();
@@ -300,21 +299,19 @@ describe('ImageCanvasSidebarView', () => {
).toBeNull();
fireEvent.click(within(toolbar).getByRole('button', { name: '下载' }));
expect(downloadSelectedAssets).toHaveBeenCalledWith(['asset-1']);
expect(downloadSelectedAssets).toHaveBeenCalledWith();
fireEvent.click(screen.getByRole('button', { name: '素材选择模式' }));
expect(closeAssetSelectionMode).toHaveBeenCalledTimes(1);
});
it('opens selection mode through the shared lifecycle and reconciles search visibility', () => {
it('opens selection mode through the shared lifecycle', () => {
const openAssetSelectionMode = vi.fn();
const reconcileSelectedAssets = vi.fn();
const folder = createFolder();
render(
<ImageCanvasSidebarView
{...createSidebarProps({
isAssetSelectionMode: false,
openAssetSelectionMode,
reconcileSelectedAssets,
groupedAssets: [
{
...folder,
@@ -329,7 +326,6 @@ describe('ImageCanvasSidebarView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '素材选择模式' }));
expect(openAssetSelectionMode).toHaveBeenCalledTimes(1);
expect(reconcileSelectedAssets).not.toHaveBeenCalled();
});
it('does not expose internal models or providers through asset search', () => {
@@ -5,13 +5,11 @@ import type {
RefObject,
SetStateAction,
} from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { PlatformTextField } from '../common/PlatformTextField';
import {
type AssetSelectionInteraction,
filterGroupedAssetsBySearch,
getSelectableAssets,
layerMatchesSearch,
layerStartsWithSearch,
} from './ImageCanvasAssetLibraryModel';
@@ -34,6 +32,10 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasLayerPanelView } from './ImageCanvasLayerPanelView';
import {
type AssetSelectionInteraction,
getSelectableAssets,
} from './useImageCanvasAssetSelection';
export type { GroupedEditorAssetFolder, UploadFilesOptions };
@@ -97,8 +99,7 @@ export type ImageCanvasSidebarViewProps = {
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
toggleAllAssetsSelected: (visibleAssetIds: string[]) => void;
reconcileSelectedAssets: (visibleAssetIds: string[]) => void;
downloadSelectedAssets: (visibleAssetIds: string[]) => void | Promise<void>;
downloadSelectedAssets: () => void | Promise<void>;
deleteSelectedAssets: () => void;
openAssetSelectionMode: () => void;
closeAssetSelectionMode: () => void;
@@ -164,7 +165,6 @@ export function ImageCanvasSidebarView({
addAssetLayer,
onDownloadAsset,
toggleAllAssetsSelected,
reconcileSelectedAssets,
downloadSelectedAssets,
deleteSelectedAssets,
openAssetSelectionMode,
@@ -201,11 +201,6 @@ export function ImageCanvasSidebarView({
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(
() =>
@@ -470,6 +470,7 @@ export function useImageCanvasAssetExportWorkflow({
exportPlans.map(async (plan): Promise<PreparedAssetExport> => {
const { layer, indexedFileName } = plan;
try {
// TODO image sequence (action) logic should be refactored together with its backend data structure impl.
if (layer.mediaType === 'image-sequence') {
const frames = getLayerImageSequenceFrames(layer);
if (!frames.length) {
@@ -215,14 +215,6 @@ function AssetLibraryHarness({
>
toggle all
</button>
<button
type="button"
onClick={() =>
assetLibrary.reconcileSelectedAssets(['asset-b', 'asset-c'])
}
>
reconcile to b c
</button>
<button type="button" onClick={assetLibrary.deleteSelectedAssets}>
delete selected
</button>
@@ -470,18 +462,18 @@ 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-b');
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-a');
});
act(() => screen.getByRole('button', { name: 'delete selected' }).click());
expect(screen.getByTestId('assets').textContent).toBe(
'asset-a:素材A:project:unset',
'asset-b:素材B:project:unset',
);
expect(onDeleteAssets).toHaveBeenCalledWith([
expect.objectContaining({ id: 'asset-b' }),
expect.objectContaining({ id: 'asset-a' }),
]);
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b');
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-a');
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-b');
});
it('clears selected status when selection mode closes', async () => {
@@ -526,7 +518,7 @@ describe('useImageCanvasAssetLibrary', () => {
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-c');
});
it('reconciles selected ids and their range anchor atomically', async () => {
it('reconciles selection only when an asset actually ceases to exist', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
@@ -545,13 +537,16 @@ describe('useImageCanvasAssetLibrary', () => {
});
render(<AssetLibraryHarness />);
fireEvent.click(await screen.findByRole('button', { name: '素材A' }));
fireEvent.click(screen.getByRole('button', { name: 'reconcile to b c' }));
fireEvent.click(screen.getByRole('button', { name: '素材C' }), {
shiftKey: true,
});
await screen.findByRole('button', { name: '素材A' });
fireEvent.click(screen.getByRole('button', { name: 'open selection' }));
fireEvent.click(screen.getByRole('button', { name: 'toggle all' }));
fireEvent.click(screen.getByRole('button', { name: 'delete asset' }));
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-c');
await waitFor(() => {
expect(screen.getByTestId('selected-assets').textContent).toBe(
'asset-b|asset-c',
);
});
});
it('submits a generated asset to showcase review', async () => {
@@ -1,12 +1,10 @@
import {
type Dispatch,
type PointerEvent as ReactPointerEvent,
type RefObject,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -21,30 +19,19 @@ import {
updateEditorAssetFolder,
} from '../../services/image-editor/editorProjectClient';
import {
type AssetSelectionInteraction,
type AssetSelectionState,
createAssetMarqueeFromPointer,
createAssetMarqueeSelectionRect,
createLocalAssetFolder,
deleteAssetFolderLocally,
getSelectableAssets,
groupAssetsByFolder,
moveAssetMarqueeToPointer,
moveAssetToFolderLocally,
reconcileAssetSelection,
removeAssetById,
removeSelectedAssets,
renameAssetById,
renameAssetFolderById,
replaceLocalAssetFolder,
resolveAssetFolderIdFromPoint,
resolveAssetSelection,
resolveAssetShowcaseSubmissionState,
resolveDefaultAssetFolder,
resolvePinnedAssetMoveFolderId,
resolveVisibleAssetSelectionToggle,
selectUploadedAssetsInRect,
shouldStartAssetMarquee,
toggleAssetFolderCollapsed,
} from './ImageCanvasAssetLibraryModel';
import {
@@ -53,11 +40,11 @@ import {
normalizeAssetLibrary,
} from './ImageCanvasEditorModel';
import type {
AssetMarqueeState,
AssetPointerDragState,
EditorAsset,
EditorAssetFolder,
} from './ImageCanvasEditorTypes';
import { useImageCanvasAssetSelection } from './useImageCanvasAssetSelection';
export { readImageFileAsDataUrl } from './ImageCanvasFileModel';
function isEditorAuthError(error: unknown) {
@@ -77,17 +64,7 @@ function formatShowcaseSubmitError(error: unknown) {
return '提交精选审核失败,请稍后重试';
}
function createEmptyAssetSelection(): AssetSelectionState {
return {
selectedAssetIds: new Set(),
anchorAssetId: null,
};
}
const ASSET_FOLDER_SELECTOR = '[data-asset-folder-id]';
const ASSET_ITEM_SELECTOR = '[data-asset-id]';
const ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR =
'button, input, textarea, select, [data-asset-id]';
function readAssetFolderHitTargets(listElement: ParentNode | null) {
return [
@@ -106,22 +83,6 @@ function readAssetFolderHitTargets(listElement: ParentNode | null) {
.filter((target): target is NonNullable<typeof target> => Boolean(target));
}
function readAssetHitTargets(listElement: ParentNode | null) {
return [
...(listElement?.querySelectorAll<HTMLElement>(ASSET_ITEM_SELECTOR) ?? []),
]
.map((element) => {
const assetId = element.dataset.assetId;
return assetId
? {
assetId,
rect: element.getBoundingClientRect(),
}
: null;
})
.filter((target): target is NonNullable<typeof target> => Boolean(target));
}
export function useImageCanvasAssetLibrary({
assetListRef,
canAccessProtectedData,
@@ -147,15 +108,6 @@ export function useImageCanvasAssetLibrary({
const [creatingFolder, setCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [activeUploadFolderId, setActiveUploadFolderId] = useState('project');
const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false);
const [assetSelection, setAssetSelection] = useState<AssetSelectionState>(
createEmptyAssetSelection,
);
const { selectedAssetIds } = assetSelection;
const marqueeBaseSelectionRef = useRef<Set<string>>(new Set());
const [assetMarquee, setAssetMarquee] = useState<AssetMarqueeState | null>(
null,
);
const [assetPointerDrag, setAssetPointerDrag] =
useState<AssetPointerDragState | null>(null);
const [assetMoveDropFolderId, setAssetMoveDropFolderId] = useState<
@@ -169,7 +121,29 @@ export function useImageCanvasAssetLibrary({
() => groupAssetsByFolder(assetFolders, assets),
[assetFolders, assets],
);
const selectableAssets = useMemo(() => getSelectableAssets(assets), [assets]);
const clearAssetRenameState = useCallback(() => {
setRenamingAsset(null);
setRenamingFolder(null);
}, []);
const {
selectableAssets,
selectedAssets,
selectedAssetIds,
isAssetSelectionMode,
assetMarquee,
clearAssetSelection,
openAssetSelectionMode,
closeAssetSelectionMode,
toggleAssetSelected,
toggleAllAssetsSelected,
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
} = useImageCanvasAssetSelection({
assetListRef,
assets,
onOpenSelectionMode: clearAssetRenameState,
});
const applyAssetLibrarySnapshot = useCallback(
(
@@ -491,54 +465,18 @@ export function useImageCanvasAssetLibrary({
[assetFolders],
);
const toggleAssetSelected = useCallback(
(assetId: string, interaction: AssetSelectionInteraction) => {
setAssetSelection((currentSelection) =>
resolveAssetSelection({
...currentSelection,
targetAssetId: assetId,
...interaction,
}),
);
},
[],
);
const toggleAllAssetsSelected = useCallback((visibleAssetIds: string[]) => {
setAssetSelection((currentSelection) => ({
selectedAssetIds: resolveVisibleAssetSelectionToggle({
selectedAssetIds: currentSelection.selectedAssetIds,
visibleAssetIds,
}),
anchorAssetId: null,
}));
}, []);
const reconcileSelectedAssets = useCallback((visibleAssetIds: string[]) => {
setAssetSelection((currentSelection) =>
reconcileAssetSelection({
...currentSelection,
visibleAssetIds,
}),
);
}, []);
const deleteSelectedAssets = useCallback(() => {
const ids = [...selectedAssetIds];
const deletedAssets = removeSelectedAssets(
assets,
selectedAssetIds,
).deletedAssets;
const ids = selectedAssets.map((asset) => asset.id);
setAssets(
(currentAssets) =>
removeSelectedAssets(currentAssets, selectedAssetIds).assets,
);
onDeleteAssets?.(deletedAssets);
setAssetSelection(createEmptyAssetSelection());
onDeleteAssets?.(selectedAssets);
clearAssetSelection();
ids.forEach((assetId) => {
void deleteEditorAsset(assetId);
});
}, [assets, onDeleteAssets, selectedAssetIds]);
}, [clearAssetSelection, onDeleteAssets, selectedAssetIds, selectedAssets]);
const moveAssetToFolder = useCallback(
(assetId: string, folderId: string) => {
@@ -556,119 +494,6 @@ export function useImageCanvasAssetLibrary({
[assets],
);
const closeAssetSelectionMode = useCallback(() => {
setIsAssetSelectionMode(false);
setAssetSelection(createEmptyAssetSelection());
marqueeBaseSelectionRef.current = new Set();
setAssetMarquee(null);
}, []);
const openAssetSelectionMode = useCallback(() => {
marqueeBaseSelectionRef.current = new Set();
setRenamingAsset(null);
setRenamingFolder(null);
setAssetSelection(createEmptyAssetSelection());
setAssetMarquee(null);
setIsAssetSelectionMode(true);
}, []);
const updateAssetSelectionFromMarquee = useCallback(
(selectionRect: {
left: number;
right: number;
top: number;
bottom: number;
}) => {
const marqueeSelection = selectUploadedAssetsInRect({
assets,
assetTargets: readAssetHitTargets(assetListRef.current),
selectionRect,
});
setAssetSelection({
selectedAssetIds: new Set([
...marqueeBaseSelectionRef.current,
...marqueeSelection,
]),
anchorAssetId: null,
});
},
[assetListRef, assets],
);
const handleAssetMarqueePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
if (
!shouldStartAssetMarquee({
isAssetSelectionMode,
button: event.button,
isBlockedTarget: Boolean(
target.closest(ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR),
),
})
) {
return;
}
event.preventDefault();
assetListRef.current?.setPointerCapture?.(event.pointerId);
setAssetMarquee(
createAssetMarqueeFromPointer({
pointerId: event.pointerId,
point: event,
containerRect: assetListRef.current?.getBoundingClientRect(),
}),
);
marqueeBaseSelectionRef.current =
event.ctrlKey || event.metaKey ? new Set(selectedAssetIds) : new Set();
setAssetSelection({
selectedAssetIds: new Set(marqueeBaseSelectionRef.current),
anchorAssetId: null,
});
},
[assetListRef, isAssetSelectionMode, selectedAssetIds],
);
const handleAssetMarqueePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
const containerRect = assetListRef.current?.getBoundingClientRect();
setAssetMarquee((currentMarquee) =>
currentMarquee
? moveAssetMarqueeToPointer({
marquee: currentMarquee,
point: event,
containerRect,
})
: null,
);
updateAssetSelectionFromMarquee(
createAssetMarqueeSelectionRect({
marquee: assetMarquee,
point: event,
containerRect,
}),
);
},
[assetListRef, assetMarquee, updateAssetSelectionFromMarquee],
);
const handleAssetMarqueePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (assetListRef.current?.hasPointerCapture?.(event.pointerId)) {
assetListRef.current.releasePointerCapture?.(event.pointerId);
}
setAssetMarquee(null);
},
[assetListRef, assetMarquee],
);
return {
assetFolders,
setAssetFolders,
@@ -676,6 +501,7 @@ export function useImageCanvasAssetLibrary({
setAssets,
groupedAssets,
selectableAssets,
selectedAssets,
renamingAsset,
setRenamingAsset,
renamingFolder,
@@ -689,7 +515,6 @@ export function useImageCanvasAssetLibrary({
isAssetSelectionMode,
selectedAssetIds,
assetMarquee,
setAssetMarquee,
assetPointerDrag,
setAssetPointerDrag,
assetMoveDropFolderId,
@@ -707,7 +532,6 @@ export function useImageCanvasAssetLibrary({
deleteAssetFolder,
toggleAssetSelected,
toggleAllAssetsSelected,
reconcileSelectedAssets,
deleteSelectedAssets,
moveAssetToFolder,
closeAssetSelectionMode,
@@ -0,0 +1,223 @@
/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { EditorAsset } from './ImageCanvasEditorTypes';
import {
createAssetMarqueeFromPointer,
createAssetMarqueeSelectionRect,
getSelectableAssets,
moveAssetMarqueeToPointer,
selectUploadedAssetsInRect,
useImageCanvasAssetSelection,
} from './useImageCanvasAssetSelection';
function createAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
return {
id: 'asset-a',
label: '素材A',
src: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
folderId: 'project',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
...overrides,
};
}
describe('useImageCanvasAssetSelection', () => {
it('owns selectable-asset validity', () => {
expect(
getSelectableAssets([
createAsset({ id: 'ready' }),
createAsset({ id: 'uploading', uploadStatus: 'uploading' }),
createAsset({ id: 'failed', uploadStatus: 'failed' }),
createAsset({ id: 'empty', src: ' ' }),
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
]).map((asset) => asset.id),
).toEqual(['ready']);
});
it('owns marquee geometry and selectable hit testing', () => {
const marquee = createAssetMarqueeFromPointer({
pointerId: 5,
point: { clientX: 180, clientY: 220 },
containerRect: { left: 100, top: 200 },
});
expect(
moveAssetMarqueeToPointer({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
}),
).toMatchObject({ currentX: 20, currentY: 5 });
const selectionRect = createAssetMarqueeSelectionRect({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
});
expect(selectionRect).toEqual({
left: 120,
right: 180,
top: 205,
bottom: 220,
});
expect([
...selectUploadedAssetsInRect({
assets: [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
],
assetTargets: [
{
assetId: 'asset-a',
rect: { left: 10, right: 20, top: 10, bottom: 20 },
},
{
assetId: 'asset-b',
rect: { left: 30, right: 40, top: 30, bottom: 40 },
},
{
assetId: 'built-in',
rect: { left: 15, right: 25, top: 15, bottom: 25 },
},
],
selectionRect: { left: 20, right: 35, top: 20, bottom: 35 },
}),
]).toEqual(['asset-a', 'asset-b']);
});
it('applies tick, shift and visible select-all as deltas', () => {
const assets = [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
createAsset({ id: 'asset-c' }),
];
const { result } = renderHook(() =>
useImageCanvasAssetSelection({
assetListRef: createRef<HTMLDivElement>(),
assets,
}),
);
act(() => result.current.openAssetSelectionMode());
act(() =>
result.current.toggleAssetSelected('asset-c', {
visibleAssetIds: ['asset-a', 'asset-b', 'asset-c'],
}),
);
act(() =>
result.current.toggleAssetSelected('asset-b', {
visibleAssetIds: ['asset-a', 'asset-b'],
shiftKey: true,
}),
);
expect([...result.current.selectedAssetIds]).toEqual([
'asset-c',
'asset-b',
]);
act(() =>
result.current.toggleAssetSelected('asset-a', {
visibleAssetIds: ['asset-a', 'asset-b'],
shiftKey: true,
}),
);
expect([...result.current.selectedAssetIds]).toEqual([
'asset-c',
'asset-b',
'asset-a',
]);
act(() => result.current.toggleAllAssetsSelected(['asset-a']));
expect([...result.current.selectedAssetIds]).toEqual([
'asset-c',
'asset-b',
]);
act(() => result.current.toggleAllAssetsSelected(['asset-a']));
expect([...result.current.selectedAssetIds]).toEqual([
'asset-c',
'asset-b',
'asset-a',
]);
act(() =>
result.current.toggleAssetSelected('asset-stale', {
visibleAssetIds: ['asset-a'],
}),
);
expect([...result.current.selectedAssetIds]).toEqual([
'asset-c',
'asset-b',
'asset-a',
]);
});
it('reconciles against selectable assets instead of UI visibility', async () => {
const initialAssets = [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
createAsset({ id: 'asset-c' }),
];
const assetListRef = createRef<HTMLDivElement>();
const { result, rerender } = renderHook(
({ assets }: { assets: EditorAsset[] }) =>
useImageCanvasAssetSelection({ assetListRef, assets }),
{ initialProps: { assets: initialAssets } },
);
act(() => result.current.openAssetSelectionMode());
act(() =>
result.current.toggleAllAssetsSelected(['asset-a', 'asset-b', 'asset-c']),
);
act(() =>
result.current.toggleAssetSelected('asset-b', {
visibleAssetIds: ['asset-b'],
}),
);
expect([...result.current.selectedAssetIds]).toEqual([
'asset-a',
'asset-c',
]);
rerender({
assets: [createAsset({ id: 'asset-b' }), createAsset({ id: 'asset-c' })],
});
await waitFor(() => {
expect([...result.current.selectedAssetIds]).toEqual(['asset-c']);
expect(result.current.selectedAssets.map((asset) => asset.id)).toEqual([
'asset-c',
]);
});
});
it('owns selection-mode open and close lifecycle', () => {
const onOpenSelectionMode = vi.fn();
const { result } = renderHook(() =>
useImageCanvasAssetSelection({
assetListRef: createRef<HTMLDivElement>(),
assets: [createAsset()],
onOpenSelectionMode,
}),
);
act(() => result.current.openAssetSelectionMode());
expect(result.current.isAssetSelectionMode).toBe(true);
expect(onOpenSelectionMode).toHaveBeenCalledTimes(1);
act(() =>
result.current.toggleAssetSelected('asset-a', {
visibleAssetIds: ['asset-a'],
}),
);
expect([...result.current.selectedAssetIds]).toEqual(['asset-a']);
act(() => result.current.closeAssetSelectionMode());
expect(result.current.isAssetSelectionMode).toBe(false);
expect(result.current.selectedAssetIds.size).toBe(0);
});
});
@@ -0,0 +1,442 @@
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { AssetMarqueeState, EditorAsset } from './ImageCanvasEditorTypes';
const ASSET_ITEM_SELECTOR = '[data-asset-id]';
const ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR =
'button, input, textarea, select, [data-asset-id]';
type ClientRectLike = Pick<DOMRect, 'bottom' | 'left' | 'right' | 'top'>;
type ClientPoint = {
clientX: number;
clientY: number;
};
type AssetHitTarget = {
assetId: string;
rect: ClientRectLike;
};
export type AssetSelectionPointerKind = 'keyboard' | 'mouse' | 'pen' | 'touch';
export type AssetSelectionInteraction = {
visibleAssetIds: string[];
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
pointerKind?: AssetSelectionPointerKind;
};
type AssetSelectionState = {
selectedAssetIds: Set<string>;
anchorAssetId: string | null;
};
export function getSelectableAssets(assets: EditorAsset[]) {
return assets.filter(
(asset) =>
asset.sourceKind === 'uploaded' &&
asset.uploadStatus !== 'uploading' &&
asset.uploadStatus !== 'failed' &&
asset.src.trim().length > 0,
);
}
function doRectsIntersect(rect: ClientRectLike, selectionRect: ClientRectLike) {
return (
rect.left <= selectionRect.right &&
rect.right >= selectionRect.left &&
rect.top <= selectionRect.bottom &&
rect.bottom >= selectionRect.top
);
}
function shouldStartAssetMarquee({
isAssetSelectionMode,
button,
isBlockedTarget,
}: {
isAssetSelectionMode: boolean;
button: number;
isBlockedTarget: boolean;
}) {
return isAssetSelectionMode && button === 0 && !isBlockedTarget;
}
export function createAssetMarqueeFromPointer({
pointerId,
point,
containerRect,
}: {
pointerId: number;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
}): AssetMarqueeState {
const startX = point.clientX - (containerRect?.left ?? 0);
const startY = point.clientY - (containerRect?.top ?? 0);
return {
pointerId,
startX,
startY,
currentX: startX,
currentY: startY,
};
}
export function moveAssetMarqueeToPointer({
marquee,
point,
containerRect,
}: {
marquee: AssetMarqueeState;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
}) {
return {
...marquee,
currentX: point.clientX - (containerRect?.left ?? 0),
currentY: point.clientY - (containerRect?.top ?? 0),
};
}
export function createAssetMarqueeSelectionRect({
marquee,
point,
containerRect,
}: {
marquee: AssetMarqueeState;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
}): ClientRectLike {
const startClientX = (containerRect?.left ?? 0) + marquee.startX;
const startClientY = (containerRect?.top ?? 0) + marquee.startY;
return {
left: Math.min(startClientX, point.clientX),
right: Math.max(startClientX, point.clientX),
top: Math.min(startClientY, point.clientY),
bottom: Math.max(startClientY, point.clientY),
};
}
export function selectUploadedAssetsInRect({
assets,
assetTargets,
selectionRect,
}: {
assets: EditorAsset[];
assetTargets: AssetHitTarget[];
selectionRect: ClientRectLike;
}) {
const uploadedAssetIds = new Set(
getSelectableAssets(assets).map((asset) => asset.id),
);
return new Set(
assetTargets
.filter(
(target) =>
uploadedAssetIds.has(target.assetId) &&
doRectsIntersect(target.rect, selectionRect),
)
.map((target) => target.assetId),
);
}
function createEmptyAssetSelection(): AssetSelectionState {
return {
selectedAssetIds: new Set(),
anchorAssetId: null,
};
}
function readAssetHitTargets(listElement: ParentNode | null) {
return [
...(listElement?.querySelectorAll<HTMLElement>(ASSET_ITEM_SELECTOR) ?? []),
].flatMap((element) => {
const assetId = element.dataset.assetId;
return assetId
? [
{
assetId,
rect: element.getBoundingClientRect(),
},
]
: [];
});
}
function dedupeAssetIds(assetIds: string[]) {
return Array.from(new Set(assetIds));
}
function toggleAssetId(selectedAssetIds: Set<string>, assetId: string) {
const nextIds = new Set(selectedAssetIds);
if (nextIds.has(assetId)) {
nextIds.delete(assetId);
} else {
nextIds.add(assetId);
}
return nextIds;
}
function reconcileAssetSelection(
selection: AssetSelectionState,
selectableAssetIds: string[],
) {
const selectableIds = new Set(selectableAssetIds);
const selectedAssetIds = new Set(
[...selection.selectedAssetIds].filter((assetId) =>
selectableIds.has(assetId),
),
);
const anchorAssetId =
selection.anchorAssetId && selectableIds.has(selection.anchorAssetId)
? selection.anchorAssetId
: null;
if (
anchorAssetId === selection.anchorAssetId &&
selectedAssetIds.size === selection.selectedAssetIds.size
) {
return selection;
}
return { selectedAssetIds, anchorAssetId };
}
function resolveAssetSelection(
selection: AssetSelectionState,
targetAssetId: string,
interaction: AssetSelectionInteraction,
) {
const orderedAssetIds = dedupeAssetIds(interaction.visibleAssetIds);
const targetIndex = orderedAssetIds.indexOf(targetAssetId);
if (targetIndex < 0) {
return selection;
}
const anchorIndex = selection.anchorAssetId
? orderedAssetIds.indexOf(selection.anchorAssetId)
: -1;
if (interaction.shiftKey && anchorIndex >= 0) {
const rangeStart = Math.min(anchorIndex, targetIndex);
const rangeEnd = Math.max(anchorIndex, targetIndex);
return {
selectedAssetIds: new Set([
...selection.selectedAssetIds,
...orderedAssetIds.slice(rangeStart, rangeEnd + 1),
]),
anchorAssetId: selection.anchorAssetId,
};
}
return {
selectedAssetIds: toggleAssetId(selection.selectedAssetIds, targetAssetId),
anchorAssetId: targetAssetId,
};
}
function toggleVisibleAssetIds(
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 function useImageCanvasAssetSelection({
assetListRef,
assets,
onOpenSelectionMode,
}: {
assetListRef: RefObject<HTMLDivElement | null>;
assets: EditorAsset[];
onOpenSelectionMode?: () => void;
}) {
const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false);
const [assetSelection, setAssetSelection] = useState<AssetSelectionState>(
createEmptyAssetSelection,
);
const [assetMarquee, setAssetMarquee] = useState<AssetMarqueeState | null>(
null,
);
const marqueeBaseSelectionRef = useRef<Set<string>>(new Set());
const selectableAssets = useMemo(() => getSelectableAssets(assets), [assets]);
const selectableAssetIds = useMemo(
() => selectableAssets.map((asset) => asset.id),
[selectableAssets],
);
const selectedAssets = useMemo(
() =>
assets.filter((asset) => assetSelection.selectedAssetIds.has(asset.id)),
[assetSelection.selectedAssetIds, assets],
);
useEffect(() => {
if (!isAssetSelectionMode) {
return;
}
setAssetSelection((currentSelection) =>
reconcileAssetSelection(currentSelection, selectableAssetIds),
);
}, [isAssetSelectionMode, selectableAssetIds]);
const clearAssetSelection = useCallback(() => {
setAssetSelection(createEmptyAssetSelection());
marqueeBaseSelectionRef.current = new Set();
setAssetMarquee(null);
}, []);
const openAssetSelectionMode = useCallback(() => {
clearAssetSelection();
onOpenSelectionMode?.();
setIsAssetSelectionMode(true);
}, [clearAssetSelection, onOpenSelectionMode]);
const closeAssetSelectionMode = useCallback(() => {
setIsAssetSelectionMode(false);
clearAssetSelection();
}, [clearAssetSelection]);
const toggleAssetSelected = useCallback(
(assetId: string, interaction: AssetSelectionInteraction) => {
setAssetSelection((currentSelection) =>
resolveAssetSelection(currentSelection, assetId, interaction),
);
},
[],
);
const toggleAllAssetsSelected = useCallback((visibleAssetIds: string[]) => {
setAssetSelection((currentSelection) => ({
selectedAssetIds: toggleVisibleAssetIds(
currentSelection.selectedAssetIds,
visibleAssetIds,
),
anchorAssetId: null,
}));
}, []);
const updateAssetSelectionFromMarquee = useCallback(
(selectionRect: {
left: number;
right: number;
top: number;
bottom: number;
}) => {
const marqueeSelection = selectUploadedAssetsInRect({
assets,
assetTargets: readAssetHitTargets(assetListRef.current),
selectionRect,
});
setAssetSelection({
selectedAssetIds: new Set([
...marqueeBaseSelectionRef.current,
...marqueeSelection,
]),
anchorAssetId: null,
});
},
[assetListRef, assets],
);
const handleAssetMarqueePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
if (
!shouldStartAssetMarquee({
isAssetSelectionMode,
button: event.button,
isBlockedTarget: Boolean(
target.closest(ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR),
),
})
) {
return;
}
event.preventDefault();
assetListRef.current?.setPointerCapture?.(event.pointerId);
setAssetMarquee(
createAssetMarqueeFromPointer({
pointerId: event.pointerId,
point: event,
containerRect: assetListRef.current?.getBoundingClientRect(),
}),
);
marqueeBaseSelectionRef.current =
event.ctrlKey || event.metaKey
? new Set(assetSelection.selectedAssetIds)
: new Set();
setAssetSelection({
selectedAssetIds: new Set(marqueeBaseSelectionRef.current),
anchorAssetId: null,
});
},
[assetListRef, assetSelection.selectedAssetIds, isAssetSelectionMode],
);
const handleAssetMarqueePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
const containerRect = assetListRef.current?.getBoundingClientRect();
setAssetMarquee((currentMarquee) =>
currentMarquee
? moveAssetMarqueeToPointer({
marquee: currentMarquee,
point: event,
containerRect,
})
: null,
);
updateAssetSelectionFromMarquee(
createAssetMarqueeSelectionRect({
marquee: assetMarquee,
point: event,
containerRect,
}),
);
},
[assetListRef, assetMarquee, updateAssetSelectionFromMarquee],
);
const handleAssetMarqueePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (assetListRef.current?.hasPointerCapture?.(event.pointerId)) {
assetListRef.current.releasePointerCapture?.(event.pointerId);
}
setAssetMarquee(null);
},
[assetListRef, assetMarquee],
);
return {
selectableAssets,
selectedAssets,
selectedAssetIds: assetSelection.selectedAssetIds,
isAssetSelectionMode,
assetMarquee,
clearAssetSelection,
openAssetSelectionMode,
closeAssetSelectionMode,
toggleAssetSelected,
toggleAllAssetsSelected,
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
};
}