修复素材选择模式交互边界

阻断选择模式下的素材移动与外部文件投放并清理拖拽状态
固定隐藏素材删除确认目标并在选择范围漂移时关闭弹窗
收敛选择交互契约与素材行标签分支
修正短视口侧栏高度并补充定向回归测试和文档
This commit is contained in:
2026-08-07 13:17:33 +08:00
parent d1a9b36104
commit f4131c0421
13 changed files with 283 additions and 90 deletions
@@ -117,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();
@@ -133,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();
@@ -13,7 +13,7 @@ import type {
RefObject,
SetStateAction,
} from 'react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
@@ -110,7 +110,7 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
onDownloadAsset: (asset: EditorAsset) => void;
toggleAllAssetsSelected: (visibleAssetIds: string[]) => void;
downloadSelectedAssets: () => void | Promise<void>;
deleteSelectedAssets: () => void;
deleteSelectedAssets: (assetIds?: ReadonlySet<string>) => void;
closeAssetSelectionMode: () => void;
};
@@ -165,10 +165,12 @@ export function ImageCanvasAssetLibraryPanelView({
deleteSelectedAssets,
closeAssetSelectionMode,
}: ImageCanvasAssetLibraryPanelViewProps) {
const [
hiddenSelectionDeleteConfirmOpen,
setHiddenSelectionDeleteConfirmOpen,
] = useState(false);
const [hiddenSelectionDeleteSnapshot, setHiddenSelectionDeleteSnapshot] =
useState<{
assetIds: Set<string>;
visibleAssetIds: string[];
hiddenAssetCount: number;
} | null>(null);
let selectionToggleActionText = '全选';
if (allVisibleAssetsSelected) {
selectionToggleActionText = '取消全选';
@@ -182,9 +184,42 @@ export function ImageCanvasAssetLibraryPanelView({
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) {
setHiddenSelectionDeleteConfirmOpen(true);
setHiddenSelectionDeleteSnapshot({
assetIds: new Set(selectedAssetIds),
visibleAssetIds: [...visibleAssetIds],
hiddenAssetCount: hiddenSelectedAssetCount,
});
return;
}
deleteSelectedAssets();
@@ -351,14 +386,17 @@ export function ImageCanvasAssetLibraryPanelView({
</PlatformBatchActionToolbar>
) : null}
<PlatformDangerConfirmDialog
open={hiddenSelectionDeleteConfirmOpen}
open={isAssetSelectionMode && hiddenSelectionDeleteSnapshot !== null}
title="删除全部已选素材"
description={`将删除 ${selectedAssetIds.size} 个已选素材,其中 ${hiddenSelectedAssetCount} 个当前未显示。`}
description={`将删除 ${hiddenSelectionDeleteSnapshot?.assetIds.size ?? 0} 个已选素材,其中 ${hiddenSelectionDeleteSnapshot?.hiddenAssetCount ?? 0} 个当前未显示。`}
confirmLabel="确认删除全部已选素材"
onClose={() => setHiddenSelectionDeleteConfirmOpen(false)}
onClose={() => setHiddenSelectionDeleteSnapshot(null)}
onConfirm={() => {
setHiddenSelectionDeleteConfirmOpen(false);
deleteSelectedAssets();
const targetAssetIds = hiddenSelectionDeleteSnapshot?.assetIds;
setHiddenSelectionDeleteSnapshot(null);
if (targetAssetIds) {
deleteSelectedAssets(targetAssetIds);
}
}}
>
删除后不可恢复。
@@ -220,9 +220,6 @@ describe('ImageCanvasAssetRowView', () => {
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', {
visibleAssetIds: ['asset-1'],
shiftKey: false,
ctrlKey: false,
metaKey: false,
pointerKind: 'keyboard',
});
expect(addAssetLayer).not.toHaveBeenCalled();
expect(
@@ -246,7 +243,7 @@ describe('ImageCanvasAssetRowView', () => {
expect(toggleAssetSelected).not.toHaveBeenCalled();
});
it('forwards modifier keys to selection', () => {
it('forwards only the shift modifier used by selection semantics', () => {
const toggleAssetSelected = vi.fn();
renderAssetRow({ isAssetSelectionMode: true, toggleAssetSelected });
fireEvent.click(screen.getByText('账号素材A'), {
@@ -254,30 +251,42 @@ describe('ImageCanvasAssetRowView', () => {
shiftKey: true,
});
expect(toggleAssetSelected).toHaveBeenCalledWith(
'asset-1',
expect.objectContaining({
ctrlKey: true,
shiftKey: true,
pointerKind: 'keyboard',
}),
);
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', {
visibleAssetIds: ['asset-1'],
shiftKey: true,
});
});
it('uses click detail to recognize keyboard selection after a stale touch gesture', () => {
const toggleAssetSelected = vi.fn();
renderAssetRow({ isAssetSelectionMode: true, toggleAssetSelected });
const primaryButton = screen.getByRole('button', {
name: '选择素材账号素材A',
it('blocks internal moves and external file drops in selection mode', () => {
const { container, props } = renderAssetRow({
isAssetSelectionMode: true,
});
fireEvent.pointerDown(primaryButton, { pointerType: 'touch' });
fireEvent.click(primaryButton, { detail: 0 });
expect(toggleAssetSelected).toHaveBeenCalledWith(
'asset-1',
expect.objectContaining({ pointerKind: 'keyboard' }),
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', () => {
@@ -90,8 +90,6 @@ export function ImageCanvasAssetRowView({
onDownloadAsset,
}: ImageCanvasAssetRowViewProps) {
const rowRef = useRef<HTMLDivElement | null>(null);
const selectionPointerKindRef =
useRef<AssetSelectionInteraction['pointerKind']>('keyboard');
const [menuPosition, setMenuPosition] = useState<{
x: number;
y: number;
@@ -118,9 +116,6 @@ export function ImageCanvasAssetRowView({
const selectAssetFromEvent = useCallback(
(event: ReactMouseEvent<HTMLElement>) => {
const pointerKind =
event.detail === 0 ? 'keyboard' : selectionPointerKindRef.current;
selectionPointerKindRef.current = 'keyboard';
if (
!isAssetSelectionMode ||
!canSelectAsset ||
@@ -132,9 +127,6 @@ export function ImageCanvasAssetRowView({
toggleAssetSelected(asset.id, {
visibleAssetIds,
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
pointerKind,
});
},
[
@@ -211,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}`}
@@ -312,17 +314,7 @@ export function ImageCanvasAssetRowView({
title={asset.label}
detail={mediaDetail}
imageAlt={`素材:${asset.label}`}
primaryLabel={
isUploadingAsset
? `上传中${asset.label}`
: isFailedUpload
? `上传失败${asset.label}`
: isAssetSelectionMode
? canSelectAsset
? `选择素材${asset.label}`
: `素材${asset.label}不可选择`
: `添加${asset.label}`
}
primaryLabel={primaryLabel}
primaryDisabled={isAssetSelectionMode && !canSelectAsset}
onPrimaryClick={() => {
if (isUploadingAsset || isFailedUpload) {
@@ -400,7 +392,12 @@ export function ImageCanvasAssetRowView({
) : undefined
}
onDragStart={(event) => {
if (isRenaming || isUploadingAsset || isFailedUpload) {
if (
isAssetSelectionMode ||
isRenaming ||
isUploadingAsset ||
isFailedUpload
) {
event.preventDefault();
return;
}
@@ -420,10 +417,6 @@ export function ImageCanvasAssetRowView({
}}
onPointerDown={(event) => {
if (isAssetSelectionMode) {
selectionPointerKindRef.current =
event.pointerType === 'touch' || event.pointerType === 'pen'
? event.pointerType
: 'mouse';
return;
}
if (event.button !== 0) {
@@ -467,6 +460,17 @@ export function ImageCanvasAssetRowView({
setAssetPointerDrag(nextDrag);
}}
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();
@@ -483,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();
@@ -592,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 />);
@@ -1039,8 +1094,24 @@ describe('ImageCanvasEditorView asset library integration', () => {
),
).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(hiddenDeleteDialog).getByRole('button', {
within(refreshedHiddenDeleteDialog).getByRole('button', {
name: '确认删除全部已选素材',
}),
);
@@ -748,9 +748,11 @@ export function ImageCanvasEditorView({
refreshAssetLibrary,
} = useImageCanvasAssetLibrary({
assetListRef,
assetPointerDragRef,
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
openEditorLoginModal,
onDeleteAssets: removeCanvasLayersLinkedToAssets,
onOpenSelectionMode: () => setUploadDropTarget(null),
});
useEffect(() => {
@@ -106,7 +106,7 @@ export type ImageCanvasSidebarViewProps = {
onDownloadAsset: (asset: EditorAsset) => void;
toggleAllAssetsSelected: (visibleAssetIds: string[]) => void;
downloadSelectedAssets: () => void | Promise<void>;
deleteSelectedAssets: () => void;
deleteSelectedAssets: (assetIds?: ReadonlySet<string>) => void;
openAssetSelectionMode: () => void;
closeAssetSelectionMode: () => void;
groupSelectedLayers: () => void;
@@ -12,7 +12,10 @@ 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());
@@ -85,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,
@@ -108,10 +113,7 @@ function AssetLibraryHarness({
visibleAssetIds: assetLibrary.selectableAssets.map(
(item) => item.id,
),
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
pointerKind: 'mouse',
})
}
>
@@ -216,7 +218,7 @@ function AssetLibraryHarness({
>
toggle all
</button>
<button type="button" onClick={assetLibrary.deleteSelectedAssets}>
<button type="button" onClick={() => assetLibrary.deleteSelectedAssets()}>
delete selected
</button>
<button type="button" onClick={assetLibrary.openAssetSelectionMode}>
@@ -85,14 +85,18 @@ function readAssetFolderHitTargets(listElement: ParentNode | null) {
export function useImageCanvasAssetLibrary({
assetListRef,
assetPointerDragRef,
canAccessProtectedData,
openEditorLoginModal,
onDeleteAssets,
onOpenSelectionMode,
}: {
assetListRef: RefObject<HTMLDivElement | null>;
assetPointerDragRef: RefObject<AssetPointerDragState | null>;
canAccessProtectedData: boolean;
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
onDeleteAssets?: (assets: EditorAsset[]) => boolean | void;
onOpenSelectionMode?: () => void;
}) {
const [assetFolders, setAssetFolders] =
useState<EditorAssetFolder[]>(EDITOR_ASSET_FOLDERS);
@@ -125,6 +129,14 @@ export function useImageCanvasAssetLibrary({
setRenamingAsset(null);
setRenamingFolder(null);
}, []);
const prepareAssetSelectionMode = useCallback(() => {
clearAssetRenameState();
assetPointerDragRef.current = null;
setAssetPointerDrag(null);
setAssetMoveDropFolderId(null);
setPinnedAssetMoveFolderId(null);
onOpenSelectionMode?.();
}, [assetPointerDragRef, clearAssetRenameState, onOpenSelectionMode]);
const {
selectableAssets,
selectedAssets,
@@ -144,7 +156,7 @@ export function useImageCanvasAssetLibrary({
} = useImageCanvasAssetSelection({
assetListRef,
assets,
onOpenSelectionMode: clearAssetRenameState,
onOpenSelectionMode: prepareAssetSelectionMode,
});
const applyAssetLibrarySnapshot = useCallback(
@@ -469,20 +481,27 @@ export function useImageCanvasAssetLibrary({
[assetFolders],
);
const deleteSelectedAssets = useCallback(() => {
const ids = selectedAssets.map((asset) => asset.id);
if (onDeleteAssets?.(selectedAssets) === false) {
return;
}
setAssets(
(currentAssets) =>
removeSelectedAssets(currentAssets, selectedAssetIds).assets,
);
clearAssetSelection();
ids.forEach((assetId) => {
void deleteEditorAsset(assetId);
});
}, [clearAssetSelection, onDeleteAssets, selectedAssetIds, selectedAssets]);
const deleteSelectedAssets = useCallback(
(requestedAssetIds?: ReadonlySet<string>) => {
const targetAssetIds = new Set(requestedAssetIds ?? selectedAssetIds);
const targetAssets = assets.filter((asset) =>
targetAssetIds.has(asset.id),
);
const ids = targetAssets.map((asset) => asset.id);
if (onDeleteAssets?.(targetAssets) === false) {
return;
}
setAssets(
(currentAssets) =>
removeSelectedAssets(currentAssets, targetAssetIds).assets,
);
clearAssetSelection();
ids.forEach((assetId) => {
void deleteEditorAsset(assetId);
});
},
[assets, clearAssetSelection, onDeleteAssets, selectedAssetIds],
);
const moveAssetToFolder = useCallback(
(assetId: string, folderId: string) => {
@@ -22,14 +22,9 @@ type AssetHitTarget = {
rect: ClientRectLike;
};
export type AssetSelectionPointerKind = 'keyboard' | 'mouse' | 'pen' | 'touch';
export type AssetSelectionInteraction = {
visibleAssetIds: string[];
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
pointerKind?: AssetSelectionPointerKind;
};
type AssetSelectionState = {