72acd0ec05
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/120 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
580 lines
13 KiB
TypeScript
580 lines
13 KiB
TypeScript
import { isEditorInternalProcessingModel } from './ImageCanvasEditorModel';
|
|
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(
|
|
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[],
|
|
): 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 resolveSearchableEditorModel(model: string | null | undefined) {
|
|
const normalizedModel = model?.trim();
|
|
return normalizedModel && !isEditorInternalProcessingModel(normalizedModel)
|
|
? normalizedModel
|
|
: undefined;
|
|
}
|
|
|
|
function buildAssetSearchValues(asset: EditorAsset) {
|
|
return [
|
|
asset.label,
|
|
asset.prompt,
|
|
asset.actualPrompt,
|
|
resolveSearchableEditorModel(asset.model),
|
|
asset.taskId,
|
|
asset.assetKind,
|
|
asset.mediaType,
|
|
];
|
|
}
|
|
|
|
function buildLayerSearchValues(layer: CanvasLayer) {
|
|
return [
|
|
layer.title,
|
|
layer.prompt,
|
|
layer.actualPrompt,
|
|
resolveSearchableEditorModel(layer.model),
|
|
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');
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|