修复素材多选与导出边界问题
修复触摸滚动、滚动框选坐标和抬手/取消语义 统一不可选素材行的交互与无障碍状态 支持仅帧来源的序列帧导出并统一导出互斥锁 补充定向回归测试并同步素材选择与导出文档
This commit is contained in:
@@ -80,6 +80,9 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
|
||||
onAssetMarqueePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onAssetMarqueePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onAssetMarqueePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onAssetMarqueePointerCancel: (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => void;
|
||||
onAssetMarqueeLostPointerCapture: (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => void;
|
||||
@@ -139,6 +142,7 @@ export function ImageCanvasAssetLibraryPanelView({
|
||||
onAssetMarqueePointerDown,
|
||||
onAssetMarqueePointerMove,
|
||||
onAssetMarqueePointerUp,
|
||||
onAssetMarqueePointerCancel,
|
||||
onAssetMarqueeLostPointerCapture,
|
||||
updateAssetMoveDropFolder,
|
||||
addUploadedFiles,
|
||||
@@ -194,7 +198,7 @@ export function ImageCanvasAssetLibraryPanelView({
|
||||
onPointerDown={onAssetMarqueePointerDown}
|
||||
onPointerMove={onAssetMarqueePointerMove}
|
||||
onPointerUp={onAssetMarqueePointerUp}
|
||||
onPointerCancel={onAssetMarqueePointerUp}
|
||||
onPointerCancel={onAssetMarqueePointerCancel}
|
||||
onLostPointerCapture={onAssetMarqueeLostPointerCapture}
|
||||
>
|
||||
{pinnedAssetMoveFolderId ? (
|
||||
|
||||
@@ -230,6 +230,22 @@ describe('ImageCanvasAssetRowView', () => {
|
||||
).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 modifier keys to selection', () => {
|
||||
const toggleAssetSelected = vi.fn();
|
||||
renderAssetRow({ isAssetSelectionMode: true, toggleAssetSelected });
|
||||
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
getCanvasMediaPreviewClassName,
|
||||
getCanvasMediaPreviewMarker,
|
||||
} from './ImageCanvasMediaModel';
|
||||
import type { AssetSelectionInteraction } from './useImageCanvasAssetSelection';
|
||||
import {
|
||||
type AssetSelectionInteraction,
|
||||
isSelectableAsset,
|
||||
} from './useImageCanvasAssetSelection';
|
||||
|
||||
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
|
||||
|
||||
@@ -96,6 +99,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;
|
||||
@@ -119,8 +123,7 @@ export function ImageCanvasAssetRowView({
|
||||
selectionPointerKindRef.current = 'keyboard';
|
||||
if (
|
||||
!isAssetSelectionMode ||
|
||||
isUploadingAsset ||
|
||||
isFailedUpload ||
|
||||
!canSelectAsset ||
|
||||
suppressAssetClickRef.current
|
||||
) {
|
||||
return;
|
||||
@@ -136,10 +139,9 @@ export function ImageCanvasAssetRowView({
|
||||
},
|
||||
[
|
||||
asset.id,
|
||||
canSelectAsset,
|
||||
closeMenu,
|
||||
isAssetSelectionMode,
|
||||
isFailedUpload,
|
||||
isUploadingAsset,
|
||||
suppressAssetClickRef,
|
||||
toggleAssetSelected,
|
||||
visibleAssetIds,
|
||||
@@ -316,9 +318,12 @@ export function ImageCanvasAssetRowView({
|
||||
: isFailedUpload
|
||||
? `上传失败${asset.label}`
|
||||
: isAssetSelectionMode
|
||||
? `选择素材${asset.label}`
|
||||
? canSelectAsset
|
||||
? `选择素材${asset.label}`
|
||||
: `素材${asset.label}不可选择`
|
||||
: `添加${asset.label}`
|
||||
}
|
||||
primaryDisabled={isAssetSelectionMode && !canSelectAsset}
|
||||
onPrimaryClick={() => {
|
||||
if (isUploadingAsset || isFailedUpload) {
|
||||
return;
|
||||
|
||||
@@ -63,6 +63,7 @@ export type SidebarMediaItemProps = {
|
||||
imageAlt: string;
|
||||
selected?: boolean;
|
||||
primaryLabel: string;
|
||||
primaryDisabled?: boolean;
|
||||
onPrimaryClick: MouseEventHandler<HTMLButtonElement>;
|
||||
thumbnailClassName: string;
|
||||
metaClassName: 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}
|
||||
|
||||
@@ -82,6 +82,7 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
|
||||
onAssetMarqueePointerDown: vi.fn(),
|
||||
onAssetMarqueePointerMove: vi.fn(),
|
||||
onAssetMarqueePointerUp: vi.fn(),
|
||||
onAssetMarqueePointerCancel: vi.fn(),
|
||||
onAssetMarqueeLostPointerCapture: vi.fn(),
|
||||
updateAssetMoveDropFolder: vi.fn(),
|
||||
addUploadedFiles: vi.fn(),
|
||||
|
||||
@@ -743,6 +743,7 @@ export function ImageCanvasEditorView({
|
||||
handleAssetMarqueePointerDown,
|
||||
handleAssetMarqueePointerMove,
|
||||
handleAssetMarqueePointerUp,
|
||||
handleAssetMarqueePointerCancel,
|
||||
handleAssetMarqueeLostPointerCapture,
|
||||
refreshAssetLibrary,
|
||||
} = useImageCanvasAssetLibrary({
|
||||
@@ -2480,6 +2481,7 @@ export function ImageCanvasEditorView({
|
||||
onAssetMarqueePointerDown: handleAssetMarqueePointerDown,
|
||||
onAssetMarqueePointerMove: handleAssetMarqueePointerMove,
|
||||
onAssetMarqueePointerUp: handleAssetMarqueePointerUp,
|
||||
onAssetMarqueePointerCancel: handleAssetMarqueePointerCancel,
|
||||
onAssetMarqueeLostPointerCapture: handleAssetMarqueeLostPointerCapture,
|
||||
updateAssetMoveDropFolder,
|
||||
addUploadedFiles,
|
||||
|
||||
@@ -101,6 +101,7 @@ function createSidebarProps(
|
||||
onAssetMarqueePointerDown: vi.fn(),
|
||||
onAssetMarqueePointerMove: vi.fn(),
|
||||
onAssetMarqueePointerUp: vi.fn(),
|
||||
onAssetMarqueePointerCancel: vi.fn(),
|
||||
onAssetMarqueeLostPointerCapture: vi.fn(),
|
||||
updateAssetMoveDropFolder: vi.fn(),
|
||||
addUploadedFiles: vi.fn(),
|
||||
|
||||
@@ -76,6 +76,9 @@ export type ImageCanvasSidebarViewProps = {
|
||||
onAssetMarqueePointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onAssetMarqueePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onAssetMarqueePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onAssetMarqueePointerCancel: (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => void;
|
||||
onAssetMarqueeLostPointerCapture: (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => void;
|
||||
@@ -151,6 +154,7 @@ export function ImageCanvasSidebarView({
|
||||
onAssetMarqueePointerDown,
|
||||
onAssetMarqueePointerMove,
|
||||
onAssetMarqueePointerUp,
|
||||
onAssetMarqueePointerCancel,
|
||||
onAssetMarqueeLostPointerCapture,
|
||||
updateAssetMoveDropFolder,
|
||||
addUploadedFiles,
|
||||
@@ -339,6 +343,7 @@ export function ImageCanvasSidebarView({
|
||||
onAssetMarqueePointerDown={onAssetMarqueePointerDown}
|
||||
onAssetMarqueePointerMove={onAssetMarqueePointerMove}
|
||||
onAssetMarqueePointerUp={onAssetMarqueePointerUp}
|
||||
onAssetMarqueePointerCancel={onAssetMarqueePointerCancel}
|
||||
onAssetMarqueeLostPointerCapture={onAssetMarqueeLostPointerCapture}
|
||||
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
|
||||
addUploadedFiles={addUploadedFiles}
|
||||
|
||||
@@ -74,6 +74,15 @@ function ExportWorkflowHarness({ layers }: { layers: CanvasLayer[] }) {
|
||||
>
|
||||
连续导出画布素材
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void workflow.exportCanvasAssets();
|
||||
void workflow.exportLayerImage(layers[0] ?? null);
|
||||
}}
|
||||
>
|
||||
同时导出画布和单图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => workflow.exportLayerImage(layers[0] ?? null)}
|
||||
@@ -301,6 +310,115 @@ describe('useImageCanvasAssetExportWorkflow', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the same mutex for collection and direct layer exports', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let resolveFetch: (response: Response) => void = () => {};
|
||||
const fetchMock = vi.fn(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 'blob:shared-mutex-export'),
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
render(
|
||||
<ExportWorkflowHarness
|
||||
layers={[createLayer('slow-export', { src: '/slow-export.png' })]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '同时导出画布和单图' }),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
resolveFetch(new Response(new Blob(['image'], { type: 'image/png' })));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('status').textContent).toBe(
|
||||
'success:画布素材已导出',
|
||||
);
|
||||
});
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
|
||||
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
|
||||
}
|
||||
});
|
||||
|
||||
it('exports frame-only image sequences from selected and canvas entry points', async () => {
|
||||
const downloads: Array<{ blob: Blob; name: string }> = [];
|
||||
let currentBlob: Blob | null = null;
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn((blob: Blob) => {
|
||||
currentBlob = blob;
|
||||
return `blob:frame-only-${downloads.length}`;
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
|
||||
function click(this: HTMLAnchorElement) {
|
||||
if (currentBlob) {
|
||||
downloads.push({ blob: currentBlob, name: this.download });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
render(
|
||||
<ExportWorkflowHarness
|
||||
layers={[
|
||||
createLayer('frame-only-sequence', {
|
||||
title: '仅帧序列',
|
||||
src: '',
|
||||
objectKey: null,
|
||||
mediaType: 'image-sequence',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
imageSrc: 'data:image/png;base64,c2VxdWVuY2U=',
|
||||
width: 100,
|
||||
height: 80,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载一个选中素材' }));
|
||||
await waitFor(() => expect(downloads).toHaveLength(1));
|
||||
expect(downloads[0]?.name).toMatch(
|
||||
/^仅帧序列-SpineJSON-\d{8}-\d{6}\.zip$/u,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出画布素材' }));
|
||||
await waitFor(() => expect(downloads).toHaveLength(2));
|
||||
const zip = await JSZip.loadAsync(downloads[1]!.blob);
|
||||
expect(
|
||||
zip.file(
|
||||
'导出项目-画布素材/sequences/001-仅帧序列/frames/frame-01.png',
|
||||
),
|
||||
).toBeTruthy();
|
||||
} finally {
|
||||
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
|
||||
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
|
||||
}
|
||||
});
|
||||
|
||||
it('reads layers and sequence frames with bounded concurrency and stable zip order', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const pendingResponses = new Map<string, () => void>();
|
||||
|
||||
@@ -41,6 +41,15 @@ type UseImageCanvasAssetExportWorkflowOptions = {
|
||||
const GIF_PREVIEW_MAX_EDGE = 320;
|
||||
const ASSET_EXPORT_READ_CONCURRENCY = 4;
|
||||
|
||||
function hasReadableLayerExportSource(layer: CanvasLayer) {
|
||||
if (layer.mediaType === 'image-sequence') {
|
||||
return getLayerImageSequenceFrames(layer).some((frame) =>
|
||||
hasReadableAssetSource(frame.imageSrc, frame.objectKey),
|
||||
);
|
||||
}
|
||||
return hasReadableAssetSource(layer.src, layer.objectKey);
|
||||
}
|
||||
|
||||
type AssetExportPlan = {
|
||||
key: string;
|
||||
layer: CanvasLayer;
|
||||
@@ -341,7 +350,7 @@ export function useImageCanvasAssetExportWorkflow({
|
||||
const [isExportingAssets, setIsExportingAssets] = useState(false);
|
||||
const isExportingAssetsRef = useRef(false);
|
||||
|
||||
const exportLayerImage = useCallback(
|
||||
const exportLayerImageUnlocked = useCallback(
|
||||
async (
|
||||
layer: CanvasLayer | null,
|
||||
options: { mode?: ImageSequenceExportMode } = {},
|
||||
@@ -408,6 +417,27 @@ export function useImageCanvasAssetExportWorkflow({
|
||||
[],
|
||||
);
|
||||
|
||||
const exportLayerImage = useCallback(
|
||||
async (
|
||||
layer: CanvasLayer | null,
|
||||
options: { mode?: ImageSequenceExportMode } = {},
|
||||
) => {
|
||||
if (!layer || isExportingAssetsRef.current) {
|
||||
return false;
|
||||
}
|
||||
isExportingAssetsRef.current = true;
|
||||
setIsExportingAssets(true);
|
||||
setAssetExportStatus(null);
|
||||
try {
|
||||
return await exportLayerImageUnlocked(layer, options);
|
||||
} finally {
|
||||
isExportingAssetsRef.current = false;
|
||||
setIsExportingAssets(false);
|
||||
}
|
||||
},
|
||||
[exportLayerImageUnlocked],
|
||||
);
|
||||
|
||||
const exportAssetCollection = useCallback(
|
||||
async ({
|
||||
targetLayers,
|
||||
@@ -424,7 +454,7 @@ export function useImageCanvasAssetExportWorkflow({
|
||||
return;
|
||||
}
|
||||
const exportableLayers = targetLayers
|
||||
.filter((layer) => hasReadableAssetSource(layer.src, layer.objectKey))
|
||||
.filter(hasReadableLayerExportSource)
|
||||
.sort((left, right) => left.zIndex - right.zIndex);
|
||||
if (!exportableLayers.length) {
|
||||
setAssetExportStatus({
|
||||
@@ -764,8 +794,8 @@ export function useImageCanvasAssetExportWorkflow({
|
||||
if (isExportingAssetsRef.current) {
|
||||
return;
|
||||
}
|
||||
const exportableLayers = selectedLayers.filter((layer) =>
|
||||
hasReadableAssetSource(layer.src, layer.objectKey),
|
||||
const exportableLayers = selectedLayers.filter(
|
||||
hasReadableLayerExportSource,
|
||||
);
|
||||
if (!exportableLayers.length) {
|
||||
setAssetExportStatus({
|
||||
@@ -775,22 +805,12 @@ export function useImageCanvasAssetExportWorkflow({
|
||||
return;
|
||||
}
|
||||
if (exportableLayers.length === 1) {
|
||||
isExportingAssetsRef.current = true;
|
||||
setIsExportingAssets(true);
|
||||
setAssetExportStatus(null);
|
||||
try {
|
||||
const downloaded = await exportLayerImage(
|
||||
exportableLayers[0] ?? null,
|
||||
);
|
||||
if (downloaded) {
|
||||
setAssetExportStatus({
|
||||
tone: 'success',
|
||||
message: '选中素材已导出',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
isExportingAssetsRef.current = false;
|
||||
setIsExportingAssets(false);
|
||||
const downloaded = await exportLayerImage(exportableLayers[0] ?? null);
|
||||
if (downloaded) {
|
||||
setAssetExportStatus({
|
||||
tone: 'success',
|
||||
message: '选中素材已导出',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ export function useImageCanvasAssetLibrary({
|
||||
handleAssetMarqueePointerDown,
|
||||
handleAssetMarqueePointerMove,
|
||||
handleAssetMarqueePointerUp,
|
||||
handleAssetMarqueePointerCancel,
|
||||
handleAssetMarqueeLostPointerCapture,
|
||||
} = useImageCanvasAssetSelection({
|
||||
assetListRef,
|
||||
@@ -544,6 +545,7 @@ export function useImageCanvasAssetLibrary({
|
||||
handleAssetMarqueePointerDown,
|
||||
handleAssetMarqueePointerMove,
|
||||
handleAssetMarqueePointerUp,
|
||||
handleAssetMarqueePointerCancel,
|
||||
handleAssetMarqueeLostPointerCapture,
|
||||
refreshAssetLibrary,
|
||||
};
|
||||
|
||||
@@ -115,6 +115,41 @@ describe('useImageCanvasAssetSelection', () => {
|
||||
]).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({
|
||||
@@ -288,6 +323,105 @@ describe('useImageCanvasAssetSelection', () => {
|
||||
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();
|
||||
|
||||
@@ -15,6 +15,8 @@ type ClientPoint = {
|
||||
clientY: number;
|
||||
};
|
||||
|
||||
type ScrollContainerLike = Pick<HTMLElement, 'scrollLeft' | 'scrollTop'>;
|
||||
|
||||
type AssetHitTarget = {
|
||||
assetId: string;
|
||||
rect: ClientRectLike;
|
||||
@@ -35,16 +37,19 @@ type AssetSelectionState = {
|
||||
anchorAssetId: string | null;
|
||||
};
|
||||
|
||||
export function getSelectableAssets(assets: EditorAsset[]) {
|
||||
return assets.filter(
|
||||
(asset) =>
|
||||
asset.sourceKind === 'uploaded' &&
|
||||
asset.uploadStatus !== 'uploading' &&
|
||||
asset.uploadStatus !== 'failed' &&
|
||||
hasReadableAssetSource(asset.src, asset.objectKey),
|
||||
export function isSelectableAsset(asset: EditorAsset) {
|
||||
return (
|
||||
asset.sourceKind === 'uploaded' &&
|
||||
asset.uploadStatus !== 'uploading' &&
|
||||
asset.uploadStatus !== 'failed' &&
|
||||
hasReadableAssetSource(asset.src, asset.objectKey)
|
||||
);
|
||||
}
|
||||
|
||||
export function getSelectableAssets(assets: EditorAsset[]) {
|
||||
return assets.filter(isSelectableAsset);
|
||||
}
|
||||
|
||||
export function countHiddenSelectedAssets(
|
||||
selectedAssetIds: Set<string>,
|
||||
visibleAssetIds: string[],
|
||||
@@ -79,13 +84,17 @@ export function createAssetMarqueeFromPointer({
|
||||
pointerId,
|
||||
point,
|
||||
containerRect,
|
||||
container,
|
||||
}: {
|
||||
pointerId: number;
|
||||
point: ClientPoint;
|
||||
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
|
||||
container?: ScrollContainerLike | null;
|
||||
}): AssetMarqueeState {
|
||||
const startX = point.clientX - (containerRect?.left ?? 0);
|
||||
const startY = point.clientY - (containerRect?.top ?? 0);
|
||||
const startX =
|
||||
point.clientX - (containerRect?.left ?? 0) + (container?.scrollLeft ?? 0);
|
||||
const startY =
|
||||
point.clientY - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0);
|
||||
return {
|
||||
pointerId,
|
||||
startX,
|
||||
@@ -99,15 +108,19 @@ export function moveAssetMarqueeToPointer({
|
||||
marquee,
|
||||
point,
|
||||
containerRect,
|
||||
container,
|
||||
}: {
|
||||
marquee: AssetMarqueeState;
|
||||
point: ClientPoint;
|
||||
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
|
||||
container?: ScrollContainerLike | null;
|
||||
}) {
|
||||
return {
|
||||
...marquee,
|
||||
currentX: point.clientX - (containerRect?.left ?? 0),
|
||||
currentY: point.clientY - (containerRect?.top ?? 0),
|
||||
currentX:
|
||||
point.clientX - (containerRect?.left ?? 0) + (container?.scrollLeft ?? 0),
|
||||
currentY:
|
||||
point.clientY - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,13 +128,17 @@ export function createAssetMarqueeSelectionRect({
|
||||
marquee,
|
||||
point,
|
||||
containerRect,
|
||||
container,
|
||||
}: {
|
||||
marquee: AssetMarqueeState;
|
||||
point: ClientPoint;
|
||||
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
|
||||
container?: ScrollContainerLike | null;
|
||||
}): ClientRectLike {
|
||||
const startClientX = (containerRect?.left ?? 0) + marquee.startX;
|
||||
const startClientY = (containerRect?.top ?? 0) + marquee.startY;
|
||||
const startClientX =
|
||||
(containerRect?.left ?? 0) + marquee.startX - (container?.scrollLeft ?? 0);
|
||||
const startClientY =
|
||||
(containerRect?.top ?? 0) + marquee.startY - (container?.scrollTop ?? 0);
|
||||
return {
|
||||
left: Math.min(startClientX, point.clientX),
|
||||
right: Math.max(startClientX, point.clientX),
|
||||
@@ -362,6 +379,7 @@ export function useImageCanvasAssetSelection({
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
event.pointerType === 'touch' ||
|
||||
!shouldStartAssetMarquee({
|
||||
isAssetSelectionMode,
|
||||
button: event.button,
|
||||
@@ -373,12 +391,14 @@ export function useImageCanvasAssetSelection({
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
assetListRef.current?.setPointerCapture?.(event.pointerId);
|
||||
const container = assetListRef.current;
|
||||
container?.setPointerCapture?.(event.pointerId);
|
||||
setAssetMarquee(
|
||||
createAssetMarqueeFromPointer({
|
||||
pointerId: event.pointerId,
|
||||
point: event,
|
||||
containerRect: assetListRef.current?.getBoundingClientRect(),
|
||||
containerRect: container?.getBoundingClientRect(),
|
||||
container,
|
||||
}),
|
||||
);
|
||||
marqueeBaseSelectionRef.current =
|
||||
@@ -399,13 +419,15 @@ export function useImageCanvasAssetSelection({
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const containerRect = assetListRef.current?.getBoundingClientRect();
|
||||
const container = assetListRef.current;
|
||||
const containerRect = container?.getBoundingClientRect();
|
||||
setAssetMarquee((currentMarquee) =>
|
||||
currentMarquee
|
||||
? moveAssetMarqueeToPointer({
|
||||
marquee: currentMarquee,
|
||||
point: event,
|
||||
containerRect,
|
||||
container,
|
||||
})
|
||||
: null,
|
||||
);
|
||||
@@ -414,6 +436,7 @@ export function useImageCanvasAssetSelection({
|
||||
marquee: assetMarquee,
|
||||
point: event,
|
||||
containerRect,
|
||||
container,
|
||||
}),
|
||||
);
|
||||
},
|
||||
@@ -426,11 +449,37 @@ export function useImageCanvasAssetSelection({
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const container = assetListRef.current;
|
||||
updateAssetSelectionFromMarquee(
|
||||
createAssetMarqueeSelectionRect({
|
||||
marquee: assetMarquee,
|
||||
point: event,
|
||||
containerRect: container?.getBoundingClientRect(),
|
||||
container,
|
||||
}),
|
||||
);
|
||||
if (assetListRef.current?.hasPointerCapture?.(event.pointerId)) {
|
||||
assetListRef.current.releasePointerCapture?.(event.pointerId);
|
||||
}
|
||||
setAssetMarquee(null);
|
||||
},
|
||||
[assetListRef, assetMarquee, updateAssetSelectionFromMarquee],
|
||||
);
|
||||
|
||||
const handleAssetMarqueePointerCancel = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
if (assetListRef.current?.hasPointerCapture?.(event.pointerId)) {
|
||||
assetListRef.current.releasePointerCapture?.(event.pointerId);
|
||||
}
|
||||
setAssetSelection({
|
||||
selectedAssetIds: new Set(marqueeBaseSelectionRef.current),
|
||||
anchorAssetId: null,
|
||||
});
|
||||
setAssetMarquee(null);
|
||||
},
|
||||
[assetListRef, assetMarquee],
|
||||
);
|
||||
|
||||
@@ -457,6 +506,7 @@ export function useImageCanvasAssetSelection({
|
||||
handleAssetMarqueePointerDown,
|
||||
handleAssetMarqueePointerMove,
|
||||
handleAssetMarqueePointerUp,
|
||||
handleAssetMarqueePointerCancel,
|
||||
handleAssetMarqueeLostPointerCapture,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user