新增图片信息弹窗组件,承接 metadata 详情渲染和 UnifiedModal 接入 修复未登录进入编辑器时项目和素材接口抢跑 401 修复重置画布视图点击事件误传导致适合视图报错 补充图片信息弹窗、鉴权门禁和重置按钮回归测试 更新前端拆分文档和 TRACKING 浏览器回归记录
610 lines
17 KiB
TypeScript
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>>;
|