2da3dc0544
更新行为文档 补充测试
452 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|