aab0b7849b
重写素材库的普通点击、Ctrl/Cmd 追加和 Shift 连续范围选择。 清理退出选择模式后的选中状态,并让整行素材统一响应选择操作。 支持单素材直接下载和多素材 ZIP 下载,文件名精确到秒。 固定批量操作栏并修复移动端素材列表可操作空间。 补充选择、导出、侧栏交互测试以及对应技术文档。
696 lines
16 KiB
TypeScript
696 lines
16 KiB
TypeScript
import type {
|
|
AssetMarqueeState,
|
|
CanvasLayer,
|
|
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[];
|
|
};
|
|
|
|
export const EDITOR_UPLOAD_ASSET_FOLDER_LABEL = '上传素材';
|
|
|
|
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(
|
|
getSelectableAssets(assets).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[],
|
|
): GroupedAssetFolder[] {
|
|
return assetFolders.map((folder) => ({
|
|
...folder,
|
|
assets: assets.filter((asset) => asset.folderId === folder.id),
|
|
}));
|
|
}
|
|
|
|
function normalizeSearchQuery(query: string) {
|
|
return query.trim().toLowerCase();
|
|
}
|
|
|
|
function matchesSearchText(
|
|
query: string,
|
|
values: Array<string | null | undefined>,
|
|
) {
|
|
if (!query) {
|
|
return true;
|
|
}
|
|
return values.some((value) => value?.toLowerCase().includes(query));
|
|
}
|
|
|
|
function startsWithSearchText(
|
|
query: string,
|
|
values: Array<string | null | undefined>,
|
|
) {
|
|
if (!query) {
|
|
return true;
|
|
}
|
|
return values.some((value) => value?.trim().toLowerCase().startsWith(query));
|
|
}
|
|
|
|
function buildAssetSearchValues(asset: EditorAsset) {
|
|
return [
|
|
asset.label,
|
|
asset.prompt,
|
|
asset.actualPrompt,
|
|
asset.model,
|
|
asset.provider,
|
|
asset.taskId,
|
|
asset.assetKind,
|
|
asset.mediaType,
|
|
];
|
|
}
|
|
|
|
function buildLayerSearchValues(layer: CanvasLayer) {
|
|
return [
|
|
layer.title,
|
|
layer.prompt,
|
|
layer.actualPrompt,
|
|
layer.model,
|
|
layer.provider,
|
|
layer.taskId,
|
|
layer.assetKind,
|
|
layer.mediaType,
|
|
];
|
|
}
|
|
|
|
function assetMatchesSearch(asset: EditorAsset, query: string) {
|
|
return matchesSearchText(query, buildAssetSearchValues(asset));
|
|
}
|
|
|
|
function assetStartsWithSearch(asset: EditorAsset, query: string) {
|
|
return startsWithSearchText(query, buildAssetSearchValues(asset));
|
|
}
|
|
|
|
export function layerMatchesSearch(layer: CanvasLayer, query: string) {
|
|
return matchesSearchText(query, buildLayerSearchValues(layer));
|
|
}
|
|
|
|
export function layerStartsWithSearch(layer: CanvasLayer, query: string) {
|
|
return startsWithSearchText(query, buildLayerSearchValues(layer));
|
|
}
|
|
|
|
export function filterGroupedAssetsBySearch(
|
|
groupedAssets: GroupedAssetFolder[],
|
|
query: string,
|
|
) {
|
|
const normalizedQuery = normalizeSearchQuery(query);
|
|
if (!normalizedQuery) {
|
|
return groupedAssets;
|
|
}
|
|
|
|
return groupedAssets
|
|
.map((folder) => {
|
|
const folderMatches =
|
|
startsWithSearchText(normalizedQuery, [folder.label]) ||
|
|
matchesSearchText(normalizedQuery, [folder.label]);
|
|
const prefixAssets = folder.assets.filter((asset) =>
|
|
assetStartsWithSearch(asset, normalizedQuery),
|
|
);
|
|
const matchingAssets = folder.assets.filter((asset) =>
|
|
assetMatchesSearch(asset, normalizedQuery),
|
|
);
|
|
return {
|
|
...folder,
|
|
assets: folderMatches
|
|
? folder.assets
|
|
: [
|
|
...prefixAssets,
|
|
...matchingAssets.filter(
|
|
(asset) =>
|
|
!prefixAssets.some(
|
|
(prefixAsset) => prefixAsset.id === asset.id,
|
|
),
|
|
),
|
|
],
|
|
};
|
|
})
|
|
.filter((folder) => folder.assets.length > 0);
|
|
}
|
|
|
|
export function getSelectableAssets(assets: EditorAsset[]) {
|
|
return assets.filter(
|
|
(asset) =>
|
|
asset.sourceKind === 'uploaded' &&
|
|
asset.uploadStatus !== 'uploading' &&
|
|
asset.uploadStatus !== 'failed' &&
|
|
asset.src.trim().length > 0,
|
|
);
|
|
}
|
|
|
|
export type AssetSelectionPointerKind = 'keyboard' | 'mouse' | 'pen' | 'touch';
|
|
|
|
export type AssetSelectionInteraction = {
|
|
visibleAssetIds: string[];
|
|
shiftKey?: boolean;
|
|
ctrlKey?: boolean;
|
|
metaKey?: boolean;
|
|
pointerKind?: AssetSelectionPointerKind;
|
|
};
|
|
|
|
export type AssetSelectionState = {
|
|
selectedAssetIds: Set<string>;
|
|
anchorAssetId: string | null;
|
|
};
|
|
|
|
function dedupeAssetIds(assetIds: string[]) {
|
|
return Array.from(new Set(assetIds));
|
|
}
|
|
|
|
export function reconcileAssetSelection({
|
|
selectedAssetIds,
|
|
anchorAssetId,
|
|
visibleAssetIds,
|
|
}: AssetSelectionState & { visibleAssetIds: string[] }): AssetSelectionState {
|
|
const visibleIds = new Set(visibleAssetIds);
|
|
const nextSelectedAssetIds = new Set(
|
|
[...selectedAssetIds].filter((assetId) => visibleIds.has(assetId)),
|
|
);
|
|
return {
|
|
selectedAssetIds: nextSelectedAssetIds,
|
|
anchorAssetId:
|
|
anchorAssetId && visibleIds.has(anchorAssetId) ? anchorAssetId : null,
|
|
};
|
|
}
|
|
|
|
export function resolveAssetSelection({
|
|
selectedAssetIds,
|
|
anchorAssetId,
|
|
visibleAssetIds,
|
|
targetAssetId,
|
|
shiftKey = false,
|
|
ctrlKey = false,
|
|
metaKey = false,
|
|
pointerKind = 'mouse',
|
|
}: AssetSelectionState &
|
|
AssetSelectionInteraction & { targetAssetId: string }): AssetSelectionState {
|
|
const orderedAssetIds = dedupeAssetIds(visibleAssetIds);
|
|
const targetIndex = orderedAssetIds.indexOf(targetAssetId);
|
|
if (targetIndex < 0) {
|
|
return reconcileAssetSelection({
|
|
selectedAssetIds,
|
|
anchorAssetId,
|
|
visibleAssetIds: orderedAssetIds,
|
|
});
|
|
}
|
|
|
|
const anchorIndex = anchorAssetId
|
|
? orderedAssetIds.indexOf(anchorAssetId)
|
|
: -1;
|
|
const shouldAppend = ctrlKey || metaKey;
|
|
if (shiftKey && anchorIndex >= 0) {
|
|
const rangeStart = Math.min(anchorIndex, targetIndex);
|
|
const rangeEnd = Math.max(anchorIndex, targetIndex);
|
|
const rangeAssetIds = orderedAssetIds.slice(rangeStart, rangeEnd + 1);
|
|
return {
|
|
selectedAssetIds: shouldAppend
|
|
? new Set([...selectedAssetIds, ...rangeAssetIds])
|
|
: new Set(rangeAssetIds),
|
|
anchorAssetId,
|
|
};
|
|
}
|
|
|
|
if (shouldAppend || pointerKind === 'touch' || pointerKind === 'pen') {
|
|
return {
|
|
selectedAssetIds: toggleAssetSelection(selectedAssetIds, targetAssetId),
|
|
anchorAssetId: targetAssetId,
|
|
};
|
|
}
|
|
|
|
return {
|
|
selectedAssetIds: new Set([targetAssetId]),
|
|
anchorAssetId: targetAssetId,
|
|
};
|
|
}
|
|
|
|
export function resolveVisibleAssetSelectionToggle({
|
|
selectedAssetIds,
|
|
visibleAssetIds,
|
|
}: {
|
|
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 type AssetShowcaseSubmissionState = {
|
|
canSubmit: boolean;
|
|
label: string;
|
|
reason: string | null;
|
|
};
|
|
|
|
function normalizeAssetShowcaseReason(value: string | null | undefined) {
|
|
return value?.trim() || null;
|
|
}
|
|
|
|
export function resolveAssetShowcaseSubmissionState(
|
|
asset: EditorAsset,
|
|
): AssetShowcaseSubmissionState {
|
|
const reviewStatus = asset.showcaseReviewStatus ?? null;
|
|
|
|
if (reviewStatus === 'pending') {
|
|
return {
|
|
canSubmit: false,
|
|
label: '精选审核中',
|
|
reason: '素材正在审核中,请等待审核结果',
|
|
};
|
|
}
|
|
|
|
if (reviewStatus === 'approved') {
|
|
return {
|
|
canSubmit: false,
|
|
label:
|
|
asset.showcaseDisplayEnabled === false
|
|
? '已入选精选,暂未展示'
|
|
: '已入选精选',
|
|
reason:
|
|
asset.showcaseDisplayEnabled === false
|
|
? '素材已通过审核,运营后台开启展示后才会出现在前台'
|
|
: '素材已经入选精选',
|
|
};
|
|
}
|
|
|
|
if (reviewStatus === 'rejected') {
|
|
return {
|
|
canSubmit: false,
|
|
label: '精选未通过',
|
|
reason: '该素材审核未通过,不能重复提交',
|
|
};
|
|
}
|
|
|
|
if (asset.sourceType !== 'generated') {
|
|
return {
|
|
canSubmit: false,
|
|
label: '提交精选审核',
|
|
reason: '只有生成素材可以提交精选审核',
|
|
};
|
|
}
|
|
|
|
if (!asset.persisted) {
|
|
return {
|
|
canSubmit: false,
|
|
label: '提交精选审核',
|
|
reason: '素材保存完成后才能提交精选审核',
|
|
};
|
|
}
|
|
|
|
if (!asset.src.trim()) {
|
|
return {
|
|
canSubmit: false,
|
|
label: '提交精选审核',
|
|
reason: '没有媒体内容的素材不能提交精选审核',
|
|
};
|
|
}
|
|
|
|
return {
|
|
canSubmit: true,
|
|
label: '提交精选审核',
|
|
reason: normalizeAssetShowcaseReason(asset.showcaseSubmitError),
|
|
};
|
|
}
|
|
|
|
export function areAllSelectableAssetsSelected(
|
|
selectableAssets: EditorAsset[],
|
|
selectedAssetIds: Set<string>,
|
|
) {
|
|
return (
|
|
selectableAssets.length > 0 &&
|
|
selectableAssets.every((asset) => selectedAssetIds.has(asset.id))
|
|
);
|
|
}
|
|
|
|
export function renameAssetById(
|
|
assets: EditorAsset[],
|
|
assetId: string,
|
|
label: string,
|
|
) {
|
|
return assets.map((asset) =>
|
|
asset.id === assetId
|
|
? {
|
|
...asset,
|
|
label,
|
|
}
|
|
: asset,
|
|
);
|
|
}
|
|
|
|
export function toggleAssetFolderCollapsed(
|
|
assetFolders: EditorAssetFolder[],
|
|
folderId: string,
|
|
) {
|
|
return assetFolders.map((folder) =>
|
|
folder.id === folderId
|
|
? {
|
|
...folder,
|
|
collapsed: !folder.collapsed,
|
|
}
|
|
: folder,
|
|
);
|
|
}
|
|
|
|
export function createLocalAssetFolder({
|
|
folderId,
|
|
label,
|
|
}: {
|
|
folderId: string;
|
|
label: string;
|
|
}): EditorAssetFolder {
|
|
return {
|
|
id: folderId,
|
|
label,
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
persisted: false,
|
|
};
|
|
}
|
|
|
|
export function replaceLocalAssetFolder({
|
|
folders,
|
|
assets,
|
|
localFolderId,
|
|
persistedFolder,
|
|
}: {
|
|
folders: EditorAssetFolder[];
|
|
assets: EditorAsset[];
|
|
localFolderId: string;
|
|
persistedFolder: {
|
|
folderId: string;
|
|
label: string;
|
|
collapsed: boolean;
|
|
systemDefault: boolean;
|
|
};
|
|
}) {
|
|
return {
|
|
folders: folders.map((folder) =>
|
|
folder.id === localFolderId
|
|
? {
|
|
id: persistedFolder.folderId,
|
|
label: persistedFolder.label,
|
|
collapsed: persistedFolder.collapsed,
|
|
systemDefault: persistedFolder.systemDefault,
|
|
persisted: true,
|
|
}
|
|
: folder,
|
|
),
|
|
assets: assets.map((asset) =>
|
|
asset.folderId === localFolderId
|
|
? {
|
|
...asset,
|
|
folderId: persistedFolder.folderId,
|
|
}
|
|
: asset,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function removeAssetById(assets: EditorAsset[], assetId: string) {
|
|
return assets.filter((asset) => asset.id !== assetId);
|
|
}
|
|
|
|
export function renameAssetFolderById(
|
|
assetFolders: EditorAssetFolder[],
|
|
folderId: string,
|
|
label: string,
|
|
) {
|
|
return assetFolders.map((folder) =>
|
|
folder.id === folderId
|
|
? {
|
|
...folder,
|
|
label,
|
|
}
|
|
: folder,
|
|
);
|
|
}
|
|
|
|
export function resolveDefaultAssetFolder(assetFolders: EditorAssetFolder[]) {
|
|
return (
|
|
assetFolders.find((folder) => folder.systemDefault) ??
|
|
assetFolders[0] ??
|
|
null
|
|
);
|
|
}
|
|
|
|
export function resolveUploadAssetFolder(assetFolders: EditorAssetFolder[]) {
|
|
return (
|
|
assetFolders.find(
|
|
(folder) => folder.label.trim() === EDITOR_UPLOAD_ASSET_FOLDER_LABEL,
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
export function resolvePreferredAssetUploadFolder(
|
|
assetFolders: EditorAssetFolder[],
|
|
) {
|
|
return (
|
|
resolveUploadAssetFolder(assetFolders) ??
|
|
resolveDefaultAssetFolder(assetFolders)
|
|
);
|
|
}
|
|
|
|
export function deleteAssetFolderLocally({
|
|
folders,
|
|
assets,
|
|
folderId,
|
|
defaultFolderId,
|
|
}: {
|
|
folders: EditorAssetFolder[];
|
|
assets: EditorAsset[];
|
|
folderId: string;
|
|
defaultFolderId: string;
|
|
}) {
|
|
return {
|
|
folders: folders.filter((folder) => folder.id !== folderId),
|
|
assets: assets.map((asset) =>
|
|
asset.folderId === folderId
|
|
? {
|
|
...asset,
|
|
folderId: defaultFolderId,
|
|
}
|
|
: asset,
|
|
),
|
|
};
|
|
}
|
|
|
|
export function toggleAssetSelection(
|
|
selectedAssetIds: Set<string>,
|
|
assetId: string,
|
|
) {
|
|
const nextIds = new Set(selectedAssetIds);
|
|
if (nextIds.has(assetId)) {
|
|
nextIds.delete(assetId);
|
|
} else {
|
|
nextIds.add(assetId);
|
|
}
|
|
return nextIds;
|
|
}
|
|
|
|
export function resolveAllAssetSelection({
|
|
allSelectableAssetsSelected,
|
|
selectableAssets,
|
|
}: {
|
|
allSelectableAssetsSelected: boolean;
|
|
selectableAssets: EditorAsset[];
|
|
}) {
|
|
return allSelectableAssetsSelected
|
|
? new Set<string>()
|
|
: new Set(selectableAssets.map((asset) => asset.id));
|
|
}
|
|
|
|
export function removeSelectedAssets(
|
|
assets: EditorAsset[],
|
|
selectedAssetIds: Set<string>,
|
|
) {
|
|
const deletedAssets = assets.filter((asset) =>
|
|
selectedAssetIds.has(asset.id),
|
|
);
|
|
return {
|
|
assets: assets.filter((asset) => !selectedAssetIds.has(asset.id)),
|
|
deletedAssets,
|
|
};
|
|
}
|
|
|
|
export function moveAssetToFolderLocally(
|
|
assets: EditorAsset[],
|
|
assetId: string,
|
|
folderId: string,
|
|
) {
|
|
return assets.map((asset) =>
|
|
asset.id === assetId
|
|
? {
|
|
...asset,
|
|
folderId,
|
|
}
|
|
: asset,
|
|
);
|
|
}
|