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; type ClientPoint = { clientX: number; clientY: number; }; type ScrollContainerLike = Pick; type AssetHitTarget = { assetId: string; rect: ClientRectLike; }; export type AssetSelectionInteraction = { visibleAssetIds: string[]; shiftKey?: boolean; }; type AssetSelectionState = { selectedAssetIds: Set; 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, 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 | 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 | 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 | 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(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, 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, 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; assets: EditorAsset[]; onOpenSelectionMode?: () => void; }) { const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false); const [assetSelection, setAssetSelection] = useState( createEmptyAssetSelection, ); const [assetMarquee, setAssetMarquee] = useState( null, ); const marqueeBaseSelectionRef = useRef>(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) => { 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) => { 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) => { 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) => { 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) => { 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, }; }