合并主分支素材库多选更新

合入素材库多选操作与批量导出更新
保留画布生成配方分支改动并解决冲突
This commit is contained in:
2026-08-08 11:11:58 +08:00
32 changed files with 3496 additions and 1127 deletions
@@ -29,6 +29,7 @@ import type {
EditorAssetFolder,
UploadTarget,
} from './ImageCanvasEditorTypes';
import type { AssetSelectionInteraction } from './useImageCanvasAssetSelection';
type ImageCanvasAssetFolderSectionViewProps = {
folder: GroupedEditorAssetFolder;
@@ -36,6 +37,7 @@ type ImageCanvasAssetFolderSectionViewProps = {
suppressAssetClickRef: { current: boolean };
isAssetSelectionMode: boolean;
selectedAssetIds: Set<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,
@@ -110,6 +117,17 @@ export function ImageCanvasAssetFolderSectionView({
aria-label={folder.label}
data-asset-folder-id={folder.id}
onDragOver={(event) => {
if (isAssetSelectionMode) {
if (
hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE) ||
hasDataTransferType(event.dataTransfer, 'Files')
) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'none';
}
return;
}
if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) {
event.preventDefault();
event.stopPropagation();
@@ -126,6 +144,18 @@ export function ImageCanvasAssetFolderSectionView({
}
}}
onDrop={(event) => {
if (isAssetSelectionMode) {
if (
getDraggedAssetId(event.dataTransfer) ||
event.dataTransfer.files.length > 0
) {
event.preventDefault();
event.stopPropagation();
setUploadDropTarget(null);
updateAssetMoveDropFolder(null);
}
return;
}
const movingAssetId = getDraggedAssetId(event.dataTransfer);
if (movingAssetId) {
event.preventDefault();
@@ -241,11 +271,11 @@ export function ImageCanvasAssetFolderSectionView({
suppressAssetClickRef={suppressAssetClickRef}
isAssetSelectionMode={isAssetSelectionMode}
selectedAssetIds={selectedAssetIds}
visibleAssetIds={visibleAssetIds}
renamingAsset={renamingAsset}
setRenamingAsset={setRenamingAsset}
setUploadDropTarget={setUploadDropTarget}
setAssetPointerDrag={setAssetPointerDrag}
setSelectedAssetIds={setSelectedAssetIds}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
moveAssetToFolder={moveAssetToFolder}
@@ -1,33 +1,25 @@
import { describe, expect, it } from 'vitest';
import {
areAllSelectableAssetsSelected,
createAssetMarqueeFromPointer,
createAssetMarqueeSelectionRect,
createLocalAssetFolder,
deleteAssetFolderLocally,
filterGroupedAssetsBySearch,
getSelectableAssets,
groupAssetsByFolder,
layerMatchesSearch,
layerStartsWithSearch,
moveAssetMarqueeToPointer,
moveAssetToFolderLocally,
removeAssetById,
removeSelectedAssets,
renameAssetById,
renameAssetFolderById,
replaceLocalAssetFolder,
resolveAllAssetSelection,
resolveAssetFolderIdFromPoint,
resolveAssetShowcaseSubmissionState,
resolveDefaultAssetFolder,
resolvePinnedAssetMoveFolderId,
resolvePreferredAssetUploadFolder,
resolveUploadAssetFolder,
selectUploadedAssetsInRect,
toggleAssetFolderCollapsed,
toggleAssetSelection,
} from './ImageCanvasAssetLibraryModel';
import type {
CanvasLayer,
@@ -105,16 +97,12 @@ describe('ImageCanvasAssetLibraryModel', () => {
{ id: 'project', assets: [{ id: 'asset-a' }, { id: 'built-in' }] },
{ id: 'folder-role', assets: [{ id: 'asset-b' }] },
]);
expect(getSelectableAssets(assets).map((asset) => asset.id)).toEqual([
'asset-a',
'asset-b',
]);
});
it('explains why assets cannot be submitted to showcase review', () => {
expect(
resolveAssetShowcaseSubmissionState(createAsset()).reason,
).toBe('只有生成素材可以提交精选审核');
expect(resolveAssetShowcaseSubmissionState(createAsset()).reason).toBe(
'只有生成素材可以提交精选审核',
);
expect(
resolveAssetShowcaseSubmissionState(
createAsset({ sourceType: 'generated', persisted: false }),
@@ -260,15 +248,15 @@ describe('ImageCanvasAssetLibraryModel', () => {
});
it('renames assets, toggles folders and creates local folders', () => {
expect(renameAssetById([createAsset()], 'asset-a', '新名字')[0]).toMatchObject(
{ label: '新名字' },
);
expect(
renameAssetById([createAsset()], 'asset-a', '新名字')[0],
).toMatchObject({ label: '新名字' });
expect(
renameAssetFolderById([createFolder()], 'project', '默认素材')[0],
).toMatchObject({ label: '默认素材' });
expect(toggleAssetFolderCollapsed([createFolder()], 'project')[0]).toMatchObject(
{ collapsed: true },
);
expect(
toggleAssetFolderCollapsed([createFolder()], 'project')[0],
).toMatchObject({ collapsed: true });
expect(
createLocalAssetFolder({
folderId: 'folder-local',
@@ -316,9 +304,9 @@ describe('ImageCanvasAssetLibraryModel', () => {
createAsset({ id: 'asset-b', folderId: 'project' }),
];
expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual([
'asset-b',
]);
expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual(
['asset-b'],
);
expect(resolveDefaultAssetFolder(folders)?.id).toBe('project');
expect(
resolveUploadAssetFolder([
@@ -343,23 +331,11 @@ describe('ImageCanvasAssetLibraryModel', () => {
});
});
it('toggles selection, selects all uploaded assets and removes selected assets', () => {
it('removes selected assets', () => {
const assets = [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
];
const selected = toggleAssetSelection(new Set<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']);
@@ -438,71 +414,4 @@ describe('ImageCanvasAssetLibraryModel', () => {
}),
).toBeNull();
});
it('updates marquee geometry from pointer coordinates and normalizes reverse drags', () => {
const marquee = createAssetMarqueeFromPointer({
pointerId: 5,
point: { clientX: 180, clientY: 220 },
containerRect: { left: 100, top: 200 },
});
expect(marquee).toEqual({
pointerId: 5,
startX: 80,
startY: 20,
currentX: 80,
currentY: 20,
});
expect(
moveAssetMarqueeToPointer({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
}),
).toMatchObject({ currentX: 20, currentY: 5 });
expect(
createAssetMarqueeSelectionRect({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
}),
).toEqual({
left: 120,
right: 180,
top: 205,
bottom: 220,
});
});
it('selects only uploaded assets intersecting the marquee rectangle', () => {
const assets = [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
];
const selectedIds = selectUploadedAssetsInRect({
assets,
assetTargets: [
{
assetId: 'asset-a',
rect: { left: 10, right: 20, top: 10, bottom: 20 },
},
{
assetId: 'asset-b',
rect: { left: 30, right: 40, top: 30, bottom: 40 },
},
{
assetId: 'built-in',
rect: { left: 15, right: 25, top: 15, bottom: 25 },
},
{
assetId: 'missing',
rect: { left: 15, right: 25, top: 15, bottom: 25 },
},
],
selectionRect: { left: 20, right: 35, top: 20, bottom: 35 },
});
expect([...selectedIds]).toEqual(['asset-a', 'asset-b']);
});
});
@@ -1,6 +1,5 @@
import { isEditorInternalProcessingModel } from './ImageCanvasEditorModel';
import type {
AssetMarqueeState,
CanvasLayer,
EditorAsset,
EditorAssetFolder,
@@ -18,11 +17,6 @@ type AssetFolderHitTarget = {
rect: ClientRectLike;
};
type AssetHitTarget = {
assetId: string;
rect: ClientRectLike;
};
export type GroupedAssetFolder = EditorAssetFolder & {
assets: EditorAsset[];
};
@@ -38,15 +32,6 @@ function isPointInRect(point: ClientPoint, rect: ClientRectLike) {
);
}
function doRectsIntersect(rect: ClientRectLike, selectionRect: ClientRectLike) {
return (
rect.left <= selectionRect.right &&
rect.right >= selectionRect.left &&
rect.top <= selectionRect.bottom &&
rect.bottom >= selectionRect.top
);
}
export function resolveAssetFolderIdFromPoint({
point,
listRect,
@@ -85,98 +70,6 @@ export function resolvePinnedAssetMoveFolderId({
return folderId;
}
export function shouldStartAssetMarquee({
isAssetSelectionMode,
button,
isBlockedTarget,
}: {
isAssetSelectionMode: boolean;
button: number;
isBlockedTarget: boolean;
}) {
return isAssetSelectionMode && button === 0 && !isBlockedTarget;
}
export function createAssetMarqueeFromPointer({
pointerId,
point,
containerRect,
}: {
pointerId: number;
point: ClientPoint;
containerRect: Pick<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(
assets
.filter((asset) => asset.sourceKind === 'uploaded')
.map((asset) => asset.id),
);
return new Set(
assetTargets
.filter(
(target) =>
uploadedAssetIds.has(target.assetId) &&
doRectsIntersect(target.rect, selectionRect),
)
.map((target) => target.assetId),
);
}
export function groupAssetsByFolder(
assetFolders: EditorAssetFolder[],
assets: EditorAsset[],
@@ -191,14 +84,20 @@ function normalizeSearchQuery(query: string) {
return query.trim().toLowerCase();
}
function matchesSearchText(query: string, values: Array<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;
}
@@ -290,10 +189,6 @@ export function filterGroupedAssetsBySearch(
.filter((folder) => folder.assets.length > 0);
}
export function getSelectableAssets(assets: EditorAsset[]) {
return assets.filter((asset) => asset.sourceKind === 'uploaded');
}
export type AssetShowcaseSubmissionState = {
canSubmit: boolean;
label: string;
@@ -370,16 +265,6 @@ export function resolveAssetShowcaseSubmissionState(
};
}
export function areAllSelectableAssetsSelected(
selectableAssets: EditorAsset[],
selectedAssetIds: Set<string>,
) {
return (
selectableAssets.length > 0 &&
selectableAssets.every((asset) => selectedAssetIds.has(asset.id))
);
}
export function renameAssetById(
assets: EditorAsset[],
assetId: string,
@@ -485,7 +370,9 @@ export function renameAssetFolderById(
export function resolveDefaultAssetFolder(assetFolders: EditorAssetFolder[]) {
return (
assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0] ?? null
assetFolders.find((folder) => folder.systemDefault) ??
assetFolders[0] ??
null
);
}
@@ -500,7 +387,10 @@ export function resolveUploadAssetFolder(assetFolders: EditorAssetFolder[]) {
export function resolvePreferredAssetUploadFolder(
assetFolders: EditorAssetFolder[],
) {
return resolveUploadAssetFolder(assetFolders) ?? resolveDefaultAssetFolder(assetFolders);
return (
resolveUploadAssetFolder(assetFolders) ??
resolveDefaultAssetFolder(assetFolders)
);
}
export function deleteAssetFolderLocally({
@@ -527,36 +417,13 @@ export function deleteAssetFolderLocally({
};
}
export function toggleAssetSelection(
selectedAssetIds: Set<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>,
) {
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,
@@ -12,9 +13,11 @@ import type {
RefObject,
SetStateAction,
} from 'react';
import { useEffect, useState } from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasAssetFolderSectionView } from './ImageCanvasAssetFolderSectionView';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
@@ -25,6 +28,10 @@ import type {
EditorAssetFolder,
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
type AssetSelectionInteraction,
countHiddenSelectedAssets,
} from './useImageCanvasAssetSelection';
export type GroupedEditorAssetFolder = EditorAssetFolder & {
assets: EditorAsset[];
@@ -49,13 +56,15 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
assetFolders: EditorAssetFolder[];
isAssetSelectionMode: boolean;
selectedAssetIds: Set<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 +77,20 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
setActiveUploadFolderId: Dispatch<SetStateAction<string>>;
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
onAssetMarqueePointerDown: (
onAssetMarqueePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerCancel: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerMove: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerUp: (
onAssetMarqueeLostPointerCapture: (
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,11 +102,15 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
submitAssetToShowcase: (asset: EditorAsset) => void;
toggleAssetSelected: (assetId: string) => void;
toggleAssetSelected: (
assetId: string,
interaction: AssetSelectionInteraction,
) => void;
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
toggleAllAssetsSelected: () => void;
deleteSelectedAssets: () => void;
toggleAllAssetsSelected: (visibleAssetIds: string[]) => void;
downloadSelectedAssets: () => void | Promise<void>;
deleteSelectedAssets: (assetIds?: ReadonlySet<string>) => void;
closeAssetSelectionMode: () => void;
};
@@ -107,13 +122,15 @@ export function ImageCanvasAssetLibraryPanelView({
assetFolders,
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds,
assetMoveDropFolderId,
pinnedAssetMoveFolderId,
creatingFolder,
newFolderName,
renamingFolder,
renamingAsset,
allSelectableAssetsSelected,
allVisibleAssetsSelected,
isExportingSelectedAssets,
assetMarquee,
setCreatingFolder,
setNewFolderName,
@@ -122,10 +139,11 @@ export function ImageCanvasAssetLibraryPanelView({
setActiveUploadFolderId,
setUploadDropTarget,
setAssetPointerDrag,
setSelectedAssetIds,
onAssetMarqueePointerDown,
onAssetMarqueePointerMove,
onAssetMarqueePointerUp,
onAssetMarqueePointerCancel,
onAssetMarqueeLostPointerCapture,
updateAssetMoveDropFolder,
addUploadedFiles,
requestUpload,
@@ -143,148 +161,246 @@ export function ImageCanvasAssetLibraryPanelView({
addAssetLayer,
onDownloadAsset,
toggleAllAssetsSelected,
downloadSelectedAssets,
deleteSelectedAssets,
closeAssetSelectionMode,
}: ImageCanvasAssetLibraryPanelViewProps) {
const [hiddenSelectionDeleteSnapshot, setHiddenSelectionDeleteSnapshot] =
useState<{
assetIds: Set<string>;
visibleAssetIds: string[];
hiddenAssetCount: number;
} | null>(null);
let selectionToggleActionText = '全选';
if (allVisibleAssetsSelected) {
selectionToggleActionText = '取消全选';
}
let selectionToggleLabel = selectionToggleActionText;
if (selectedAssetIds.size > 0) {
selectionToggleLabel = `${selectionToggleActionText} · 已选 ${selectedAssetIds.size}`;
}
const hiddenSelectedAssetCount = countHiddenSelectedAssets(
selectedAssetIds,
visibleAssetIds,
);
useEffect(() => {
if (!hiddenSelectionDeleteSnapshot) {
return;
}
const selectedIdsUnchanged =
hiddenSelectionDeleteSnapshot.assetIds.size === selectedAssetIds.size &&
[...hiddenSelectionDeleteSnapshot.assetIds].every((assetId) =>
selectedAssetIds.has(assetId),
);
const visibleIdsUnchanged =
hiddenSelectionDeleteSnapshot.visibleAssetIds.length ===
visibleAssetIds.length &&
hiddenSelectionDeleteSnapshot.visibleAssetIds.every(
(assetId, index) => assetId === visibleAssetIds[index],
);
if (
!isAssetSelectionMode ||
!selectedIdsUnchanged ||
!visibleIdsUnchanged
) {
setHiddenSelectionDeleteSnapshot(null);
}
}, [
hiddenSelectionDeleteSnapshot,
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds,
]);
const requestDeleteSelectedAssets = () => {
if (hiddenSelectedAssetCount > 0) {
setHiddenSelectionDeleteSnapshot({
assetIds: new Set(selectedAssetIds),
visibleAssetIds: [...visibleAssetIds],
hiddenAssetCount: hiddenSelectedAssetCount,
});
return;
}
deleteSelectedAssets();
};
return (
<div
ref={assetListRef}
className="image-canvas-editor__asset-list"
onPointerDown={onAssetMarqueePointerDown}
onPointerMove={onAssetMarqueePointerMove}
onPointerUp={onAssetMarqueePointerUp}
onPointerCancel={onAssetMarqueePointerUp}
>
{pinnedAssetMoveFolderId ? (
<div
className="image-canvas-editor__asset-folder-sticky-target"
aria-hidden="true"
>
<Folder className="h-4 w-4" />
<span>
{assetFolders.find((folder) => folder.id === pinnedAssetMoveFolderId)
?.label ?? '目标文件夹'}
</span>
</div>
) : null}
{creatingFolder ? (
<form
className="image-canvas-editor__folder-create"
onSubmit={(event) => {
event.preventDefault();
void commitNewAssetFolder();
}}
>
<PlatformTextField
aria-label="素材文件夹名称"
value={newFolderName}
autoFocus
size="xs"
density="compact"
className="image-canvas-editor__folder-create-input"
onChange={(event) => setNewFolderName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
<div className="image-canvas-editor__asset-library">
<div
ref={assetListRef}
className="image-canvas-editor__asset-list"
onPointerDown={onAssetMarqueePointerDown}
onPointerMove={onAssetMarqueePointerMove}
onPointerUp={onAssetMarqueePointerUp}
onPointerCancel={onAssetMarqueePointerCancel}
onLostPointerCapture={onAssetMarqueeLostPointerCapture}
>
{pinnedAssetMoveFolderId ? (
<div
className="image-canvas-editor__asset-folder-sticky-target"
aria-hidden="true"
>
<Folder className="h-4 w-4" />
<span>
{assetFolders.find(
(folder) => folder.id === pinnedAssetMoveFolderId,
)?.label ?? '目标文件夹'}
</span>
</div>
) : null}
{creatingFolder ? (
<form
className="image-canvas-editor__folder-create"
onSubmit={(event) => {
event.preventDefault();
void commitNewAssetFolder();
}}
>
<PlatformTextField
aria-label="素材文件夹名称"
value={newFolderName}
autoFocus
size="xs"
density="compact"
className="image-canvas-editor__folder-create-input"
onChange={(event) => setNewFolderName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
setCreatingFolder(false);
setNewFolderName('');
}
}}
/>
<EditorIconButton
type="submit"
label="保存素材文件夹"
icon={Check}
/>
<EditorIconButton
label="取消新建素材文件夹"
icon={X}
onClick={() => {
setCreatingFolder(false);
setNewFolderName('');
}
}}
/>
</form>
) : null}
{groupedAssets.map((folder) => (
<ImageCanvasAssetFolderSectionView
key={folder.id}
folder={folder}
assetPointerDragRef={assetPointerDragRef}
suppressAssetClickRef={suppressAssetClickRef}
isAssetSelectionMode={isAssetSelectionMode}
selectedAssetIds={selectedAssetIds}
visibleAssetIds={visibleAssetIds}
assetMoveDropFolderId={assetMoveDropFolderId}
renamingFolder={renamingFolder}
renamingAsset={renamingAsset}
setRenamingFolder={setRenamingFolder}
setRenamingAsset={setRenamingAsset}
setActiveUploadFolderId={setActiveUploadFolderId}
setUploadDropTarget={setUploadDropTarget}
setAssetPointerDrag={setAssetPointerDrag}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
requestUpload={requestUpload}
moveAssetToFolder={moveAssetToFolder}
toggleAssetFolder={toggleAssetFolder}
startRenamingFolder={startRenamingFolder}
commitFolderRename={commitFolderRename}
deleteAssetFolder={deleteAssetFolder}
startRenamingAsset={startRenamingAsset}
commitAssetRename={commitAssetRename}
deleteUploadedAsset={deleteUploadedAsset}
submitAssetToShowcase={submitAssetToShowcase}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
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),
}}
/>
<EditorIconButton type="submit" label="保存素材文件夹" icon={Check} />
<EditorIconButton
label="取消新建素材文件夹"
icon={X}
onClick={() => {
setCreatingFolder(false);
setNewFolderName('');
}}
/>
</form>
) : null}
{groupedAssets.map((folder) => (
<ImageCanvasAssetFolderSectionView
key={folder.id}
folder={folder}
assetPointerDragRef={assetPointerDragRef}
suppressAssetClickRef={suppressAssetClickRef}
isAssetSelectionMode={isAssetSelectionMode}
selectedAssetIds={selectedAssetIds}
assetMoveDropFolderId={assetMoveDropFolderId}
renamingFolder={renamingFolder}
renamingAsset={renamingAsset}
setRenamingFolder={setRenamingFolder}
setRenamingAsset={setRenamingAsset}
setActiveUploadFolderId={setActiveUploadFolderId}
setUploadDropTarget={setUploadDropTarget}
setAssetPointerDrag={setAssetPointerDrag}
setSelectedAssetIds={setSelectedAssetIds}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
requestUpload={requestUpload}
moveAssetToFolder={moveAssetToFolder}
toggleAssetFolder={toggleAssetFolder}
startRenamingFolder={startRenamingFolder}
commitFolderRename={commitFolderRename}
deleteAssetFolder={deleteAssetFolder}
startRenamingAsset={startRenamingAsset}
commitAssetRename={commitAssetRename}
deleteUploadedAsset={deleteUploadedAsset}
submitAssetToShowcase={submitAssetToShowcase}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
onDownloadAsset={onDownloadAsset}
/>
))}
) : 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-3! 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="image-canvas-editor__asset-batch-action col-span-3"
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" />
)}
{selectedAssetIds.size > 0
? `${allSelectableAssetsSelected ? '取消全选' : '全选'} · 已选 ${selectedAssetIds.size}`
: '全选'}
<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()}
>
<Download className="h-4 w-4" />
{isExportingSelectedAssets ? '下载中' : '下载'}
</PlatformActionButton>
<PlatformActionButton
className="image-canvas-editor__asset-batch-action"
tone="warning"
size="sm"
disabled={selectedAssetIds.size === 0}
onClick={deleteSelectedAssets}
disabled={selectedAssetIds.size === 0 || isExportingSelectedAssets}
onClick={requestDeleteSelectedAssets}
>
<Trash2 className="h-4 w-4" />
</PlatformActionButton>
<PlatformActionButton
className="image-canvas-editor__asset-batch-action"
tone="secondary"
size="sm"
onClick={closeAssetSelectionMode}
>
<X className="h-4 w-4" />
</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}
<PlatformDangerConfirmDialog
open={isAssetSelectionMode && hiddenSelectionDeleteSnapshot !== null}
title="删除全部已选素材"
description={`将删除 ${hiddenSelectionDeleteSnapshot?.assetIds.size ?? 0} 个已选素材,其中 ${hiddenSelectionDeleteSnapshot?.hiddenAssetCount ?? 0} 个当前未显示。`}
confirmLabel="确认删除全部已选素材"
onClose={() => setHiddenSelectionDeleteSnapshot(null)}
onConfirm={() => {
const targetAssetIds = hiddenSelectionDeleteSnapshot?.assetIds;
setHiddenSelectionDeleteSnapshot(null);
if (targetAssetIds) {
deleteSelectedAssets(targetAssetIds);
}
}}
>
</PlatformDangerConfirmDialog>
</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(),
@@ -123,8 +126,10 @@ function renderAssetRow({
onDownloadAsset,
};
render(<ImageCanvasAssetRowView {...props} />);
return props;
return {
props,
...render(<ImageCanvasAssetRowView {...props} />),
};
}
describe('ImageCanvasAssetRowView', () => {
@@ -182,6 +187,25 @@ describe('ImageCanvasAssetRowView', () => {
expect(screen.queryByRole('menu')).toBeNull();
});
it('closes the right click menu when selection mode becomes active', () => {
const { props, rerender } = renderAssetRow();
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
expect(screen.getByRole('menu')).toBeTruthy();
rerender(
<ImageCanvasAssetRowView {...props} isAssetSelectionMode={true} />,
);
expect(screen.queryByRole('menu')).toBeNull();
});
it('selects the asset instead of adding it in selection mode', () => {
const addAssetLayer = vi.fn();
const toggleAssetSelected = vi.fn();
@@ -193,8 +217,76 @@ describe('ImageCanvasAssetRowView', () => {
fireEvent.click(screen.getByRole('button', { name: '选择素材账号素材A' }));
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1');
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', {
visibleAssetIds: ['asset-1'],
shiftKey: false,
});
expect(addAssetLayer).not.toHaveBeenCalled();
expect(
screen.queryByRole('button', { name: '下载素材账号素材A' }),
).toBeNull();
});
it('disables rows that are outside the shared selectable-asset boundary', () => {
const toggleAssetSelected = vi.fn();
renderAssetRow({
asset: createAsset({ sourceKind: 'built-in' }),
isAssetSelectionMode: true,
toggleAssetSelected,
});
const button = screen.getByRole('button', {
name: '素材账号素材A不可选择',
}) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.click(screen.getByText('账号素材A'));
expect(toggleAssetSelected).not.toHaveBeenCalled();
});
it('forwards only the shift modifier used by selection semantics', () => {
const toggleAssetSelected = vi.fn();
renderAssetRow({ isAssetSelectionMode: true, toggleAssetSelected });
fireEvent.click(screen.getByText('账号素材A'), {
ctrlKey: true,
shiftKey: true,
});
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', {
visibleAssetIds: ['asset-1'],
shiftKey: true,
});
});
it('blocks internal moves and external file drops in selection mode', () => {
const { container, props } = renderAssetRow({
isAssetSelectionMode: true,
});
const row = container.querySelector<HTMLElement>(
'[data-asset-id="asset-1"]',
);
if (!row) {
throw new Error('asset row should exist');
}
const fileTransfer = {
types: ['Files'],
files: [new File(['image'], '新增素材.png', { type: 'image/png' })],
dropEffect: 'copy',
getData: vi.fn(() => ''),
};
fireEvent.dragOver(row, { dataTransfer: fileTransfer });
fireEvent.drop(row, { dataTransfer: fileTransfer });
const assetTransfer = {
types: ['application/x-genarrative-editor-asset'],
files: [],
dropEffect: 'move',
getData: vi.fn(() => 'asset-other'),
};
fireEvent.dragOver(row, { dataTransfer: assetTransfer });
fireEvent.drop(row, { dataTransfer: assetTransfer });
expect(props.addUploadedFiles).not.toHaveBeenCalled();
expect(props.moveAssetToFolder).not.toHaveBeenCalled();
});
it('renders rename input and commits with Enter', () => {
@@ -1,5 +1,10 @@
import { Check, Download, Pencil, Video, X } from 'lucide-react';
import type { Dispatch, SetStateAction } from 'react';
import type {
Dispatch,
MouseEvent as ReactMouseEvent,
ReactNode,
SetStateAction,
} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
@@ -24,6 +29,10 @@ import {
getCanvasMediaPreviewClassName,
getCanvasMediaPreviewMarker,
} from './ImageCanvasMediaModel';
import {
type AssetSelectionInteraction,
isSelectableAsset,
} from './useImageCanvasAssetSelection';
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
@@ -33,13 +42,13 @@ export type ImageCanvasAssetRowViewProps = {
suppressAssetClickRef: { current: boolean };
isAssetSelectionMode: boolean;
selectedAssetIds: Set<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 +59,10 @@ export type ImageCanvasAssetRowViewProps = {
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
submitAssetToShowcase: (asset: EditorAsset) => void;
toggleAssetSelected: (assetId: string) => void;
toggleAssetSelected: (
assetId: string,
interaction: AssetSelectionInteraction,
) => void;
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
};
@@ -61,11 +73,11 @@ export function ImageCanvasAssetRowView({
suppressAssetClickRef,
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds,
renamingAsset,
setRenamingAsset,
setUploadDropTarget,
setAssetPointerDrag,
setSelectedAssetIds,
updateAssetMoveDropFolder,
addUploadedFiles,
moveAssetToFolder,
@@ -85,6 +97,7 @@ export function ImageCanvasAssetRowView({
const isRenaming = renamingAsset?.assetId === asset.id;
const isUploadingAsset = asset.uploadStatus === 'uploading';
const isFailedUpload = asset.uploadStatus === 'failed';
const canSelectAsset = isSelectableAsset(asset);
const showcaseReviewStatus = asset.showcaseReviewStatus ?? null;
const showcaseSubmissionState = resolveAssetShowcaseSubmissionState(asset);
const canSubmitShowcaseAsset = showcaseSubmissionState.canSubmit;
@@ -95,6 +108,38 @@ export function ImageCanvasAssetRowView({
setMenuPosition(null);
}, []);
useEffect(() => {
if (isAssetSelectionMode) {
closeMenu();
}
}, [closeMenu, isAssetSelectionMode]);
const selectAssetFromEvent = useCallback(
(event: ReactMouseEvent<HTMLElement>) => {
if (
!isAssetSelectionMode ||
!canSelectAsset ||
suppressAssetClickRef.current
) {
return;
}
closeMenu();
toggleAssetSelected(asset.id, {
visibleAssetIds,
shiftKey: event.shiftKey,
});
},
[
asset.id,
canSelectAsset,
closeMenu,
isAssetSelectionMode,
suppressAssetClickRef,
toggleAssetSelected,
visibleAssetIds,
],
);
useEffect(() => {
if (!menuPosition) {
return undefined;
@@ -158,6 +203,16 @@ export function ImageCanvasAssetRowView({
<Video className="h-5 w-5" />
</div>
) : undefined;
let primaryLabel = `添加${asset.label}`;
if (isUploadingAsset) {
primaryLabel = `上传中${asset.label}`;
} else if (isFailedUpload) {
primaryLabel = `上传失败${asset.label}`;
} else if (isAssetSelectionMode) {
primaryLabel = canSelectAsset
? `选择素材${asset.label}`
: `素材${asset.label}不可选择`;
}
const titleNode = isRenaming ? (
<PlatformTextField
aria-label={`重命名素材${asset.label}`}
@@ -184,54 +239,69 @@ export function ImageCanvasAssetRowView({
}}
/>
) : undefined;
const actions = isUploadingAsset ? (
<div className="image-canvas-editor__asset-upload-status">
<span>{asset.uploadMessage ?? '上传中'}</span>
<strong>{Math.round(uploadProgress)}%</strong>
</div>
) : isRenaming ? (
<div className="image-canvas-editor__asset-actions">
<EditorIconButton
label={`保存素材${asset.label}名称`}
title="保存"
icon={Check}
onClick={() => commitAssetRename(asset)}
/>
<EditorIconButton
label={`取消重命名素材${asset.label}`}
title="取消"
icon={X}
onClick={() => setRenamingAsset(null)}
/>
</div>
) : (
<div className="image-canvas-editor__asset-actions">
<EditorIconButton
label={`下载素材${asset.label}`}
title="下载"
icon={Download}
onClick={() => {
closeMenu();
onDownloadAsset(asset);
}}
/>
<EditorIconButton
label={`重命名素材${asset.label}`}
title="重命名"
icon={Pencil}
onClick={() => {
closeMenu();
startRenamingAsset(asset);
}}
/>
</div>
);
let actions: ReactNode;
if (isAssetSelectionMode) {
actions = undefined;
} else if (isUploadingAsset) {
actions = (
<div className="image-canvas-editor__asset-upload-status">
<span>{asset.uploadMessage ?? '上传中'}</span>
<strong>{Math.round(uploadProgress)}%</strong>
</div>
);
} else if (isRenaming) {
actions = (
<div className="image-canvas-editor__asset-actions">
<EditorIconButton
label={`保存素材${asset.label}名称`}
title="保存"
icon={Check}
onClick={() => commitAssetRename(asset)}
/>
<EditorIconButton
label={`取消重命名素材${asset.label}`}
title="取消"
icon={X}
onClick={() => setRenamingAsset(null)}
/>
</div>
);
} else {
actions = (
<div className="image-canvas-editor__asset-actions">
<EditorIconButton
label={`下载素材${asset.label}`}
title="下载"
icon={Download}
onClick={() => {
closeMenu();
onDownloadAsset(asset);
}}
/>
<EditorIconButton
label={`重命名素材${asset.label}`}
title="重命名"
icon={Pencil}
onClick={() => {
closeMenu();
startRenamingAsset(asset);
}}
/>
</div>
);
}
return (
<div
ref={rowRef}
data-asset-id={asset.id}
onClick={selectAssetFromEvent}
onContextMenu={(event) => {
if (isAssetSelectionMode) {
event.preventDefault();
event.stopPropagation();
return;
}
if (isUploadingAsset || isFailedUpload || isRenaming) {
return;
}
@@ -244,15 +314,8 @@ export function ImageCanvasAssetRowView({
title={asset.label}
detail={mediaDetail}
imageAlt={`素材:${asset.label}`}
primaryLabel={
isUploadingAsset
? `上传中${asset.label}`
: isFailedUpload
? `上传失败${asset.label}`
: isAssetSelectionMode
? `选择素材${asset.label}`
: `添加${asset.label}`
}
primaryLabel={primaryLabel}
primaryDisabled={isAssetSelectionMode && !canSelectAsset}
onPrimaryClick={() => {
if (isUploadingAsset || isFailedUpload) {
return;
@@ -262,12 +325,11 @@ export function ImageCanvasAssetRowView({
return;
}
if (isAssetSelectionMode) {
toggleAssetSelected(asset.id);
return;
}
addAssetLayer(asset);
}}
selected={selectedAssetIds.has(asset.id)}
selected={isAssetSelectionMode && selectedAssetIds.has(asset.id)}
rowClassName={[
'image-canvas-editor__asset-row',
isUploadingAsset ? 'image-canvas-editor__asset-row--uploading' : '',
@@ -295,7 +357,12 @@ export function ImageCanvasAssetRowView({
)
}
actions={actions}
draggable={!isRenaming && !isUploadingAsset && !isFailedUpload}
draggable={
!isAssetSelectionMode &&
!isRenaming &&
!isUploadingAsset &&
!isFailedUpload
}
previewOverlay={
isUploadingAsset ? (
<div className="image-canvas-editor__asset-upload-overlay">
@@ -325,7 +392,12 @@ export function ImageCanvasAssetRowView({
) : undefined
}
onDragStart={(event) => {
if (isRenaming || isUploadingAsset || isFailedUpload) {
if (
isAssetSelectionMode ||
isRenaming ||
isUploadingAsset ||
isFailedUpload
) {
event.preventDefault();
return;
}
@@ -344,16 +416,13 @@ export function ImageCanvasAssetRowView({
updateAssetMoveDropFolder(null);
}}
onPointerDown={(event) => {
if (isAssetSelectionMode) {
return;
}
if (event.button !== 0) {
return;
}
const target = event.target as HTMLElement;
if (isAssetSelectionMode) {
if (target.closest('button')) {
return;
}
return;
}
if (
isRenaming ||
isUploadingAsset ||
@@ -390,16 +459,18 @@ export function ImageCanvasAssetRowView({
assetPointerDragRef.current = nextDrag;
setAssetPointerDrag(nextDrag);
}}
onPointerEnter={(event) => {
if (isAssetSelectionMode && event.buttons === 1) {
setSelectedAssetIds((currentIds) => {
const nextIds = new Set(currentIds);
nextIds.add(asset.id);
return nextIds;
});
}
}}
onDragOver={(event) => {
if (isAssetSelectionMode) {
if (
hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE) ||
hasDataTransferType(event.dataTransfer, 'Files')
) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'none';
}
return;
}
if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) {
event.preventDefault();
event.stopPropagation();
@@ -416,6 +487,18 @@ export function ImageCanvasAssetRowView({
}
}}
onDrop={(event) => {
if (isAssetSelectionMode) {
if (
getDraggedAssetId(event.dataTransfer) ||
event.dataTransfer.files.length > 0
) {
event.preventDefault();
event.stopPropagation();
setUploadDropTarget(null);
updateAssetMoveDropFolder(null);
}
return;
}
const movingAssetId = getDraggedAssetId(event.dataTransfer);
if (movingAssetId) {
event.preventDefault();
@@ -80,10 +80,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: (
@@ -141,6 +140,7 @@ function createTestImageAsset({
imageSrc,
objectKey,
assetObjectId,
folderId = 'project',
sourceType = 'uploaded',
sourceResourceId,
}: {
@@ -149,6 +149,7 @@ function createTestImageAsset({
imageSrc?: string;
objectKey?: string | null;
assetObjectId?: string | null;
folderId?: string;
sourceType?: 'uploaded' | 'generated' | 'mock_generated';
sourceResourceId?: string | null;
}) {
@@ -158,7 +159,7 @@ function createTestImageAsset({
: objectKey;
return {
assetId,
folderId: 'project',
folderId,
label,
imageSrc: imageSrc ?? (resolvedObjectKey ? `/${resolvedObjectKey}` : ''),
objectKey: resolvedObjectKey,
@@ -339,7 +340,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
);
await waitFor(() => {
expect(downloadName).toBe('拼图素材.webp');
expect(downloadName).toMatch(/^-\d{8}-\d{6}\.webp$/u);
});
expect(downloadHref).toBe('blob:asset-library-download');
} finally {
@@ -385,8 +386,9 @@ describe('ImageCanvasEditorView asset library integration', () => {
],
});
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (..._args: Parameters<typeof fetch>) =>
new Response(new Blob(['frame'], { type: 'image/png' })),
const fetchMock = vi.fn(
async (..._args: Parameters<typeof fetch>) =>
new Response(new Blob(['frame'], { type: 'image/png' })),
);
globalThis.fetch = fetchMock as typeof fetch;
let downloadName = '';
@@ -415,7 +417,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
);
await waitFor(() => {
expect(downloadName).toBe('角色挥手-SpineJSON.zip');
expect(downloadName).toMatch(/^-SpineJSON-\d{8}-\d{6}\.zip$/u);
});
const fetchedUrls = fetchMock.mock.calls.map(([url]) => url);
expect(fetchedUrls).toContain('/generated/action/frame01.png');
@@ -590,6 +592,61 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('clears drag targets and blocks folder drops after entering asset selection mode', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'folder-role',
label: '角色',
sortOrder: 100,
collapsed: false,
systemDefault: false,
},
],
assets: [
createTestImageAsset({ assetId: 'asset-puzzle', label: '拼图素材' }),
],
});
render(<ImageCanvasEditorView />);
const roleFolder = await screen.findByRole('region', { name: '角色' });
const internalTransfer = createDataTransferStub();
internalTransfer.setData(
'application/x-genarrative-editor-asset',
'asset-puzzle',
);
fireEvent.dragOver(roleFolder, { dataTransfer: internalTransfer });
expect(roleFolder.className).toContain(
'image-canvas-editor__asset-folder--move-target',
);
fireEvent.click(screen.getByRole('button', { name: '素材选择模式' }));
await waitFor(() => {
expect(roleFolder.className).not.toContain(
'image-canvas-editor__asset-folder--move-target',
);
});
fireEvent.drop(roleFolder, { dataTransfer: internalTransfer });
const fileTransfer = createDataTransferStub();
fileTransfer.types.push('Files');
(fileTransfer.files as File[]).push(
new File(['image'], '新增素材.png', { type: 'image/png' }),
);
fireEvent.dragOver(roleFolder, { dataTransfer: fileTransfer });
fireEvent.drop(roleFolder, { dataTransfer: fileTransfer });
expect(updateEditorAssetMock).not.toHaveBeenCalled();
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('uploads multiple files as account-level assets without adding canvas layers', async () => {
render(<ImageCanvasEditorView />);
@@ -641,13 +698,15 @@ describe('ImageCanvasEditorView asset library integration', () => {
it('uploads MP3 and MP4 files through OSS before adding them to the asset library', async () => {
const createdMediaElements: HTMLMediaElement[] = [];
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
const element = originalCreateElement(tagName, options);
if (tagName === 'video' || tagName === 'audio') {
createdMediaElements.push(element as HTMLMediaElement);
}
return element;
});
vi.spyOn(document, 'createElement').mockImplementation(
(tagName, options) => {
const element = originalCreateElement(tagName, options);
if (tagName === 'video' || tagName === 'audio') {
createdMediaElements.push(element as HTMLMediaElement);
}
return element;
},
);
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:editor-media'),
@@ -686,8 +745,12 @@ describe('ImageCanvasEditorView asset library integration', () => {
);
});
await waitFor(() => {
expect(screen.getByRole('button', { name: '添加胜利音效.mp3' })).toBeTruthy();
expect(screen.getByRole('button', { name: '添加开场动画.mp4' })).toBeTruthy();
expect(
screen.getByRole('button', { name: '添加胜利音效.mp3' }),
).toBeTruthy();
expect(
screen.getByRole('button', { name: '添加开场动画.mp4' }),
).toBeTruthy();
});
expect(createEditorAssetMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -887,6 +950,176 @@ describe('ImageCanvasEditorView asset library integration', () => {
).toBeNull();
});
it('adds visible ranges to the stable selection and clears state on exit', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }),
createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }),
createTestImageAsset({ assetId: 'asset-c', label: '账号素材C' }),
],
});
render(<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(
/ 3/u,
),
).toBeTruthy();
fireEvent.click(screen.getByText('账号素材A'), {
ctrlKey: true,
shiftKey: true,
});
expect(
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByText(
/ 3/u,
),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
expect(screen.queryByRole('toolbar', { name: '素材批量操作' })).toBeNull();
expect(screen.getByRole('button', { name: '添加账号素材A' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '素材选择模式' }));
expect(
within(screen.getByRole('toolbar', { name: '素材批量操作' })).getByRole(
'button',
{ name: '删除' },
),
).toHaveProperty('disabled', true);
});
it('keeps hidden selections stable and applies visible actions as deltas', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'folder-other',
label: '其它素材',
sortOrder: 1,
collapsed: false,
systemDefault: false,
},
],
assets: [
createTestImageAsset({ assetId: 'asset-a', label: '账号素材A' }),
createTestImageAsset({ assetId: 'asset-b', label: '账号素材B' }),
createTestImageAsset({
assetId: 'asset-c',
label: '账号素材C',
folderId: 'folder-other',
}),
],
});
render(<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();
await user.click(screen.getByRole('button', { name: '折叠其它素材' }));
await user.click(within(toolbar).getByRole('button', { name: '删除' }));
const hiddenDeleteDialog = screen.getByRole('dialog', {
name: '删除全部已选素材',
});
expect(
within(hiddenDeleteDialog).getByText(
'将删除 2 个已选素材,其中 1 个当前未显示。',
),
).toBeTruthy();
expect(deleteEditorAssetMock).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('搜索素材'), {
target: { value: '账号素材B' },
});
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: '删除全部已选素材' }),
).toBeNull();
});
expect(deleteEditorAssetMock).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('搜索素材'), {
target: { value: '' },
});
await user.click(within(toolbar).getByRole('button', { name: '删除' }));
const refreshedHiddenDeleteDialog = screen.getByRole('dialog', {
name: '删除全部已选素材',
});
await user.click(
within(refreshedHiddenDeleteDialog).getByRole('button', {
name: '确认删除全部已选素材',
}),
);
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-b');
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-c');
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-a');
});
it('removes canvas layers linked to deleted assets', async () => {
const user = userEvent.setup();
loadEditorAssetLibraryMock.mockResolvedValueOnce({
@@ -1638,9 +1871,7 @@ describe('ImageCanvasEditorView asset library integration', () => {
dataTransfer,
});
expect(
await screen.findByAltText('画布序列帧:角色奔跑'),
).toBeTruthy();
expect(await screen.findByAltText('画布序列帧:角色奔跑')).toBeTruthy();
expect(
screen.getByRole('button', { name: '暂停角色奔跑序列帧播放' }),
).toBeTruthy();
@@ -63,7 +63,8 @@ export type SidebarMediaItemProps = {
imageAlt: string;
selected?: boolean;
primaryLabel: string;
onPrimaryClick: () => void;
primaryDisabled?: boolean;
onPrimaryClick: MouseEventHandler<HTMLButtonElement>;
thumbnailClassName: string;
metaClassName: string;
rowClassName: string;
@@ -91,6 +92,7 @@ export function SidebarMediaItem({
imageAlt,
selected = false,
primaryLabel,
primaryDisabled = false,
onPrimaryClick,
thumbnailClassName,
metaClassName,
@@ -127,6 +129,7 @@ export function SidebarMediaItem({
className={primaryClassName}
onClick={onPrimaryClick}
aria-label={primaryLabel}
disabled={primaryDisabled}
draggable={draggable}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
@@ -68,9 +68,8 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
newFolderName: '',
renamingFolder: null,
renamingAsset: null,
allSelectableAssetsSelected: false,
isExportingSelectedAssets: false,
assetMarquee: null,
setIsAssetSelectionMode: vi.fn(),
setCreatingFolder: vi.fn(),
setNewFolderName: vi.fn(),
setRenamingFolder: vi.fn(),
@@ -78,12 +77,13 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
setActiveUploadFolderId: vi.fn(),
setUploadDropTarget: vi.fn(),
setAssetPointerDrag: vi.fn(),
setSelectedAssetIds: vi.fn(),
setImageContextMenu: vi.fn(),
setContextMenu: vi.fn(),
onAssetMarqueePointerDown: vi.fn(),
onAssetMarqueePointerMove: vi.fn(),
onAssetMarqueePointerUp: vi.fn(),
onAssetMarqueePointerCancel: vi.fn(),
onAssetMarqueeLostPointerCapture: vi.fn(),
updateAssetMoveDropFolder: vi.fn(),
addUploadedFiles: vi.fn(),
requestUpload: vi.fn(),
@@ -101,7 +101,9 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
addAssetLayer: vi.fn(),
onDownloadAsset: vi.fn(),
toggleAllAssetsSelected: vi.fn(),
downloadSelectedAssets: vi.fn(),
deleteSelectedAssets: vi.fn(),
openAssetSelectionMode: vi.fn(),
closeAssetSelectionMode: vi.fn(),
groupSelectedLayers: vi.fn(),
selectSingleLayer: vi.fn(),
@@ -1253,7 +1253,9 @@ describe('ImageCanvasEditorView', () => {
await waitFor(() => {
expect(exportedBlob).toBeTruthy();
});
expect(downloadName).toMatch(/^--\d{8}\.zip$/u);
expect(downloadName).toMatch(
/^--\d{8}-\d{6}\.zip$/u,
);
const zip = await JSZip.loadAsync(exportedBlob!);
expect(zip.file('导出项目-画布素材/images/001-素材 A.png')).toBeTruthy();
@@ -1493,7 +1495,7 @@ describe('ImageCanvasEditorView', () => {
fireEvent.click(screen.getByRole('button', { name: '下载按钮' }));
await waitFor(() => {
expect(downloadName).toBe('拼图素材.webp');
expect(downloadName).toMatch(/^-\d{8}-\d{6}\.webp$/u);
});
expect(downloadHref).toBe('blob:layer-download');
expect(globalThis.fetch).toHaveBeenCalledWith(
@@ -675,7 +675,6 @@ export function ImageCanvasEditorView({
assets,
setAssets,
groupedAssets,
allSelectableAssetsSelected,
renamingAsset,
setRenamingAsset,
renamingFolder,
@@ -687,9 +686,8 @@ export function ImageCanvasEditorView({
activeUploadFolderId,
setActiveUploadFolderId,
isAssetSelectionMode,
setIsAssetSelectionMode,
selectedAssetIds,
setSelectedAssetIds,
selectedAssets,
assetMarquee,
assetPointerDrag,
setAssetPointerDrag,
@@ -711,17 +709,28 @@ export function ImageCanvasEditorView({
deleteSelectedAssets,
moveAssetToFolder,
closeAssetSelectionMode,
openAssetSelectionMode,
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
handleAssetMarqueePointerCancel,
handleAssetMarqueeLostPointerCapture,
refreshAssetLibrary,
} = useImageCanvasAssetLibrary({
assetListRef,
assetPointerDragRef,
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
openEditorLoginModal,
onDeleteAssets: removeCanvasLayersLinkedToAssets,
onOpenSelectionMode: () => setUploadDropTarget(null),
});
useEffect(() => {
if (activeSidebarPanel !== 'assets' && isAssetSelectionMode) {
closeAssetSelectionMode();
}
}, [activeSidebarPanel, closeAssetSelectionMode, isAssetSelectionMode]);
const pendingImageReferenceUploadCountRef = useRef(0);
const isPendingReferenceUploadContextMutationLocked = useCallback(
() => pendingImageReferenceUploadCountRef.current > 0,
@@ -1357,6 +1366,7 @@ export function ImageCanvasEditorView({
assetExportStatus,
isExportingAssets,
exportCanvasAssets,
exportSelectedAssets,
exportLayerImage,
reportAssetError,
} = useImageCanvasAssetExportWorkflow({
@@ -1370,6 +1380,13 @@ export function ImageCanvasEditorView({
},
[exportLayerImage],
);
const downloadSelectedAssets = useCallback(() => {
const selectedLayers = selectedAssets.map((asset, index) => ({
...createAssetActionLayer(asset),
zIndex: index,
}));
return exportSelectedAssets(selectedLayers);
}, [exportSelectedAssets, selectedAssets]);
const {
uploadInputRef,
uploadTarget,
@@ -2437,9 +2454,8 @@ export function ImageCanvasEditorView({
newFolderName,
renamingFolder,
renamingAsset,
allSelectableAssetsSelected,
isExportingSelectedAssets: isExportingAssets,
assetMarquee,
setIsAssetSelectionMode,
setCreatingFolder,
setNewFolderName,
setRenamingFolder,
@@ -2447,12 +2463,13 @@ export function ImageCanvasEditorView({
setActiveUploadFolderId,
setUploadDropTarget,
setAssetPointerDrag,
setSelectedAssetIds,
setImageContextMenu,
setContextMenu,
onAssetMarqueePointerDown: handleAssetMarqueePointerDown,
onAssetMarqueePointerMove: handleAssetMarqueePointerMove,
onAssetMarqueePointerUp: handleAssetMarqueePointerUp,
onAssetMarqueePointerCancel: handleAssetMarqueePointerCancel,
onAssetMarqueeLostPointerCapture: handleAssetMarqueeLostPointerCapture,
updateAssetMoveDropFolder,
addUploadedFiles,
requestUpload,
@@ -2470,8 +2487,10 @@ export function ImageCanvasEditorView({
addAssetLayer,
onDownloadAsset: downloadAsset,
toggleAllAssetsSelected,
downloadSelectedAssets,
deleteSelectedAssets,
closeAssetSelectionMode,
openAssetSelectionMode,
groupSelectedLayers,
selectSingleLayer,
resolveContextMenuPosition,
@@ -21,6 +21,12 @@ import {
} from './ImageCanvasGenerationModel';
describe('ImageCanvasExportModel', () => {
it('formats export timestamps through seconds', () => {
expect(formatExportDate(new Date(2026, 6, 27, 9, 8, 7))).toBe(
'20260727-090807',
);
});
it('builds a real animated GIF preview from sequence frames', () => {
const gifBytes = buildAnimatedGifPreviewBytes({
width: 2,
@@ -68,8 +74,8 @@ describe('ImageCanvasExportModel', () => {
'角色 草图',
);
expect(sanitizeExportFilePart(' ', 'fallback')).toBe('fallback');
expect(formatExportDate(new Date('2026-06-17T01:02:03.000Z'))).toBe(
'20260617',
expect(formatExportDate(new Date(2026, 5, 17, 9, 2, 3))).toBe(
'20260617-090203',
);
});
@@ -104,7 +110,26 @@ describe('ImageCanvasExportModel', () => {
},
],
}),
).toBe('image-sequence:sequence-task');
).toBe('image-sequence:task:sequence-task');
expect(
getLayerExportKey({
...buildLayer(),
mediaType: 'image-sequence',
taskId: null,
sourceResourceId: null,
src: '',
imageSequenceFrames: [
{
imageSrc: '',
objectKey: 'private/animation-a/frame-01.png',
width: 1024,
height: 1024,
},
],
}),
).toBe(
'image-sequence:frames:["object-key:private/animation-a/frame-01.png"]',
);
});
it('detects image extensions from content type before falling back to src', () => {
@@ -307,7 +332,7 @@ describe('ImageCanvasExportModel', () => {
try {
const blob = await readLayerAssetBlob(
buildLayer({
src: '/generated-editor-videos/video.mp4',
src: '',
mediaType: 'video',
objectKey: 'generated/video.mp4',
}),
@@ -27,17 +27,30 @@ export function formatExportDate(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
const hour = String(date.getHours()).padStart(2, '0');
const minute = String(date.getMinutes()).padStart(2, '0');
const second = String(date.getSeconds()).padStart(2, '0');
return `${year}${month}${day}-${hour}${minute}${second}`;
}
export function getLayerExportKey(layer: CanvasLayer) {
if (layer.mediaType === 'image-sequence') {
return `image-sequence:${
layer.taskId ||
layer.sourceResourceId ||
layer.imageSequenceFrames?.map((frame) => frame.imageSrc).join('|') ||
layer.src
}`;
if (layer.taskId) {
return `image-sequence:task:${layer.taskId}`;
}
if (layer.sourceResourceId) {
return `image-sequence:resource:${layer.sourceResourceId}`;
}
const frameIdentities = layer.imageSequenceFrames?.map((frame) =>
frame.assetObjectId
? `asset-object:${frame.assetObjectId}`
: frame.objectKey
? `object-key:${frame.objectKey}`
: `source:${frame.imageSrc}`,
);
return frameIdentities?.length
? `image-sequence:frames:${JSON.stringify(frameIdentities)}`
: `image-sequence:source:${layer.src}`;
}
return (
layer.assetObjectId ||
@@ -87,9 +87,8 @@ function createSidebarProps(
newFolderName: '',
renamingFolder: null,
renamingAsset: null,
allSelectableAssetsSelected: false,
isExportingSelectedAssets: false,
assetMarquee: null,
setIsAssetSelectionMode: vi.fn(),
setCreatingFolder: vi.fn(),
setNewFolderName: vi.fn(),
setRenamingFolder: vi.fn(),
@@ -97,12 +96,13 @@ function createSidebarProps(
setActiveUploadFolderId: vi.fn(),
setUploadDropTarget: vi.fn(),
setAssetPointerDrag: vi.fn(),
setSelectedAssetIds: vi.fn(),
setImageContextMenu: vi.fn(),
setContextMenu: vi.fn(),
onAssetMarqueePointerDown: vi.fn(),
onAssetMarqueePointerMove: vi.fn(),
onAssetMarqueePointerUp: vi.fn(),
onAssetMarqueePointerCancel: vi.fn(),
onAssetMarqueeLostPointerCapture: vi.fn(),
updateAssetMoveDropFolder: vi.fn(),
addUploadedFiles: vi.fn(),
requestUpload: vi.fn(),
@@ -120,7 +120,9 @@ function createSidebarProps(
addAssetLayer: vi.fn(),
onDownloadAsset: vi.fn(),
toggleAllAssetsSelected: vi.fn(),
downloadSelectedAssets: vi.fn(),
deleteSelectedAssets: vi.fn(),
openAssetSelectionMode: vi.fn(),
closeAssetSelectionMode: vi.fn(),
groupSelectedLayers: vi.fn(),
selectSingleLayer: vi.fn(),
@@ -269,6 +271,65 @@ describe('ImageCanvasSidebarView', () => {
).toBeNull();
});
it('keeps the batch toolbar outside the scrolling list and downloads the stable selection', () => {
const downloadSelectedAssets = vi.fn();
const closeAssetSelectionMode = vi.fn();
const asset = createAsset();
render(
<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();
fireEvent.click(screen.getByRole('button', { name: '素材选择模式' }));
expect(closeAssetSelectionMode).toHaveBeenCalledTimes(1);
});
it('opens selection mode through the shared lifecycle', () => {
const openAssetSelectionMode = vi.fn();
const folder = createFolder();
render(
<ImageCanvasSidebarView
{...createSidebarProps({
isAssetSelectionMode: false,
openAssetSelectionMode,
groupedAssets: [
{
...folder,
assets: [
createAsset({ id: 'asset-a', label: '角色' }),
createAsset({ id: 'asset-b', label: '背景' }),
],
},
],
})}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材选择模式' }));
expect(openAssetSelectionMode).toHaveBeenCalledTimes(1);
});
it('does not expose internal models or providers through asset search', () => {
const folder = createFolder();
const internalAsset = createAsset({
@@ -1,8 +1,4 @@
import {
CheckSquare,
FolderPlus,
Square,
} from 'lucide-react';
import { CheckSquare, FolderPlus, Square } from 'lucide-react';
import type {
Dispatch,
PointerEvent as ReactPointerEvent,
@@ -36,6 +32,10 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasLayerPanelView } from './ImageCanvasLayerPanelView';
import {
type AssetSelectionInteraction,
getSelectableAssets,
} from './useImageCanvasAssetSelection';
export type { GroupedEditorAssetFolder, UploadFilesOptions };
@@ -58,9 +58,8 @@ export type ImageCanvasSidebarViewProps = {
newFolderName: string;
renamingFolder: { folderId: string; value: string } | null;
renamingAsset: { assetId: string; value: string } | null;
allSelectableAssetsSelected: boolean;
isExportingSelectedAssets: boolean;
assetMarquee: AssetMarqueeState | null;
setIsAssetSelectionMode: Dispatch<SetStateAction<boolean>>;
setCreatingFolder: Dispatch<SetStateAction<boolean>>;
setNewFolderName: Dispatch<SetStateAction<string>>;
setRenamingFolder: Dispatch<
@@ -72,20 +71,22 @@ 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: (
onAssetMarqueePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onAssetMarqueePointerCancel: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerMove: (
event: ReactPointerEvent<HTMLDivElement>,
) => void;
onAssetMarqueePointerUp: (
onAssetMarqueeLostPointerCapture: (
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 +98,16 @@ export type ImageCanvasSidebarViewProps = {
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
submitAssetToShowcase: (asset: EditorAsset) => void;
toggleAssetSelected: (assetId: string) => void;
toggleAssetSelected: (
assetId: string,
interaction: AssetSelectionInteraction,
) => void;
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
toggleAllAssetsSelected: () => void;
deleteSelectedAssets: () => void;
toggleAllAssetsSelected: (visibleAssetIds: string[]) => void;
downloadSelectedAssets: () => void | Promise<void>;
deleteSelectedAssets: (assetIds?: ReadonlySet<string>) => void;
openAssetSelectionMode: () => void;
closeAssetSelectionMode: () => void;
groupSelectedLayers: () => void;
selectSingleLayer: (layerId: string | null) => void;
@@ -134,9 +140,8 @@ export function ImageCanvasSidebarView({
newFolderName,
renamingFolder,
renamingAsset,
allSelectableAssetsSelected,
isExportingSelectedAssets,
assetMarquee,
setIsAssetSelectionMode,
setCreatingFolder,
setNewFolderName,
setRenamingFolder,
@@ -144,12 +149,13 @@ export function ImageCanvasSidebarView({
setActiveUploadFolderId,
setUploadDropTarget,
setAssetPointerDrag,
setSelectedAssetIds,
setImageContextMenu,
setContextMenu,
onAssetMarqueePointerDown,
onAssetMarqueePointerMove,
onAssetMarqueePointerUp,
onAssetMarqueePointerCancel,
onAssetMarqueeLostPointerCapture,
updateAssetMoveDropFolder,
addUploadedFiles,
requestUpload,
@@ -167,7 +173,9 @@ export function ImageCanvasSidebarView({
addAssetLayer,
onDownloadAsset,
toggleAllAssetsSelected,
downloadSelectedAssets,
deleteSelectedAssets,
openAssetSelectionMode,
closeAssetSelectionMode,
groupSelectedLayers,
selectSingleLayer,
@@ -188,6 +196,19 @@ export function ImageCanvasSidebarView({
),
[filteredGroupedAssets],
);
const visibleAssetIds = useMemo(
() =>
filteredGroupedAssets.flatMap((folder) =>
folder.collapsed
? []
: getSelectableAssets(folder.assets).map((asset) => asset.id),
),
[filteredGroupedAssets],
);
const allVisibleAssetsSelected =
visibleAssetIds.length > 0 &&
visibleAssetIds.every((assetId) => selectedAssetIds.has(assetId));
const normalizedLayerSearchQuery = layerSearchQuery.trim().toLowerCase();
const filteredLayers = useMemo(
() =>
@@ -211,7 +232,17 @@ export function ImageCanvasSidebarView({
}
return (
<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 +262,10 @@ export function ImageCanvasSidebarView({
title="选择"
icon={isAssetSelectionMode ? CheckSquare : Square}
pressed={isAssetSelectionMode}
onClick={() =>
setIsAssetSelectionMode((currentMode) => !currentMode)
onClick={
isAssetSelectionMode
? closeAssetSelectionMode
: openAssetSelectionMode
}
/>
<EditorIconButton
@@ -260,9 +293,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 +323,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,10 +340,11 @@ export function ImageCanvasSidebarView({
setActiveUploadFolderId={setActiveUploadFolderId}
setUploadDropTarget={setUploadDropTarget}
setAssetPointerDrag={setAssetPointerDrag}
setSelectedAssetIds={setSelectedAssetIds}
onAssetMarqueePointerDown={onAssetMarqueePointerDown}
onAssetMarqueePointerMove={onAssetMarqueePointerMove}
onAssetMarqueePointerUp={onAssetMarqueePointerUp}
onAssetMarqueePointerCancel={onAssetMarqueePointerCancel}
onAssetMarqueeLostPointerCapture={onAssetMarqueeLostPointerCapture}
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
addUploadedFiles={addUploadedFiles}
requestUpload={requestUpload}
@@ -322,6 +362,7 @@ export function ImageCanvasSidebarView({
addAssetLayer={addAssetLayer}
onDownloadAsset={onDownloadAsset}
toggleAllAssetsSelected={toggleAllAssetsSelected}
downloadSelectedAssets={downloadSelectedAssets}
deleteSelectedAssets={deleteSelectedAssets}
closeAssetSelectionMode={closeAssetSelectionMode}
/>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,21 @@
/* @vitest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { useRef } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
import type { EditorAsset } from './ImageCanvasEditorTypes';
import type {
AssetPointerDragState,
EditorAsset,
} from './ImageCanvasEditorTypes';
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
@@ -79,8 +88,10 @@ function AssetLibraryHarness({
onDeleteAssets?: (assets: EditorAsset[]) => boolean | void;
}) {
const assetListRef = useRef<HTMLDivElement | null>(null);
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
const assetLibrary = useImageCanvasAssetLibrary({
assetListRef,
assetPointerDragRef,
canAccessProtectedData,
openEditorLoginModal,
onDeleteAssets,
@@ -97,7 +108,14 @@ 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,
),
shiftKey: event.shiftKey,
})
}
>
{asset.label}
</button>
@@ -129,11 +147,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"
@@ -182,12 +208,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}>
<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>
);
}
@@ -454,6 +493,79 @@ describe('useImageCanvasAssetLibrary', () => {
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-b');
});
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('clears the range anchor when toggling all visible assets', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createAssetSnapshot({ assetId: 'asset-a', label: '素材A' }),
createAssetSnapshot({ assetId: 'asset-b', label: '素材B' }),
createAssetSnapshot({ assetId: 'asset-c', label: '素材C' }),
],
});
render(<AssetLibraryHarness />);
fireEvent.click(await screen.findByRole('button', { name: '素材A' }));
fireEvent.click(screen.getByRole('button', { name: 'toggle all' }));
fireEvent.click(screen.getByRole('button', { name: 'toggle all' }));
fireEvent.click(screen.getByRole('button', { name: '素材C' }), {
shiftKey: true,
});
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-c');
});
it('reconciles selection only when an asset actually ceases to exist', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createAssetSnapshot({ assetId: 'asset-a', label: '素材A' }),
createAssetSnapshot({ assetId: 'asset-b', label: '素材B' }),
createAssetSnapshot({ assetId: 'asset-c', label: '素材C' }),
],
});
render(<AssetLibraryHarness />);
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' }));
await waitFor(() => {
expect(screen.getByTestId('selected-assets').textContent).toBe(
'asset-b|asset-c',
);
});
});
it('submits a generated asset to showcase review', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,470 @@
/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import { createRef, type PointerEvent as ReactPointerEvent } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { EditorAsset } from './ImageCanvasEditorTypes';
import {
countHiddenSelectedAssets,
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('counts selected assets outside the current visible scope', () => {
expect(
countHiddenSelectedAssets(new Set(['asset-a', 'asset-b', 'asset-c']), [
'asset-a',
]),
).toBe(2);
});
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: 'object-key-only',
src: '',
objectKey: 'generated/editor/object-key-only.png',
}),
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
]).map((asset) => asset.id),
).toEqual(['ready', 'object-key-only']);
});
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: 'object-key-only',
src: '',
objectKey: 'generated/editor/object-key-only.png',
}),
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 },
},
{
assetId: 'object-key-only',
rect: { left: 25, right: 30, top: 25, bottom: 30 },
},
],
selectionRect: { left: 20, right: 35, top: 20, bottom: 35 },
}),
]).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({
id: 'object-key-a',
src: '',
objectKey: 'generated/editor/object-key-a.png',
}),
createAsset({
id: 'object-key-b',
src: '',
objectKey: 'generated/editor/object-key-b.png',
}),
];
const visibleAssetIds = getSelectableAssets(assets).map(
(asset) => asset.id,
);
const { result } = renderHook(() =>
useImageCanvasAssetSelection({
assetListRef: createRef<HTMLDivElement>(),
assets,
}),
);
act(() => result.current.openAssetSelectionMode());
act(() =>
result.current.toggleAssetSelected('object-key-a', { visibleAssetIds }),
);
act(() =>
result.current.toggleAssetSelected('object-key-b', {
visibleAssetIds,
shiftKey: true,
}),
);
expect([...result.current.selectedAssetIds]).toEqual([
'object-key-a',
'object-key-b',
]);
act(() => result.current.toggleAllAssetsSelected(visibleAssetIds));
expect(result.current.selectedAssetIds.size).toBe(0);
act(() => result.current.toggleAllAssetsSelected(visibleAssetIds));
expect([...result.current.selectedAssetIds]).toEqual(visibleAssetIds);
});
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);
});
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();
Object.assign(assetList, {
setPointerCapture: vi.fn(),
releasePointerCapture,
});
const assetListRef = { current: assetList };
const { result } = renderHook(() =>
useImageCanvasAssetSelection({
assetListRef,
assets: [createAsset()],
}),
);
const target = document.createElement('div');
act(() => result.current.openAssetSelectionMode());
act(() =>
result.current.handleAssetMarqueePointerDown({
target,
button: 0,
pointerId: 7,
clientX: 10,
clientY: 20,
ctrlKey: false,
metaKey: false,
preventDefault: vi.fn(),
} as unknown as ReactPointerEvent<HTMLDivElement>),
);
expect(result.current.assetMarquee?.pointerId).toBe(7);
act(() =>
result.current.handleAssetMarqueeLostPointerCapture({
pointerId: 8,
} as ReactPointerEvent<HTMLDivElement>),
);
expect(result.current.assetMarquee?.pointerId).toBe(7);
act(() =>
result.current.handleAssetMarqueeLostPointerCapture({
pointerId: 7,
} as ReactPointerEvent<HTMLDivElement>),
);
expect(result.current.assetMarquee).toBeNull();
expect(releasePointerCapture).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
+34 -7
View File
@@ -4180,6 +4180,30 @@ 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-batch-action {
width: 100% !important;
min-width: 0 !important;
max-width: 100% !important;
min-height: 2.35rem !important;
gap: 0.375rem !important;
overflow: hidden !important;
padding: 0.375rem 0.5rem !important;
font-size: 0.75rem !important;
line-height: 1rem !important;
}
.image-canvas-editor__asset-folder {
display: grid;
gap: 0.45rem;
@@ -4382,13 +4406,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;
@@ -8987,6 +9004,11 @@ button.image-canvas-editor__reference-chip:disabled {
border-bottom: 1px solid #d9dee8;
}
.image-canvas-editor__sidebar--asset-selection {
height: clamp(14rem, 62dvh, 21rem);
max-height: 62dvh;
}
.image-canvas-editor__asset-list {
grid-auto-flow: column;
grid-auto-columns: minmax(15rem, 78vw);
@@ -8994,6 +9016,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);
+50 -9
View File
@@ -16,10 +16,16 @@ function readPlatformThemeCss() {
function getCssBlock(source: string, selector: string) {
const selectorIndex = source.indexOf(selector);
expect(selectorIndex, `${selector} should exist in src/index.css`).toBeGreaterThanOrEqual(0);
expect(
selectorIndex,
`${selector} should exist in src/index.css`,
).toBeGreaterThanOrEqual(0);
const openBraceIndex = source.indexOf('{', selectorIndex);
expect(openBraceIndex, `${selector} should open a CSS block`).toBeGreaterThanOrEqual(0);
expect(
openBraceIndex,
`${selector} should open a CSS block`,
).toBeGreaterThanOrEqual(0);
let depth = 0;
for (let index = openBraceIndex; index < source.length; index += 1) {
@@ -111,7 +117,9 @@ describe('index stylesheet unread dots', () => {
);
expect(generatingBlock).toContain('cursor: progress;');
expect(generatingScanBlock).toContain('animation: image-canvas-generation-scan');
expect(generatingScanBlock).toContain(
'animation: image-canvas-generation-scan',
);
expect(iconBlock).toContain('background: linear-gradient(');
expect(iconDecorationBlock).toContain('width: 42%;');
expect(css).not.toContain(
@@ -129,11 +137,16 @@ describe('index stylesheet unread dots', () => {
css,
'.project-gallery__canvas-cover-loading::before,\n.image-canvas-editor__layer-image-loading::before',
);
const keyframesBlock = getCssBlock(css, '@keyframes image-canvas-generation-scan');
const keyframesBlock = getCssBlock(
css,
'@keyframes image-canvas-generation-scan',
);
expect(loadingBlock).toContain('overflow: hidden;');
expect(loadingBlock).toContain('background: rgba(226, 232, 240, 0.72);');
expect(loadingBlock).not.toContain('animation: image-canvas-generation-scan');
expect(loadingBlock).not.toContain(
'animation: image-canvas-generation-scan',
);
expect(shimmerBlock).toContain('left: -64%;');
expect(shimmerBlock).toContain('width: 64%;');
@@ -215,7 +228,9 @@ describe('index stylesheet unread dots', () => {
);
expect(platformShellBlock).toContain('--platform-layout-viewport-height');
expect(platformShellBlock).not.toContain('translate3d');
expect(platformShellBlock).not.toContain('--platform-keyboard-focus-offset');
expect(platformShellBlock).not.toContain(
'--platform-keyboard-focus-offset',
);
});
it('uses warm brown tokens for draft unread markers instead of red literals', () => {
@@ -248,7 +263,9 @@ describe('index stylesheet unread dots', () => {
const css = readIndexCss();
const themeCss = readPlatformThemeCss();
const titleRuleIndex = css.indexOf('.selection-hero-brand__title');
const platformThemeRuleIndex = css.indexOf('.platform-theme,\n.platform-theme *');
const platformThemeRuleIndex = css.indexOf(
'.platform-theme,\n.platform-theme *',
);
expect(titleRuleIndex).toBeGreaterThanOrEqual(0);
expect(platformThemeRuleIndex).toBeGreaterThan(titleRuleIndex);
@@ -305,6 +322,24 @@ describe('index stylesheet unread dots', () => {
});
});
describe('index stylesheet image canvas asset selection', () => {
it('bounds the raised mobile selection sidebar by the short viewport', () => {
const css = readIndexCss();
const mobileQueryIndex = css.indexOf('@media (max-width: 760px)');
expect(mobileQueryIndex).toBeGreaterThanOrEqual(0);
const selectionSidebarBlock = getCssBlock(
css.slice(mobileQueryIndex),
'.image-canvas-editor__sidebar--asset-selection',
);
expect(selectionSidebarBlock).toContain(
'height: clamp(14rem, 62dvh, 21rem);',
);
expect(selectionSidebarBlock).toContain('max-height: 62dvh;');
expect(selectionSidebarBlock).not.toContain('clamp(19rem');
});
});
describe('index stylesheet draft mobile cards', () => {
it('disables long-press text selection on the whole mobile draft tab', () => {
const css = readIndexCss();
@@ -313,7 +348,10 @@ describe('index stylesheet draft mobile cards', () => {
const draftPanelSelector =
'#platform-tab-panel-saves,\n #platform-tab-panel-saves *';
const draftPanelSelectorIndex = css.indexOf(draftPanelSelector, mobileQueryIndex);
const draftPanelSelectorIndex = css.indexOf(
draftPanelSelector,
mobileQueryIndex,
);
expect(draftPanelSelectorIndex).toBeGreaterThanOrEqual(0);
const draftPanelBlock = getCssBlock(css, draftPanelSelector);
@@ -323,7 +361,10 @@ describe('index stylesheet draft mobile cards', () => {
const editableSelector =
"#platform-tab-panel-saves :is(input, textarea, [contenteditable='true'])";
const editableSelectorIndex = css.indexOf(editableSelector, mobileQueryIndex);
const editableSelectorIndex = css.indexOf(
editableSelector,
mobileQueryIndex,
);
expect(editableSelectorIndex).toBeGreaterThanOrEqual(0);
const editableBlock = getCssBlock(css, editableSelector);
+11 -2
View File
@@ -101,6 +101,15 @@ export function shouldResolveAssetReadUrl(source: string | null | undefined) {
);
}
export function hasReadableAssetSource(
source: string | null | undefined,
objectKey?: string | null,
) {
return Boolean(
source?.trim() || objectKey?.trim().replace(/^\/+/u, ''),
);
}
function normalizeLegacyPublicPath(value: string) {
return `/${value.trim().replace(/^\/+/u, '')}`;
}
@@ -513,11 +522,11 @@ export async function readAssetBytes(
options: AssetReadBytesOptions = {},
) {
const value = source?.trim() ?? '';
if (!value) {
const objectKey = options.objectKey?.trim().replace(/^\/+/u, '') ?? '';
if (!hasReadableAssetSource(value, objectKey)) {
throw new Error('资源路径不能为空');
}
const objectKey = options.objectKey?.trim().replace(/^\/+/u, '') ?? '';
const legacyPath = isGeneratedLegacyPath(value)
? value
: resolveGeneratedLegacyPathFromUrl(value);