抽出素材库框选几何模型

扩展 ImageCanvasAssetLibraryModel 承载文件夹命中和框选几何规则

让素材库 hook 保留 DOM 和后端副作用并调用纯模型

补充素材库几何模型单测覆盖置顶和反向框选

更新 TRACKING.md 记录第四十四执行批次验证
This commit is contained in:
2026-06-17 20:11:47 +08:00
parent 489b0a7743
commit 7dec8b7a66
4 changed files with 397 additions and 82 deletions

View File

@@ -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']);
});
});

View File

@@ -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[],

View File

@@ -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],
);