原子维护素材选择与锚点

将选中素材集合和范围锚点合并到同一个状态

让单击、全选、可见协调、框选和模式切换返回纯状态转移

新增可见范围协调后 Shift 选择的回归测试
This commit is contained in:
2026-07-27 16:56:32 +08:00
parent 35cb6888f9
commit 1a216501a9
2 changed files with 76 additions and 35 deletions
@@ -215,6 +215,14 @@ function AssetLibraryHarness({
>
toggle all
</button>
<button
type="button"
onClick={() =>
assetLibrary.reconcileSelectedAssets(['asset-b', 'asset-c'])
}
>
reconcile to b c
</button>
<button type="button" onClick={assetLibrary.deleteSelectedAssets}>
delete selected
</button>
@@ -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(<AssetLibraryHarness />);
fireEvent.click(await screen.findByRole('button', { name: '素材A' }));
fireEvent.click(screen.getByRole('button', { name: 'reconcile to b c' }));
fireEvent.click(screen.getByRole('button', { name: '素材C' }), {
shiftKey: true,
});
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-c');
});
it('submits a generated asset to showcase review', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
@@ -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<Set<string>>(
() => new Set(),
const [assetSelection, setAssetSelection] = useState<AssetSelectionState>(
createEmptyAssetSelection,
);
const assetSelectionAnchorRef = useRef<string | null>(null);
const { selectedAssetIds } = assetSelection;
const marqueeBaseSelectionRef = useRef<Set<string>>(new Set());
const [assetMarquee, setAssetMarquee] = useState<AssetMarqueeState | null>(
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],
);