合并主分支素材库多选更新
合入素材库多选操作与批量导出更新 保留画布生成配方分支改动并解决冲突
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user