498b905bd8
<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>
580 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|