Files
Genarrative/src/components/image-editor/useImageCanvasAssetSelection.ts
T
2026-07-31 20:04:48 +08:00

452 lines
12 KiB
TypeScript

import type { PointerEvent as ReactPointerEvent, RefObject } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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 AssetHitTarget = {
assetId: string;
rect: ClientRectLike;
};
export type AssetSelectionPointerKind = 'keyboard' | 'mouse' | 'pen' | 'touch';
export type AssetSelectionInteraction = {
visibleAssetIds: string[];
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
pointerKind?: AssetSelectionPointerKind;
};
type AssetSelectionState = {
selectedAssetIds: Set<string>;
anchorAssetId: string | null;
};
export function getSelectableAssets(assets: EditorAsset[]) {
return assets.filter(
(asset) =>
asset.sourceKind === 'uploaded' &&
asset.uploadStatus !== 'uploading' &&
asset.uploadStatus !== 'failed' &&
asset.src.trim().length > 0,
);
}
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,
}: {
pointerId: number;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
}): AssetMarqueeState {
const startX = point.clientX - (containerRect?.left ?? 0);
const startY = point.clientY - (containerRect?.top ?? 0);
return {
pointerId,
startX,
startY,
currentX: startX,
currentY: startY,
};
}
export function moveAssetMarqueeToPointer({
marquee,
point,
containerRect,
}: {
marquee: AssetMarqueeState;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
}) {
return {
...marquee,
currentX: point.clientX - (containerRect?.left ?? 0),
currentY: point.clientY - (containerRect?.top ?? 0),
};
}
export function createAssetMarqueeSelectionRect({
marquee,
point,
containerRect,
}: {
marquee: AssetMarqueeState;
point: ClientPoint;
containerRect: Pick<DOMRect, 'left' | 'top'> | null | undefined;
}): ClientRectLike {
const startClientX = (containerRect?.left ?? 0) + marquee.startX;
const startClientY = (containerRect?.top ?? 0) + marquee.startY;
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;
}) => {
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 (
!shouldStartAssetMarquee({
isAssetSelectionMode,
button: event.button,
isBlockedTarget: Boolean(
target.closest(ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR),
),
})
) {
return;
}
event.preventDefault();
assetListRef.current?.setPointerCapture?.(event.pointerId);
setAssetMarquee(
createAssetMarqueeFromPointer({
pointerId: event.pointerId,
point: event,
containerRect: assetListRef.current?.getBoundingClientRect(),
}),
);
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 containerRect = assetListRef.current?.getBoundingClientRect();
setAssetMarquee((currentMarquee) =>
currentMarquee
? moveAssetMarqueeToPointer({
marquee: currentMarquee,
point: event,
containerRect,
})
: null,
);
updateAssetSelectionFromMarquee(
createAssetMarqueeSelectionRect({
marquee: assetMarquee,
point: event,
containerRect,
}),
);
},
[assetListRef, assetMarquee, updateAssetSelectionFromMarquee],
);
const handleAssetMarqueePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (assetListRef.current?.hasPointerCapture?.(event.pointerId)) {
assetListRef.current.releasePointerCapture?.(event.pointerId);
}
setAssetMarquee(null);
},
[assetListRef, assetMarquee],
);
return {
selectableAssets,
selectedAssets,
selectedAssetIds: assetSelection.selectedAssetIds,
isAssetSelectionMode,
assetMarquee,
clearAssetSelection,
openAssetSelectionMode,
closeAssetSelectionMode,
toggleAssetSelected,
toggleAllAssetsSelected,
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
};
}