Files
Genarrative/src/components/image-editor/useImageCanvasAssetLibrary.ts
kdletters f34556d33d 拆分图片画布图片信息弹窗
新增图片信息弹窗组件,承接 metadata 详情渲染和 UnifiedModal 接入

修复未登录进入编辑器时项目和素材接口抢跑 401

修复重置画布视图点击事件误传导致适合视图报错

补充图片信息弹窗、鉴权门禁和重置按钮回归测试

更新前端拆分文档和 TRACKING 浏览器回归记录
2026-06-17 10:56:51 +08:00

610 lines
17 KiB
TypeScript

import {
type Dispatch,
type PointerEvent as ReactPointerEvent,
type RefObject,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { ApiClientError } from '../../services/apiClient';
import {
createEditorAssetFolder,
deleteEditorAsset,
deleteEditorAssetFolder,
loadEditorAssetLibrary,
updateEditorAsset,
updateEditorAssetFolder,
} from '../../services/image-editor/editorProjectClient';
import {
EDITOR_ASSET_FOLDERS,
escapeCssIdentifier,
normalizeAssetLibrary,
} from './ImageCanvasEditorModel';
import type {
AssetMarqueeState,
AssetPointerDragState,
EditorAsset,
EditorAssetFolder,
} from './ImageCanvasEditorTypes';
export { readImageFileAsDataUrl } from './ImageCanvasFileModel';
function isEditorAuthError(error: unknown) {
return (
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403)
);
}
export function useImageCanvasAssetLibrary({
assetListRef,
canAccessProtectedData,
openEditorLoginModal,
onDeleteAssets,
}: {
assetListRef: RefObject<HTMLDivElement | null>;
canAccessProtectedData: boolean;
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
onDeleteAssets?: (assets: EditorAsset[]) => void;
}) {
const [assetFolders, setAssetFolders] =
useState<EditorAssetFolder[]>(EDITOR_ASSET_FOLDERS);
const [assets, setAssets] = useState<EditorAsset[]>([]);
const [renamingAsset, setRenamingAsset] = useState<{
assetId: string;
value: string;
} | null>(null);
const [renamingFolder, setRenamingFolder] = useState<{
folderId: string;
value: string;
} | null>(null);
const [creatingFolder, setCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [activeUploadFolderId, setActiveUploadFolderId] = useState('project');
const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false);
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(
() => new Set(),
);
const [assetMarquee, setAssetMarquee] = useState<AssetMarqueeState | null>(
null,
);
const [assetPointerDrag, setAssetPointerDrag] =
useState<AssetPointerDragState | null>(null);
const [assetMoveDropFolderId, setAssetMoveDropFolderId] = useState<
string | null
>(null);
const [pinnedAssetMoveFolderId, setPinnedAssetMoveFolderId] = useState<
string | null
>(null);
const groupedAssets = useMemo(
() =>
assetFolders.map((folder) => ({
...folder,
assets: assets.filter((asset) => asset.folderId === folder.id),
})),
[assetFolders, assets],
);
const selectableAssets = useMemo(
() => assets.filter((asset) => asset.sourceKind === 'uploaded'),
[assets],
);
const allSelectableAssetsSelected =
selectableAssets.length > 0 &&
selectableAssets.every((asset) => selectedAssetIds.has(asset.id));
useEffect(() => {
if (!canAccessProtectedData) {
return undefined;
}
let cancelled = false;
loadEditorAssetLibrary()
.then((library) => {
if (cancelled) {
return;
}
const nextLibrary = normalizeAssetLibrary(library);
setAssetFolders(nextLibrary.folders);
setAssets(nextLibrary.assets);
const defaultFolder = nextLibrary.folders.find(
(folder) => folder.systemDefault,
);
setActiveUploadFolderId(
defaultFolder?.id ?? nextLibrary.folders[0]?.id ?? 'project',
);
})
.catch((error: unknown) => {
if (!cancelled && isEditorAuthError(error)) {
openEditorLoginModal();
}
});
return () => {
cancelled = true;
};
}, [canAccessProtectedData, openEditorLoginModal]);
const resolveAssetFolderId = useCallback(
(clientX: number, clientY: number) => {
const listElement = assetListRef.current;
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 matchedFolder?.dataset.assetFolderId ?? null;
},
[assetListRef],
);
const updateAssetMoveDropFolder = useCallback(
(folderId: string | null) => {
setAssetMoveDropFolderId(folderId);
if (!folderId) {
setPinnedAssetMoveFolderId(null);
return;
}
const listElement = assetListRef.current;
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,
);
},
[assetListRef],
);
const startRenamingAsset = useCallback((asset: EditorAsset) => {
setRenamingAsset({
assetId: asset.id,
value: asset.label,
});
}, []);
const commitAssetRename = useCallback(
(asset: EditorAsset) => {
const nextLabel = renamingAsset?.value.trim();
if (!nextLabel) {
setRenamingAsset(null);
return;
}
setAssets((currentAssets) =>
currentAssets.map((currentAsset) =>
currentAsset.id === asset.id
? {
...currentAsset,
label: nextLabel,
}
: currentAsset,
),
);
if (asset.persisted) {
updateEditorAsset(asset.id, { label: nextLabel }).catch(() => {});
}
setRenamingAsset(null);
},
[renamingAsset],
);
const toggleAssetFolder = useCallback(
(folderId: string) => {
const nextFolder = assetFolders.find((folder) => folder.id === folderId);
const nextCollapsed = !(nextFolder?.collapsed ?? false);
setAssetFolders((currentFolders) =>
currentFolders.map((folder) =>
folder.id === folderId
? {
...folder,
collapsed: !folder.collapsed,
}
: folder,
),
);
if (nextFolder?.persisted) {
updateEditorAssetFolder(folderId, { collapsed: nextCollapsed }).catch(
() => {},
);
}
},
[assetFolders],
);
const commitNewAssetFolder = useCallback(async () => {
const label = newFolderName.trim();
if (!label) {
setCreatingFolder(false);
setNewFolderName('');
return;
}
const folderId = `folder-${Date.now()}`;
setAssetFolders((currentFolders) => [
...currentFolders,
{
id: folderId,
label,
collapsed: false,
systemDefault: false,
persisted: false,
},
]);
setActiveUploadFolderId(folderId);
setCreatingFolder(false);
setNewFolderName('');
try {
const folder = await createEditorAssetFolder(
label,
assetFolders.length + 100,
);
setAssetFolders((currentFolders) =>
currentFolders.map((currentFolder) =>
currentFolder.id === folderId
? {
id: folder.folderId,
label: folder.label,
collapsed: folder.collapsed,
systemDefault: folder.systemDefault,
persisted: true,
}
: currentFolder,
),
);
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.folderId === folderId
? {
...asset,
folderId: folder.folderId,
}
: asset,
),
);
setActiveUploadFolderId(folder.folderId);
} catch {
// 本地临时文件夹仍可继续使用;下次刷新以后端为准。
}
}, [assetFolders.length, newFolderName]);
const deleteUploadedAsset = useCallback(
(asset: EditorAsset) => {
if (asset.sourceKind !== 'uploaded') {
return;
}
setAssets((currentAssets) =>
currentAssets.filter((currentAsset) => currentAsset.id !== asset.id),
);
onDeleteAssets?.([asset]);
setRenamingAsset((currentRename) =>
currentRename?.assetId === asset.id ? null : currentRename,
);
if (asset.persisted) {
deleteEditorAsset(asset.id).catch(() => {});
}
},
[onDeleteAssets],
);
const startRenamingFolder = useCallback((folder: EditorAssetFolder) => {
setRenamingFolder({
folderId: folder.id,
value: folder.label,
});
}, []);
const commitFolderRename = useCallback(
(folder: EditorAssetFolder) => {
const nextLabel = renamingFolder?.value.trim();
if (!nextLabel) {
setRenamingFolder(null);
return;
}
setAssetFolders((currentFolders) =>
currentFolders.map((currentFolder) =>
currentFolder.id === folder.id
? {
...currentFolder,
label: nextLabel,
}
: currentFolder,
),
);
if (folder.persisted) {
updateEditorAssetFolder(folder.id, { label: nextLabel }).catch(
() => {},
);
}
setRenamingFolder(null);
},
[renamingFolder],
);
const deleteAssetFolder = useCallback(
(folder: EditorAssetFolder) => {
if (folder.systemDefault) {
return;
}
const defaultFolder =
assetFolders.find((currentFolder) => currentFolder.systemDefault) ??
assetFolders[0];
if (!defaultFolder) {
return;
}
setAssetFolders((currentFolders) =>
currentFolders.filter(
(currentFolder) => currentFolder.id !== folder.id,
),
);
setAssets((currentAssets) =>
currentAssets.map((asset) =>
asset.folderId === folder.id
? {
...asset,
folderId: defaultFolder.id,
}
: asset,
),
);
if (folder.persisted) {
deleteEditorAssetFolder(folder.id)
.then((library) => {
const nextLibrary = normalizeAssetLibrary(library);
setAssetFolders(nextLibrary.folders);
setAssets(nextLibrary.assets);
})
.catch(() => {});
}
},
[assetFolders],
);
const toggleAssetSelected = useCallback((assetId: string) => {
setSelectedAssetIds((currentIds) => {
const nextIds = new Set(currentIds);
if (nextIds.has(assetId)) {
nextIds.delete(assetId);
} else {
nextIds.add(assetId);
}
return nextIds;
});
}, []);
const toggleAllAssetsSelected = useCallback(() => {
setSelectedAssetIds(
allSelectableAssetsSelected
? new Set()
: new Set(selectableAssets.map((asset) => asset.id)),
);
}, [allSelectableAssetsSelected, selectableAssets]);
const deleteSelectedAssets = useCallback(() => {
const ids = [...selectedAssetIds];
const deletedAssets = assets.filter((asset) =>
selectedAssetIds.has(asset.id),
);
setAssets((currentAssets) =>
currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)),
);
onDeleteAssets?.(deletedAssets);
setSelectedAssetIds(new Set());
ids.forEach((assetId) => {
void deleteEditorAsset(assetId);
});
}, [assets, onDeleteAssets, selectedAssetIds]);
const moveAssetToFolder = useCallback(
(assetId: string, folderId: string) => {
const asset = assets.find((currentAsset) => currentAsset.id === assetId);
if (!asset || asset.folderId === folderId) {
return;
}
setAssets((currentAssets) =>
currentAssets.map((currentAsset) =>
currentAsset.id === assetId
? {
...currentAsset,
folderId,
}
: currentAsset,
),
);
if (asset.persisted) {
updateEditorAsset(asset.id, { folderId }).catch(() => {});
}
},
[assets],
);
const closeAssetSelectionMode = useCallback(() => {
setIsAssetSelectionMode(false);
setSelectedAssetIds(new Set());
setAssetMarquee(null);
}, []);
const updateAssetSelectionFromMarquee = useCallback(
(selectionRect: {
left: number;
right: number;
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);
},
[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]')) {
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,
});
setSelectedAssetIds(new Set());
},
[assetListRef, isAssetSelectionMode],
);
const handleAssetMarqueePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) {
return;
}
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,
}
: 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),
});
},
[assetListRef, assetMarquee, updateAssetSelectionFromMarquee],
);
const handleAssetMarqueePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (assetListRef.current?.hasPointerCapture?.(event.pointerId)) {
assetListRef.current.releasePointerCapture?.(event.pointerId);
}
setAssetMarquee(null);
},
[assetListRef, assetMarquee],
);
return {
assetFolders,
setAssetFolders,
assets,
setAssets,
groupedAssets,
selectableAssets,
allSelectableAssetsSelected,
renamingAsset,
setRenamingAsset,
renamingFolder,
setRenamingFolder,
creatingFolder,
setCreatingFolder,
newFolderName,
setNewFolderName,
activeUploadFolderId,
setActiveUploadFolderId,
isAssetSelectionMode,
setIsAssetSelectionMode,
selectedAssetIds,
setSelectedAssetIds,
assetMarquee,
setAssetMarquee,
assetPointerDrag,
setAssetPointerDrag,
assetMoveDropFolderId,
pinnedAssetMoveFolderId,
resolveAssetFolderId,
updateAssetMoveDropFolder,
startRenamingAsset,
commitAssetRename,
toggleAssetFolder,
commitNewAssetFolder,
deleteUploadedAsset,
startRenamingFolder,
commitFolderRename,
deleteAssetFolder,
toggleAssetSelected,
toggleAllAssetsSelected,
deleteSelectedAssets,
moveAssetToFolder,
closeAssetSelectionMode,
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
};
}
export type ImageCanvasAssetLibraryState = ReturnType<
typeof useImageCanvasAssetLibrary
>;
export type ImageCanvasAssetLibrarySetter<T> = Dispatch<SetStateAction<T>>;