Files
Genarrative/src/components/image-editor/ImageCanvasAssetRowView.tsx
T
k88936 498b905bd8
Project CI / Repository checks (push) Successful in 1m21s
Project CI / Frontend tests (push) Successful in 3m9s
Project CI / Backend tests (push) Successful in 4m2s
Project CI / Native shell tests (push) Successful in 15m48s
优化素材库多选操作 (#114)
<video src="attachments/2377fc8b-50e4-4919-ba41-5027b957709f" title="2026-07-27 16-15-53.mp4" controls></video>

- 实现shift ctrl的范围选扩选
- 实现多选下载(为一个zip)
- 原本有搜索和文件夹折叠展开功能, 我的处理是: 只作为方便查找的用途. 最终操作会有"目前一些(比如待删除)元素被隐藏了,请再确认"的提示.

result:
```shell
root@k88936-t14p: ~/Downloads# unzip 未命名画布-选中素材-20260806-195631.zip
Archive:  未命名画布-选中素材-20260806-195631.zip
   creating: 未命名画布-选中素材/
   creating: 未命名画布-选中素材/images/
   creating: 未命名画布-选中素材/media/
 extracting: 未命名画布-选中素材/media/001-角色动作(原始视频).mp4
   creating: 未命名画布-选中素材/sequences/
   creating: 未命名画布-选中素材/sequences/002-角色动作/
   creating: 未命名画布-选中素材/sequences/002-角色动作/frames/
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-01.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-02.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-03.png
...
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-31.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-32.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/metadata.json
 extracting: 未命名画布-选中素材/sequences/002-角色动作/skeleton.json
 extracting: 未命名画布-选中素材/sequences/002-角色动作/README.md
 extracting: 未命名画布-选中素材/sequences/002-角色动作/manifest.txt
 extracting: 未命名画布-选中素材/metadata.json
 extracting: 未命名画布-选中素材/manifest.txt
 ```

~~动作的导出存在很大的问题, 在另一个pr #117 解决~~

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/114
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-08 11:11:25 +08:00

580 lines
18 KiB
TypeScript

import { Check, Download, Pencil, Video, X } from 'lucide-react';
import type {
Dispatch,
MouseEvent as ReactMouseEvent,
ReactNode,
SetStateAction,
} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { PlatformTextField } from '../common/PlatformTextField';
import { resolveAssetShowcaseSubmissionState } from './ImageCanvasAssetLibraryModel';
import type { UploadFilesOptions } from './ImageCanvasAssetLibraryPanelView';
import {
ASSET_DRAG_MIME_TYPE,
clamp,
getDraggedAssetId,
hasDataTransferType,
} from './ImageCanvasEditorModel';
import {
EditorIconButton,
SidebarMediaItem,
} from './ImageCanvasEditorPrimitives';
import type {
AssetPointerDragState,
EditorAsset,
} from './ImageCanvasEditorTypes';
import {
getCanvasMediaPreviewClassName,
getCanvasMediaPreviewMarker,
} from './ImageCanvasMediaModel';
import {
type AssetSelectionInteraction,
isSelectableAsset,
} from './useImageCanvasAssetSelection';
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
export type ImageCanvasAssetRowViewProps = {
asset: EditorAsset;
assetPointerDragRef: { current: AssetPointerDragState | null };
suppressAssetClickRef: { current: boolean };
isAssetSelectionMode: boolean;
selectedAssetIds: Set<string>;
visibleAssetIds: string[];
renamingAsset: { assetId: string; value: string } | null;
setRenamingAsset: Dispatch<
SetStateAction<{ assetId: string; value: string } | null>
>;
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
updateAssetMoveDropFolder: (folderId: string | null) => void;
addUploadedFiles: (
files: FileList | File[],
options?: UploadFilesOptions,
) => void;
moveAssetToFolder: (assetId: string, folderId: string) => void;
startRenamingAsset: (asset: EditorAsset) => void;
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
submitAssetToShowcase: (asset: EditorAsset) => void;
toggleAssetSelected: (
assetId: string,
interaction: AssetSelectionInteraction,
) => void;
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
};
export function ImageCanvasAssetRowView({
asset,
assetPointerDragRef,
suppressAssetClickRef,
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds,
renamingAsset,
setRenamingAsset,
setUploadDropTarget,
setAssetPointerDrag,
updateAssetMoveDropFolder,
addUploadedFiles,
moveAssetToFolder,
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
submitAssetToShowcase,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset,
}: ImageCanvasAssetRowViewProps) {
const rowRef = useRef<HTMLDivElement | null>(null);
const [menuPosition, setMenuPosition] = useState<{
x: number;
y: number;
} | null>(null);
const isRenaming = renamingAsset?.assetId === asset.id;
const isUploadingAsset = asset.uploadStatus === 'uploading';
const isFailedUpload = asset.uploadStatus === 'failed';
const canSelectAsset = isSelectableAsset(asset);
const showcaseReviewStatus = asset.showcaseReviewStatus ?? null;
const showcaseSubmissionState = resolveAssetShowcaseSubmissionState(asset);
const canSubmitShowcaseAsset = showcaseSubmissionState.canSubmit;
const showcaseMenuLabel = showcaseSubmissionState.label;
const showcaseMenuReason = showcaseSubmissionState.reason;
const closeMenu = useCallback(() => {
setMenuPosition(null);
}, []);
useEffect(() => {
if (isAssetSelectionMode) {
closeMenu();
}
}, [closeMenu, isAssetSelectionMode]);
const selectAssetFromEvent = useCallback(
(event: ReactMouseEvent<HTMLElement>) => {
if (
!isAssetSelectionMode ||
!canSelectAsset ||
suppressAssetClickRef.current
) {
return;
}
closeMenu();
toggleAssetSelected(asset.id, {
visibleAssetIds,
shiftKey: event.shiftKey,
});
},
[
asset.id,
canSelectAsset,
closeMenu,
isAssetSelectionMode,
suppressAssetClickRef,
toggleAssetSelected,
visibleAssetIds,
],
);
useEffect(() => {
if (!menuPosition) {
return undefined;
}
const handlePointerDown = (event: PointerEvent) => {
if (rowRef.current?.contains(event.target as Node)) {
return;
}
closeMenu();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeMenu();
}
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('scroll', closeMenu, true);
window.addEventListener('resize', closeMenu);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('scroll', closeMenu, true);
window.removeEventListener('resize', closeMenu);
};
}, [closeMenu, menuPosition]);
const uploadProgress = clamp(asset.uploadProgress ?? 0, 0, 100);
const mediaPreviewMarker = getCanvasMediaPreviewMarker(asset);
const mediaPreviewClassName =
getCanvasMediaPreviewClassName(mediaPreviewMarker);
const { resolvedUrl: resolvedVideoPreviewUrl } = useResolvedAssetReadUrl(
asset.mediaType === 'video' ? asset.src : '',
{
enabled: asset.mediaType === 'video' && !asset.thumbnailSrc?.trim(),
objectKey: asset.mediaType === 'video' ? asset.objectKey : undefined,
refreshKey: asset.id,
},
);
const videoPreview =
asset.mediaType === 'video' && !asset.thumbnailSrc?.trim() ? (
<video
className="image-canvas-editor__asset-video-preview"
src={resolvedVideoPreviewUrl || undefined}
muted
playsInline
preload="metadata"
aria-label={`视频素材预览:${asset.label}`}
/>
) : undefined;
const mediaDetail =
asset.mediaType === 'audio'
? '音频'
: asset.mediaType === 'video'
? '视频'
: `${asset.width} x ${asset.height}`;
const mediaOverlay =
asset.mediaType === 'video' ? (
<div
className={`image-canvas-editor__asset-media-overlay ${mediaPreviewClassName}`}
>
<Video className="h-5 w-5" />
</div>
) : undefined;
let primaryLabel = `添加${asset.label}`;
if (isUploadingAsset) {
primaryLabel = `上传中${asset.label}`;
} else if (isFailedUpload) {
primaryLabel = `上传失败${asset.label}`;
} else if (isAssetSelectionMode) {
primaryLabel = canSelectAsset
? `选择素材${asset.label}`
: `素材${asset.label}不可选择`;
}
const titleNode = isRenaming ? (
<PlatformTextField
aria-label={`重命名素材${asset.label}`}
value={renamingAsset.value}
autoFocus
size="xs"
density="compact"
className="image-canvas-editor__asset-rename-input"
onChange={(event) =>
setRenamingAsset({
assetId: asset.id,
value: event.target.value,
})
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commitAssetRename(asset);
}
if (event.key === 'Escape') {
event.preventDefault();
setRenamingAsset(null);
}
}}
/>
) : undefined;
let actions: ReactNode;
if (isAssetSelectionMode) {
actions = undefined;
} else if (isUploadingAsset) {
actions = (
<div className="image-canvas-editor__asset-upload-status">
<span>{asset.uploadMessage ?? '上传中'}</span>
<strong>{Math.round(uploadProgress)}%</strong>
</div>
);
} else if (isRenaming) {
actions = (
<div className="image-canvas-editor__asset-actions">
<EditorIconButton
label={`保存素材${asset.label}名称`}
title="保存"
icon={Check}
onClick={() => commitAssetRename(asset)}
/>
<EditorIconButton
label={`取消重命名素材${asset.label}`}
title="取消"
icon={X}
onClick={() => setRenamingAsset(null)}
/>
</div>
);
} else {
actions = (
<div className="image-canvas-editor__asset-actions">
<EditorIconButton
label={`下载素材${asset.label}`}
title="下载"
icon={Download}
onClick={() => {
closeMenu();
onDownloadAsset(asset);
}}
/>
<EditorIconButton
label={`重命名素材${asset.label}`}
title="重命名"
icon={Pencil}
onClick={() => {
closeMenu();
startRenamingAsset(asset);
}}
/>
</div>
);
}
return (
<div
ref={rowRef}
data-asset-id={asset.id}
onClick={selectAssetFromEvent}
onContextMenu={(event) => {
if (isAssetSelectionMode) {
event.preventDefault();
event.stopPropagation();
return;
}
if (isUploadingAsset || isFailedUpload || isRenaming) {
return;
}
event.preventDefault();
event.stopPropagation();
setMenuPosition({ x: event.clientX, y: event.clientY });
}}
>
<SidebarMediaItem
title={asset.label}
detail={mediaDetail}
imageAlt={`素材:${asset.label}`}
primaryLabel={primaryLabel}
primaryDisabled={isAssetSelectionMode && !canSelectAsset}
onPrimaryClick={() => {
if (isUploadingAsset || isFailedUpload) {
return;
}
closeMenu();
if (suppressAssetClickRef.current) {
return;
}
if (isAssetSelectionMode) {
return;
}
addAssetLayer(asset);
}}
selected={isAssetSelectionMode && selectedAssetIds.has(asset.id)}
rowClassName={[
'image-canvas-editor__asset-row',
isUploadingAsset ? 'image-canvas-editor__asset-row--uploading' : '',
isFailedUpload ? 'image-canvas-editor__asset-row--upload-failed' : '',
]
.filter(Boolean)
.join(' ')}
primaryClassName="image-canvas-editor__asset-button"
thumbnailClassName="image-canvas-editor__asset-thumb"
metaClassName="image-canvas-editor__asset-meta"
imageSrc={
asset.mediaType === 'image'
? asset.src
: asset.mediaType === 'audio'
? AUDIO_ASSET_COVER_SRC
: (asset.thumbnailSrc ?? '')
}
objectKey={asset.mediaType === 'image' ? asset.objectKey : undefined}
previewNode={videoPreview}
titleNode={
isUploadingAsset || isFailedUpload ? (
<span>{asset.label}</span>
) : (
titleNode
)
}
actions={actions}
draggable={
!isAssetSelectionMode &&
!isRenaming &&
!isUploadingAsset &&
!isFailedUpload
}
previewOverlay={
isUploadingAsset ? (
<div className="image-canvas-editor__asset-upload-overlay">
<span>上传中</span>
</div>
) : (
mediaOverlay
)
}
footerNode={
isUploadingAsset || isFailedUpload ? (
<div className="image-canvas-editor__asset-upload-progress">
<div>
<span>
{isFailedUpload
? (asset.uploadMessage ?? '上传失败')
: (asset.uploadMessage ?? '上传中')}
</span>
<strong>{Math.round(uploadProgress)}%</strong>
</div>
<progress
aria-label={`素材${asset.label}上传进度`}
max={100}
value={uploadProgress}
/>
</div>
) : undefined
}
onDragStart={(event) => {
if (
isAssetSelectionMode ||
isRenaming ||
isUploadingAsset ||
isFailedUpload
) {
event.preventDefault();
return;
}
if (assetPointerDragRef.current?.assetId === asset.id) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData(ASSET_DRAG_MIME_TYPE, asset.id);
event.dataTransfer.setData('text/plain', asset.label);
event.dataTransfer.setData('text/uri-list', asset.src);
updateAssetMoveDropFolder(asset.folderId);
}}
onDragEnd={() => {
setUploadDropTarget(null);
updateAssetMoveDropFolder(null);
}}
onPointerDown={(event) => {
if (isAssetSelectionMode) {
return;
}
if (event.button !== 0) {
return;
}
const target = event.target as HTMLElement;
if (
isRenaming ||
isUploadingAsset ||
isFailedUpload ||
target.closest('input, textarea, select')
) {
return;
}
const primaryAssetButton = target.closest(
'.image-canvas-editor__asset-button',
);
if (target.closest('button') && !primaryAssetButton) {
return;
}
const nextDrag: AssetPointerDragState = {
pointerId: event.pointerId,
assetId: asset.id,
startClientX: event.clientX,
startClientY: event.clientY,
currentClientX: event.clientX,
currentClientY: event.clientY,
active: false,
dropFolderId: null,
};
if (!primaryAssetButton) {
try {
event.currentTarget.setPointerCapture?.(event.pointerId);
} catch {
// 自动化环境可能没有 active pointer,拖拽状态仍可走 window 事件完成。
}
event.preventDefault();
}
event.stopPropagation();
assetPointerDragRef.current = nextDrag;
setAssetPointerDrag(nextDrag);
}}
onDragOver={(event) => {
if (isAssetSelectionMode) {
if (
hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE) ||
hasDataTransferType(event.dataTransfer, 'Files')
) {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'none';
}
return;
}
if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) {
event.preventDefault();
event.stopPropagation();
setUploadDropTarget(null);
updateAssetMoveDropFolder(asset.folderId);
event.dataTransfer.dropEffect = 'move';
return;
}
if (hasDataTransferType(event.dataTransfer, 'Files')) {
event.preventDefault();
event.stopPropagation();
setUploadDropTarget('assets');
event.dataTransfer.dropEffect = 'copy';
}
}}
onDrop={(event) => {
if (isAssetSelectionMode) {
if (
getDraggedAssetId(event.dataTransfer) ||
event.dataTransfer.files.length > 0
) {
event.preventDefault();
event.stopPropagation();
setUploadDropTarget(null);
updateAssetMoveDropFolder(null);
}
return;
}
const movingAssetId = getDraggedAssetId(event.dataTransfer);
if (movingAssetId) {
event.preventDefault();
event.stopPropagation();
setUploadDropTarget(null);
updateAssetMoveDropFolder(null);
moveAssetToFolder(movingAssetId, asset.folderId);
return;
}
if (!event.dataTransfer.files.length) {
return;
}
event.preventDefault();
event.stopPropagation();
setUploadDropTarget(null);
updateAssetMoveDropFolder(null);
addUploadedFiles(event.dataTransfer.files, {
folderId: asset.folderId,
});
}}
/>
{menuPosition ? (
<div
className="image-canvas-editor__context-menu image-canvas-editor__asset-context-menu"
role="menu"
style={{
left: menuPosition.x,
top: menuPosition.y,
}}
>
<button
type="button"
role="menuitem"
className="image-canvas-editor__asset-context-menu-toggle"
disabled={!canSubmitShowcaseAsset}
title={showcaseMenuReason ?? showcaseMenuLabel}
onClick={() => {
if (!canSubmitShowcaseAsset) {
return;
}
submitAssetToShowcase(asset);
}}
>
<span className="image-canvas-editor__asset-context-menu-label">
<span>{showcaseMenuLabel}</span>
{showcaseMenuReason ? (
<span className="image-canvas-editor__asset-context-menu-reason">
{showcaseMenuReason}
</span>
) : null}
</span>
{showcaseReviewStatus === 'approved' ? (
<span
aria-hidden="true"
className="image-canvas-editor__asset-context-menu-check"
>
</span>
) : null}
</button>
{asset.sourceKind === 'uploaded' ? (
<button
type="button"
role="menuitem"
className="image-canvas-editor__context-menu-danger"
onClick={() => {
deleteUploadedAsset(asset);
closeMenu();
}}
>
删除
</button>
) : null}
</div>
) : null}
</div>
);
}