diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx
index 3642649b6..a02d1c826 100644
--- a/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx
+++ b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx
@@ -215,6 +215,14 @@ function AssetLibraryHarness({
>
toggle all
+
@@ -518,6 +526,34 @@ describe('useImageCanvasAssetLibrary', () => {
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-c');
});
+ it('reconciles selected ids and their range anchor atomically', 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();
+
+ fireEvent.click(await screen.findByRole('button', { name: '素材A' }));
+ fireEvent.click(screen.getByRole('button', { name: 'reconcile to b c' }));
+ fireEvent.click(screen.getByRole('button', { name: '素材C' }), {
+ shiftKey: true,
+ });
+
+ expect(screen.getByTestId('selected-assets').textContent).toBe('asset-c');
+ });
+
it('submits a generated asset to showcase review', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.ts b/src/components/image-editor/useImageCanvasAssetLibrary.ts
index 63f02a52c..3cd870bb7 100644
--- a/src/components/image-editor/useImageCanvasAssetLibrary.ts
+++ b/src/components/image-editor/useImageCanvasAssetLibrary.ts
@@ -22,6 +22,7 @@ import {
} from '../../services/image-editor/editorProjectClient';
import {
type AssetSelectionInteraction,
+ type AssetSelectionState,
createAssetMarqueeFromPointer,
createAssetMarqueeSelectionRect,
createLocalAssetFolder,
@@ -76,6 +77,13 @@ function formatShowcaseSubmitError(error: unknown) {
return '提交精选审核失败,请稍后重试';
}
+function createEmptyAssetSelection(): AssetSelectionState {
+ return {
+ selectedAssetIds: new Set(),
+ anchorAssetId: null,
+ };
+}
+
const ASSET_FOLDER_SELECTOR = '[data-asset-folder-id]';
const ASSET_ITEM_SELECTOR = '[data-asset-id]';
const ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR =
@@ -140,10 +148,10 @@ export function useImageCanvasAssetLibrary({
const [newFolderName, setNewFolderName] = useState('');
const [activeUploadFolderId, setActiveUploadFolderId] = useState('project');
const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false);
- const [selectedAssetIds, setSelectedAssetIds] = useState>(
- () => new Set(),
+ const [assetSelection, setAssetSelection] = useState(
+ createEmptyAssetSelection,
);
- const assetSelectionAnchorRef = useRef(null);
+ const { selectedAssetIds } = assetSelection;
const marqueeBaseSelectionRef = useRef>(new Set());
const [assetMarquee, setAssetMarquee] = useState(
null,
@@ -485,40 +493,34 @@ export function useImageCanvasAssetLibrary({
const toggleAssetSelected = useCallback(
(assetId: string, interaction: AssetSelectionInteraction) => {
- setSelectedAssetIds((currentIds) => {
- const nextSelection = resolveAssetSelection({
- selectedAssetIds: currentIds,
- anchorAssetId: assetSelectionAnchorRef.current,
+ setAssetSelection((currentSelection) =>
+ resolveAssetSelection({
+ ...currentSelection,
targetAssetId: assetId,
...interaction,
- });
- assetSelectionAnchorRef.current = nextSelection.anchorAssetId;
- return nextSelection.selectedAssetIds;
- });
+ }),
+ );
},
[],
);
const toggleAllAssetsSelected = useCallback((visibleAssetIds: string[]) => {
- assetSelectionAnchorRef.current = null;
- setSelectedAssetIds((currentIds) =>
- resolveVisibleAssetSelectionToggle({
- selectedAssetIds: currentIds,
+ setAssetSelection((currentSelection) => ({
+ selectedAssetIds: resolveVisibleAssetSelectionToggle({
+ selectedAssetIds: currentSelection.selectedAssetIds,
visibleAssetIds,
}),
- );
+ anchorAssetId: null,
+ }));
}, []);
const reconcileSelectedAssets = useCallback((visibleAssetIds: string[]) => {
- setSelectedAssetIds((currentIds) => {
- const nextSelection = reconcileAssetSelection({
- selectedAssetIds: currentIds,
- anchorAssetId: assetSelectionAnchorRef.current,
+ setAssetSelection((currentSelection) =>
+ reconcileAssetSelection({
+ ...currentSelection,
visibleAssetIds,
- });
- assetSelectionAnchorRef.current = nextSelection.anchorAssetId;
- return nextSelection.selectedAssetIds;
- });
+ }),
+ );
}, []);
const deleteSelectedAssets = useCallback(() => {
@@ -532,8 +534,7 @@ export function useImageCanvasAssetLibrary({
removeSelectedAssets(currentAssets, selectedAssetIds).assets,
);
onDeleteAssets?.(deletedAssets);
- setSelectedAssetIds(new Set());
- assetSelectionAnchorRef.current = null;
+ setAssetSelection(createEmptyAssetSelection());
ids.forEach((assetId) => {
void deleteEditorAsset(assetId);
});
@@ -557,18 +558,16 @@ export function useImageCanvasAssetLibrary({
const closeAssetSelectionMode = useCallback(() => {
setIsAssetSelectionMode(false);
- setSelectedAssetIds(new Set());
- assetSelectionAnchorRef.current = null;
+ setAssetSelection(createEmptyAssetSelection());
marqueeBaseSelectionRef.current = new Set();
setAssetMarquee(null);
}, []);
const openAssetSelectionMode = useCallback(() => {
- assetSelectionAnchorRef.current = null;
marqueeBaseSelectionRef.current = new Set();
setRenamingAsset(null);
setRenamingFolder(null);
- setSelectedAssetIds(new Set());
+ setAssetSelection(createEmptyAssetSelection());
setAssetMarquee(null);
setIsAssetSelectionMode(true);
}, []);
@@ -585,9 +584,13 @@ export function useImageCanvasAssetLibrary({
assetTargets: readAssetHitTargets(assetListRef.current),
selectionRect,
});
- setSelectedAssetIds(
- new Set([...marqueeBaseSelectionRef.current, ...marqueeSelection]),
- );
+ setAssetSelection({
+ selectedAssetIds: new Set([
+ ...marqueeBaseSelectionRef.current,
+ ...marqueeSelection,
+ ]),
+ anchorAssetId: null,
+ });
},
[assetListRef, assets],
);
@@ -617,8 +620,10 @@ export function useImageCanvasAssetLibrary({
);
marqueeBaseSelectionRef.current =
event.ctrlKey || event.metaKey ? new Set(selectedAssetIds) : new Set();
- assetSelectionAnchorRef.current = null;
- setSelectedAssetIds(new Set(marqueeBaseSelectionRef.current));
+ setAssetSelection({
+ selectedAssetIds: new Set(marqueeBaseSelectionRef.current),
+ anchorAssetId: null,
+ });
},
[assetListRef, isAssetSelectionMode, selectedAssetIds],
);