修复素材多选与导出边界问题

修复触摸滚动、滚动框选坐标和抬手/取消语义
统一不可选素材行的交互与无障碍状态
支持仅帧来源的序列帧导出并统一导出互斥锁
补充定向回归测试并同步素材选择与导出文档
This commit is contained in:
2026-08-07 12:11:17 +08:00
parent ef42374b44
commit d1a9b36104
16 changed files with 408 additions and 47 deletions
@@ -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,
};
}