Files
Genarrative/src/components/image-editor/useImageCanvasAssetSelection.ts
T
k88936 498b905bd8
Project CI / Repository checks (push) Successful in 1m21s
Project CI / Frontend tests (push) Successful in 3m9s
Project CI / Backend tests (push) Successful in 4m2s
Project CI / Native shell tests (push) Successful in 15m48s
优化素材库多选操作 (#114)
<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>
2026-08-08 11:11:25 +08:00

509 lines
14 KiB
TypeScript

import type { PointerEvent as ReactPointerEvent, RefObject } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { hasReadableAssetSource } from '../../services/assetReadUrlService';
import type { AssetMarqueeState, EditorAsset } from './ImageCanvasEditorTypes';
const ASSET_ITEM_SELECTOR = '[data-asset-id]';
const ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR =
'button, input, textarea, select, [data-asset-id]';
type ClientRectLike = Pick<DOMRect, 'bottom' | 'left' | 'right' | 'top'>;
type ClientPoint = {
clientX: number;
clientY: number;
};
type ScrollContainerLike = Pick<HTMLElement, 'scrollLeft' | 'scrollTop'>;
type AssetHitTarget = {
assetId: string;
rect: ClientRectLike;
};
export type AssetSelectionInteraction = {
visibleAssetIds: string[];
shiftKey?: boolean;
};
type AssetSelectionState = {
selectedAssetIds: Set<string>;
anchorAssetId: string | null;
};
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[],
) {
const visibleIds = new Set(visibleAssetIds);
return [...selectedAssetIds].filter((assetId) => !visibleIds.has(assetId))
.length;
}
function doRectsIntersect(rect: ClientRectLike, selectionRect: ClientRectLike) {
return (
rect.left <= selectionRect.right &&
rect.right >= selectionRect.left &&
rect.top <= selectionRect.bottom &&
rect.bottom >= selectionRect.top
);
}
function shouldStartAssetMarquee({
isAssetSelectionMode,
button,
isBlockedTarget,
}: {
isAssetSelectionMode: boolean;
button: number;
isBlockedTarget: boolean;
}) {
return isAssetSelectionMode && button === 0 && !isBlockedTarget;
}
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) + (container?.scrollLeft ?? 0);
const startY =
point.clientY - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0);
return {
pointerId,
startX,
startY,
currentX: startX,
currentY: startY,
};
}
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) + (container?.scrollLeft ?? 0),
currentY:
point.clientY - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0),
};
}
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 - (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),
top: Math.min(startClientY, point.clientY),
bottom: Math.max(startClientY, point.clientY),
};
}
export function selectUploadedAssetsInRect({
assets,
assetTargets,
selectionRect,
}: {
assets: EditorAsset[];
assetTargets: AssetHitTarget[];
selectionRect: ClientRectLike;
}) {
const uploadedAssetIds = new Set(
getSelectableAssets(assets).map((asset) => asset.id),
);
return new Set(
assetTargets
.filter(
(target) =>
uploadedAssetIds.has(target.assetId) &&
doRectsIntersect(target.rect, selectionRect),
)
.map((target) => target.assetId),
);
}
function createEmptyAssetSelection(): AssetSelectionState {
return {
selectedAssetIds: new Set(),
anchorAssetId: null,
};
}
function readAssetHitTargets(listElement: ParentNode | null) {
return [
...(listElement?.querySelectorAll<HTMLElement>(ASSET_ITEM_SELECTOR) ?? []),
].flatMap((element) => {
const assetId = element.dataset.assetId;
return assetId
? [
{
assetId,
rect: element.getBoundingClientRect(),
},
]
: [];
});
}
function dedupeAssetIds(assetIds: string[]) {
return Array.from(new Set(assetIds));
}
function toggleAssetId(selectedAssetIds: Set<string>, assetId: string) {
const nextIds = new Set(selectedAssetIds);
if (nextIds.has(assetId)) {
nextIds.delete(assetId);
} else {
nextIds.add(assetId);
}
return nextIds;
}
function reconcileAssetSelection(
selection: AssetSelectionState,
selectableAssetIds: string[],
) {
const selectableIds = new Set(selectableAssetIds);
const selectedAssetIds = new Set(
[...selection.selectedAssetIds].filter((assetId) =>
selectableIds.has(assetId),
),
);
const anchorAssetId =
selection.anchorAssetId && selectableIds.has(selection.anchorAssetId)
? selection.anchorAssetId
: null;
if (
anchorAssetId === selection.anchorAssetId &&
selectedAssetIds.size === selection.selectedAssetIds.size
) {
return selection;
}
return { selectedAssetIds, anchorAssetId };
}
function resolveAssetSelection(
selection: AssetSelectionState,
targetAssetId: string,
interaction: AssetSelectionInteraction,
) {
const orderedAssetIds = dedupeAssetIds(interaction.visibleAssetIds);
const targetIndex = orderedAssetIds.indexOf(targetAssetId);
if (targetIndex < 0) {
return selection;
}
const anchorIndex = selection.anchorAssetId
? orderedAssetIds.indexOf(selection.anchorAssetId)
: -1;
if (interaction.shiftKey && anchorIndex >= 0) {
const rangeStart = Math.min(anchorIndex, targetIndex);
const rangeEnd = Math.max(anchorIndex, targetIndex);
return {
selectedAssetIds: new Set([
...selection.selectedAssetIds,
...orderedAssetIds.slice(rangeStart, rangeEnd + 1),
]),
anchorAssetId: selection.anchorAssetId,
};
}
return {
selectedAssetIds: toggleAssetId(selection.selectedAssetIds, targetAssetId),
anchorAssetId: targetAssetId,
};
}
function toggleVisibleAssetIds(
selectedAssetIds: Set<string>,
visibleAssetIds: string[],
) {
const orderedAssetIds = dedupeAssetIds(visibleAssetIds);
const allVisibleAssetsSelected =
orderedAssetIds.length > 0 &&
orderedAssetIds.every((assetId) => selectedAssetIds.has(assetId));
if (allVisibleAssetsSelected) {
const visibleIds = new Set(orderedAssetIds);
return new Set(
[...selectedAssetIds].filter((assetId) => !visibleIds.has(assetId)),
);
}
return new Set([...selectedAssetIds, ...orderedAssetIds]);
}
export function useImageCanvasAssetSelection({
assetListRef,
assets,
onOpenSelectionMode,
}: {
assetListRef: RefObject<HTMLDivElement | null>;
assets: EditorAsset[];
onOpenSelectionMode?: () => void;
}) {
const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false);
const [assetSelection, setAssetSelection] = useState<AssetSelectionState>(
createEmptyAssetSelection,
);
const [assetMarquee, setAssetMarquee] = useState<AssetMarqueeState | null>(
null,
);
const marqueeBaseSelectionRef = useRef<Set<string>>(new Set());
const selectableAssets = useMemo(() => getSelectableAssets(assets), [assets]);
const selectableAssetIds = useMemo(
() => selectableAssets.map((asset) => asset.id),
[selectableAssets],
);
const selectedAssets = useMemo(
() =>
assets.filter((asset) => assetSelection.selectedAssetIds.has(asset.id)),
[assetSelection.selectedAssetIds, assets],
);
useEffect(() => {
if (!isAssetSelectionMode) {
return;
}
setAssetSelection((currentSelection) =>
reconcileAssetSelection(currentSelection, selectableAssetIds),
);
}, [isAssetSelectionMode, selectableAssetIds]);
const clearAssetSelection = useCallback(() => {
setAssetSelection(createEmptyAssetSelection());
marqueeBaseSelectionRef.current = new Set();
setAssetMarquee(null);
}, []);
const openAssetSelectionMode = useCallback(() => {
clearAssetSelection();
onOpenSelectionMode?.();
setIsAssetSelectionMode(true);
}, [clearAssetSelection, onOpenSelectionMode]);
const closeAssetSelectionMode = useCallback(() => {
setIsAssetSelectionMode(false);
clearAssetSelection();
}, [clearAssetSelection]);
const toggleAssetSelected = useCallback(
(assetId: string, interaction: AssetSelectionInteraction) => {
setAssetSelection((currentSelection) =>
resolveAssetSelection(currentSelection, assetId, interaction),
);
},
[],
);
const toggleAllAssetsSelected = useCallback((visibleAssetIds: string[]) => {
setAssetSelection((currentSelection) => ({
selectedAssetIds: toggleVisibleAssetIds(
currentSelection.selectedAssetIds,
visibleAssetIds,
),
anchorAssetId: null,
}));
}, []);
const updateAssetSelectionFromMarquee = useCallback(
(selectionRect: {
left: number;
right: number;
top: number;
bottom: number;
}) => {
// TODO(性能问题): pointermove 会读取全部素材布局并更新 React state;后续需在保留滚动框选、末帧提交和取消回滚语义的前提下,用内容坐标缓存与 rAF 合帧优化。
const marqueeSelection = selectUploadedAssetsInRect({
assets,
assetTargets: readAssetHitTargets(assetListRef.current),
selectionRect,
});
setAssetSelection({
selectedAssetIds: new Set([
...marqueeBaseSelectionRef.current,
...marqueeSelection,
]),
anchorAssetId: null,
});
},
[assetListRef, assets],
);
const handleAssetMarqueePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
if (
event.pointerType === 'touch' ||
!shouldStartAssetMarquee({
isAssetSelectionMode,
button: event.button,
isBlockedTarget: Boolean(
target.closest(ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR),
),
})
) {
return;
}
event.preventDefault();
const container = assetListRef.current;
container?.setPointerCapture?.(event.pointerId);
setAssetMarquee(
createAssetMarqueeFromPointer({
pointerId: event.pointerId,
point: event,
containerRect: container?.getBoundingClientRect(),
container,
}),
);
marqueeBaseSelectionRef.current =
event.ctrlKey || event.metaKey
? new Set(assetSelection.selectedAssetIds)
: new Set();
setAssetSelection({
selectedAssetIds: new Set(marqueeBaseSelectionRef.current),
anchorAssetId: null,
});
},
[assetListRef, assetSelection.selectedAssetIds, isAssetSelectionMode],
);
const handleAssetMarqueePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
const container = assetListRef.current;
const containerRect = container?.getBoundingClientRect();
setAssetMarquee((currentMarquee) =>
currentMarquee
? moveAssetMarqueeToPointer({
marquee: currentMarquee,
point: event,
containerRect,
container,
})
: null,
);
updateAssetSelectionFromMarquee(
createAssetMarqueeSelectionRect({
marquee: assetMarquee,
point: event,
containerRect,
container,
}),
);
},
[assetListRef, assetMarquee, updateAssetSelectionFromMarquee],
);
const handleAssetMarqueePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) {
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],
);
const handleAssetMarqueeLostPointerCapture = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
setAssetMarquee((currentMarquee) =>
currentMarquee?.pointerId === event.pointerId ? null : currentMarquee,
);
},
[],
);
return {
selectableAssets,
selectedAssets,
selectedAssetIds: assetSelection.selectedAssetIds,
isAssetSelectionMode,
assetMarquee,
clearAssetSelection,
openAssetSelectionMode,
closeAssetSelectionMode,
toggleAssetSelected,
toggleAllAssetsSelected,
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
handleAssetMarqueePointerCancel,
handleAssetMarqueeLostPointerCapture,
};
}