抽出素材库框选几何模型
扩展 ImageCanvasAssetLibraryModel 承载文件夹命中和框选几何规则 让素材库 hook 保留 DOM 和后端副作用并调用纯模型 补充素材库几何模型单测覆盖置顶和反向框选 更新 TRACKING.md 记录第四十四执行批次验证
This commit is contained in:
@@ -6,18 +6,24 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
areAllSelectableAssetsSelected,
|
||||
createAssetMarqueeFromPointer,
|
||||
createAssetMarqueeSelectionRect,
|
||||
createLocalAssetFolder,
|
||||
deleteAssetFolderLocally,
|
||||
getSelectableAssets,
|
||||
groupAssetsByFolder,
|
||||
moveAssetToFolderLocally,
|
||||
moveAssetMarqueeToPointer,
|
||||
removeAssetById,
|
||||
removeSelectedAssets,
|
||||
renameAssetById,
|
||||
renameAssetFolderById,
|
||||
replaceLocalAssetFolder,
|
||||
resolveAllAssetSelection,
|
||||
resolveAssetFolderIdFromPoint,
|
||||
resolveDefaultAssetFolder,
|
||||
resolvePinnedAssetMoveFolderId,
|
||||
selectUploadedAssetsInRect,
|
||||
toggleAssetFolderCollapsed,
|
||||
toggleAssetSelection,
|
||||
} from './ImageCanvasAssetLibraryModel';
|
||||
@@ -184,4 +190,139 @@ describe('ImageCanvasAssetLibraryModel', () => {
|
||||
moveAssetToFolderLocally([createAsset()], 'asset-a', 'folder-role')[0],
|
||||
).toMatchObject({ folderId: 'folder-role' });
|
||||
});
|
||||
|
||||
it('resolves folder hit targets from pointer coordinates', () => {
|
||||
const folders = [
|
||||
{
|
||||
folderId: 'project',
|
||||
rect: { left: 10, right: 110, top: 10, bottom: 80 },
|
||||
},
|
||||
{
|
||||
folderId: 'folder-role',
|
||||
rect: { left: 10, right: 110, top: 81, bottom: 150 },
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveAssetFolderIdFromPoint({
|
||||
point: { clientX: 40, clientY: 80 },
|
||||
listRect: { left: 0, right: 140, top: 0, bottom: 180 },
|
||||
folders,
|
||||
}),
|
||||
).toBe('project');
|
||||
expect(
|
||||
resolveAssetFolderIdFromPoint({
|
||||
point: { clientX: 5, clientY: 40 },
|
||||
listRect: { left: 10, right: 140, top: 0, bottom: 180 },
|
||||
folders,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveAssetFolderIdFromPoint({
|
||||
point: { clientX: 120, clientY: 40 },
|
||||
listRect: { left: 0, right: 140, top: 0, bottom: 180 },
|
||||
folders,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('pins asset move folder labels only when their header is outside the list viewport', () => {
|
||||
const listRect = { left: 0, right: 120, top: 100, bottom: 240 };
|
||||
|
||||
expect(
|
||||
resolvePinnedAssetMoveFolderId({
|
||||
folderId: 'folder-role',
|
||||
listRect,
|
||||
headerRect: { left: 0, right: 120, top: 40, bottom: 80 },
|
||||
}),
|
||||
).toBe('folder-role');
|
||||
expect(
|
||||
resolvePinnedAssetMoveFolderId({
|
||||
folderId: 'folder-role',
|
||||
listRect,
|
||||
headerRect: { left: 0, right: 120, top: 260, bottom: 300 },
|
||||
}),
|
||||
).toBe('folder-role');
|
||||
expect(
|
||||
resolvePinnedAssetMoveFolderId({
|
||||
folderId: 'folder-role',
|
||||
listRect,
|
||||
headerRect: { left: 0, right: 120, top: 120, bottom: 150 },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolvePinnedAssetMoveFolderId({
|
||||
folderId: null,
|
||||
listRect,
|
||||
headerRect: { left: 0, right: 120, top: 40, bottom: 80 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('updates marquee geometry from pointer coordinates and normalizes reverse drags', () => {
|
||||
const marquee = createAssetMarqueeFromPointer({
|
||||
pointerId: 5,
|
||||
point: { clientX: 180, clientY: 220 },
|
||||
containerRect: { left: 100, top: 200 },
|
||||
});
|
||||
|
||||
expect(marquee).toEqual({
|
||||
pointerId: 5,
|
||||
startX: 80,
|
||||
startY: 20,
|
||||
currentX: 80,
|
||||
currentY: 20,
|
||||
});
|
||||
expect(
|
||||
moveAssetMarqueeToPointer({
|
||||
marquee,
|
||||
point: { clientX: 120, clientY: 205 },
|
||||
containerRect: { left: 100, top: 200 },
|
||||
}),
|
||||
).toMatchObject({ currentX: 20, currentY: 5 });
|
||||
expect(
|
||||
createAssetMarqueeSelectionRect({
|
||||
marquee,
|
||||
point: { clientX: 120, clientY: 205 },
|
||||
containerRect: { left: 100, top: 200 },
|
||||
}),
|
||||
).toEqual({
|
||||
left: 120,
|
||||
right: 180,
|
||||
top: 205,
|
||||
bottom: 220,
|
||||
});
|
||||
});
|
||||
|
||||
it('selects only uploaded assets intersecting the marquee rectangle', () => {
|
||||
const assets = [
|
||||
createAsset({ id: 'asset-a' }),
|
||||
createAsset({ id: 'asset-b' }),
|
||||
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
|
||||
];
|
||||
const selectedIds = selectUploadedAssetsInRect({
|
||||
assets,
|
||||
assetTargets: [
|
||||
{
|
||||
assetId: 'asset-a',
|
||||
rect: { left: 10, right: 20, top: 10, bottom: 20 },
|
||||
},
|
||||
{
|
||||
assetId: 'asset-b',
|
||||
rect: { left: 30, right: 40, top: 30, bottom: 40 },
|
||||
},
|
||||
{
|
||||
assetId: 'built-in',
|
||||
rect: { left: 15, right: 25, top: 15, bottom: 25 },
|
||||
},
|
||||
{
|
||||
assetId: 'missing',
|
||||
rect: { left: 15, right: 25, top: 15, bottom: 25 },
|
||||
},
|
||||
],
|
||||
selectionRect: { left: 20, right: 35, top: 20, bottom: 35 },
|
||||
});
|
||||
|
||||
expect([...selectedIds]).toEqual(['asset-a', 'asset-b']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,178 @@
|
||||
import type {
|
||||
AssetMarqueeState,
|
||||
EditorAsset,
|
||||
EditorAssetFolder,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
type ClientRectLike = Pick<DOMRect, 'bottom' | 'left' | 'right' | 'top'>;
|
||||
|
||||
type ClientPoint = {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
};
|
||||
|
||||
type AssetFolderHitTarget = {
|
||||
folderId: string;
|
||||
rect: ClientRectLike;
|
||||
};
|
||||
|
||||
type AssetHitTarget = {
|
||||
assetId: string;
|
||||
rect: ClientRectLike;
|
||||
};
|
||||
|
||||
export type GroupedAssetFolder = EditorAssetFolder & {
|
||||
assets: EditorAsset[];
|
||||
};
|
||||
|
||||
function isPointInRect(point: ClientPoint, rect: ClientRectLike) {
|
||||
return (
|
||||
point.clientX >= rect.left &&
|
||||
point.clientX <= rect.right &&
|
||||
point.clientY >= rect.top &&
|
||||
point.clientY <= rect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
function doRectsIntersect(rect: ClientRectLike, selectionRect: ClientRectLike) {
|
||||
return (
|
||||
rect.left <= selectionRect.right &&
|
||||
rect.right >= selectionRect.left &&
|
||||
rect.top <= selectionRect.bottom &&
|
||||
rect.bottom >= selectionRect.top
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAssetFolderIdFromPoint({
|
||||
point,
|
||||
listRect,
|
||||
folders,
|
||||
}: {
|
||||
point: ClientPoint;
|
||||
listRect: ClientRectLike | null | undefined;
|
||||
folders: AssetFolderHitTarget[];
|
||||
}) {
|
||||
if (!listRect || !isPointInRect(point, listRect)) {
|
||||
return null;
|
||||
}
|
||||
const matchedFolder = folders.find((folder) =>
|
||||
isPointInRect(point, folder.rect),
|
||||
);
|
||||
return matchedFolder?.folderId ?? null;
|
||||
}
|
||||
|
||||
export function resolvePinnedAssetMoveFolderId({
|
||||
folderId,
|
||||
listRect,
|
||||
headerRect,
|
||||
}: {
|
||||
folderId: string | null;
|
||||
listRect: ClientRectLike | null | undefined;
|
||||
headerRect: ClientRectLike | null | undefined;
|
||||
}) {
|
||||
if (
|
||||
!folderId ||
|
||||
!listRect ||
|
||||
!headerRect ||
|
||||
(headerRect.bottom >= listRect.top && headerRect.top <= listRect.bottom)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return folderId;
|
||||
}
|
||||
|
||||
export 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(
|
||||
assets
|
||||
.filter((asset) => asset.sourceKind === 'uploaded')
|
||||
.map((asset) => asset.id),
|
||||
);
|
||||
return new Set(
|
||||
assetTargets
|
||||
.filter(
|
||||
(target) =>
|
||||
uploadedAssetIds.has(target.assetId) &&
|
||||
doRectsIntersect(target.rect, selectionRect),
|
||||
)
|
||||
.map((target) => target.assetId),
|
||||
);
|
||||
}
|
||||
|
||||
export function groupAssetsByFolder(
|
||||
assetFolders: EditorAssetFolder[],
|
||||
assets: EditorAsset[],
|
||||
|
||||
@@ -25,11 +25,14 @@ import {
|
||||
} from './ImageCanvasEditorModel';
|
||||
import {
|
||||
areAllSelectableAssetsSelected,
|
||||
createAssetMarqueeFromPointer,
|
||||
createAssetMarqueeSelectionRect,
|
||||
createLocalAssetFolder,
|
||||
deleteAssetFolderLocally,
|
||||
getSelectableAssets,
|
||||
groupAssetsByFolder,
|
||||
moveAssetToFolderLocally,
|
||||
moveAssetMarqueeToPointer,
|
||||
removeAssetById,
|
||||
removeSelectedAssets,
|
||||
renameAssetById,
|
||||
@@ -37,6 +40,10 @@ import {
|
||||
replaceLocalAssetFolder,
|
||||
resolveAllAssetSelection,
|
||||
resolveDefaultAssetFolder,
|
||||
resolveAssetFolderIdFromPoint,
|
||||
resolvePinnedAssetMoveFolderId,
|
||||
selectUploadedAssetsInRect,
|
||||
shouldStartAssetMarquee,
|
||||
toggleAssetFolderCollapsed,
|
||||
toggleAssetSelection,
|
||||
} from './ImageCanvasAssetLibraryModel';
|
||||
@@ -55,6 +62,43 @@ function isEditorAuthError(error: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
const ASSET_FOLDER_SELECTOR = '[data-asset-folder-id]';
|
||||
const ASSET_ITEM_SELECTOR = '[data-asset-id]';
|
||||
const ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR =
|
||||
'button, input, textarea, select, [data-asset-id]';
|
||||
|
||||
function readAssetFolderHitTargets(listElement: ParentNode | null) {
|
||||
return [
|
||||
...(listElement?.querySelectorAll<HTMLElement>(ASSET_FOLDER_SELECTOR) ?? []),
|
||||
]
|
||||
.map((element) => {
|
||||
const folderId = element.dataset.assetFolderId;
|
||||
return folderId
|
||||
? {
|
||||
folderId,
|
||||
rect: element.getBoundingClientRect(),
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter((target): target is NonNullable<typeof target> => Boolean(target));
|
||||
}
|
||||
|
||||
function readAssetHitTargets(listElement: ParentNode | null) {
|
||||
return [
|
||||
...(listElement?.querySelectorAll<HTMLElement>(ASSET_ITEM_SELECTOR) ?? []),
|
||||
]
|
||||
.map((element) => {
|
||||
const assetId = element.dataset.assetId;
|
||||
return assetId
|
||||
? {
|
||||
assetId,
|
||||
rect: element.getBoundingClientRect(),
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter((target): target is NonNullable<typeof target> => Boolean(target));
|
||||
}
|
||||
|
||||
export function useImageCanvasAssetLibrary({
|
||||
assetListRef,
|
||||
canAccessProtectedData,
|
||||
@@ -145,28 +189,11 @@ export function useImageCanvasAssetLibrary({
|
||||
if (!listElement) {
|
||||
return null;
|
||||
}
|
||||
const listRect = listElement.getBoundingClientRect();
|
||||
if (
|
||||
clientX < listRect.left ||
|
||||
clientX > listRect.right ||
|
||||
clientY < listRect.top ||
|
||||
clientY > listRect.bottom
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const folderElements = [
|
||||
...listElement.querySelectorAll<HTMLElement>('[data-asset-folder-id]'),
|
||||
];
|
||||
const matchedFolder = folderElements.find((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
);
|
||||
return resolveAssetFolderIdFromPoint({
|
||||
point: { clientX, clientY },
|
||||
listRect: listElement.getBoundingClientRect(),
|
||||
folders: readAssetFolderHitTargets(listElement),
|
||||
});
|
||||
return matchedFolder?.dataset.assetFolderId ?? null;
|
||||
},
|
||||
[assetListRef],
|
||||
);
|
||||
@@ -182,14 +209,12 @@ export function useImageCanvasAssetLibrary({
|
||||
const header = listElement?.querySelector<HTMLElement>(
|
||||
`[data-asset-folder-header-id="${escapeCssIdentifier(folderId)}"]`,
|
||||
);
|
||||
const listRect = listElement?.getBoundingClientRect();
|
||||
const headerRect = header?.getBoundingClientRect();
|
||||
setPinnedAssetMoveFolderId(
|
||||
listRect &&
|
||||
headerRect &&
|
||||
(headerRect.bottom < listRect.top || headerRect.top > listRect.bottom)
|
||||
? folderId
|
||||
: null,
|
||||
resolvePinnedAssetMoveFolderId({
|
||||
folderId,
|
||||
listRect: listElement?.getBoundingClientRect(),
|
||||
headerRect: header?.getBoundingClientRect(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
[assetListRef],
|
||||
@@ -418,56 +443,40 @@ export function useImageCanvasAssetLibrary({
|
||||
top: number;
|
||||
bottom: number;
|
||||
}) => {
|
||||
const nextSelectedIds = new Set<string>();
|
||||
assetListRef.current
|
||||
?.querySelectorAll<HTMLElement>('[data-asset-id]')
|
||||
.forEach((element) => {
|
||||
const assetId = element.dataset.assetId;
|
||||
if (!assetId) {
|
||||
return;
|
||||
}
|
||||
const asset = assets.find(
|
||||
(currentAsset) => currentAsset.id === assetId,
|
||||
);
|
||||
if (!asset || asset.sourceKind !== 'uploaded') {
|
||||
return;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const intersects =
|
||||
rect.left <= selectionRect.right &&
|
||||
rect.right >= selectionRect.left &&
|
||||
rect.top <= selectionRect.bottom &&
|
||||
rect.bottom >= selectionRect.top;
|
||||
if (intersects) {
|
||||
nextSelectedIds.add(assetId);
|
||||
}
|
||||
});
|
||||
setSelectedAssetIds(nextSelectedIds);
|
||||
setSelectedAssetIds(
|
||||
selectUploadedAssetsInRect({
|
||||
assets,
|
||||
assetTargets: readAssetHitTargets(assetListRef.current),
|
||||
selectionRect,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[assetListRef, assets],
|
||||
);
|
||||
|
||||
const handleAssetMarqueePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!isAssetSelectionMode || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('button, input, textarea, select, [data-asset-id]')) {
|
||||
if (
|
||||
!shouldStartAssetMarquee({
|
||||
isAssetSelectionMode,
|
||||
button: event.button,
|
||||
isBlockedTarget: Boolean(
|
||||
target.closest(ASSET_MARQUEE_BLOCKED_TARGET_SELECTOR),
|
||||
),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
assetListRef.current?.setPointerCapture?.(event.pointerId);
|
||||
const rect = assetListRef.current?.getBoundingClientRect();
|
||||
const startX = event.clientX - (rect?.left ?? 0);
|
||||
const startY = event.clientY - (rect?.top ?? 0);
|
||||
setAssetMarquee({
|
||||
pointerId: event.pointerId,
|
||||
startX,
|
||||
startY,
|
||||
currentX: startX,
|
||||
currentY: startY,
|
||||
});
|
||||
setAssetMarquee(
|
||||
createAssetMarqueeFromPointer({
|
||||
pointerId: event.pointerId,
|
||||
point: event,
|
||||
containerRect: assetListRef.current?.getBoundingClientRect(),
|
||||
}),
|
||||
);
|
||||
setSelectedAssetIds(new Set());
|
||||
},
|
||||
[assetListRef, isAssetSelectionMode],
|
||||
@@ -480,25 +489,22 @@ export function useImageCanvasAssetLibrary({
|
||||
}
|
||||
event.preventDefault();
|
||||
const containerRect = assetListRef.current?.getBoundingClientRect();
|
||||
const currentX = event.clientX - (containerRect?.left ?? 0);
|
||||
const currentY = event.clientY - (containerRect?.top ?? 0);
|
||||
const startClientX = (containerRect?.left ?? 0) + assetMarquee.startX;
|
||||
const startClientY = (containerRect?.top ?? 0) + assetMarquee.startY;
|
||||
setAssetMarquee((currentMarquee) =>
|
||||
currentMarquee
|
||||
? {
|
||||
...currentMarquee,
|
||||
currentX,
|
||||
currentY,
|
||||
}
|
||||
? moveAssetMarqueeToPointer({
|
||||
marquee: currentMarquee,
|
||||
point: event,
|
||||
containerRect,
|
||||
})
|
||||
: null,
|
||||
);
|
||||
updateAssetSelectionFromMarquee({
|
||||
left: Math.min(startClientX, event.clientX),
|
||||
right: Math.max(startClientX, event.clientX),
|
||||
top: Math.min(startClientY, event.clientY),
|
||||
bottom: Math.max(startClientY, event.clientY),
|
||||
});
|
||||
updateAssetSelectionFromMarquee(
|
||||
createAssetMarqueeSelectionRect({
|
||||
marquee: assetMarquee,
|
||||
point: event,
|
||||
containerRect,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[assetListRef, assetMarquee, updateAssetSelectionFromMarquee],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user