498b905bd8
<video src="attachments/2377fc8b-50e4-4919-ba41-5027b957709f" title="2026-07-27 16-15-53.mp4" controls></video> - 实现shift ctrl的范围选扩选 - 实现多选下载(为一个zip) - 原本有搜索和文件夹折叠展开功能, 我的处理是: 只作为方便查找的用途. 最终操作会有"目前一些(比如待删除)元素被隐藏了,请再确认"的提示. result: ```shell root@k88936-t14p: ~/Downloads# unzip 未命名画布-选中素材-20260806-195631.zip Archive: 未命名画布-选中素材-20260806-195631.zip creating: 未命名画布-选中素材/ creating: 未命名画布-选中素材/images/ creating: 未命名画布-选中素材/media/ extracting: 未命名画布-选中素材/media/001-角色动作(原始视频).mp4 creating: 未命名画布-选中素材/sequences/ creating: 未命名画布-选中素材/sequences/002-角色动作/ creating: 未命名画布-选中素材/sequences/002-角色动作/frames/ extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-01.png extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-02.png extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-03.png ... extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-31.png extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-32.png extracting: 未命名画布-选中素材/sequences/002-角色动作/metadata.json extracting: 未命名画布-选中素材/sequences/002-角色动作/skeleton.json extracting: 未命名画布-选中素材/sequences/002-角色动作/README.md extracting: 未命名画布-选中素材/sequences/002-角色动作/manifest.txt extracting: 未命名画布-选中素材/metadata.json extracting: 未命名画布-选中素材/manifest.txt ``` ~~动作的导出存在很大的问题, 在另一个pr #117 解决~~ --------- Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/114 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
471 lines
14 KiB
TypeScript
471 lines
14 KiB
TypeScript
/* @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();
|
|
});
|
|
});
|