387a1c26e3
清理已跟踪原始日志与个人本地配置 修复前后端有效门禁、测试和构建问题 补齐生产 Jenkins、SpacetimeDB 本地命令与文档约束 收紧图片编辑器状态、附件与媒体引用测试 排除已下线旧创作入口测试并清理 warning
497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
import { Check, Download, Pencil, Video, X } from 'lucide-react';
|
|
import type { Dispatch, 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';
|
|
|
|
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>;
|
|
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>>;
|
|
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
|
|
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) => void;
|
|
addAssetLayer: (asset: EditorAsset) => void;
|
|
onDownloadAsset: (asset: EditorAsset) => void;
|
|
};
|
|
|
|
export function ImageCanvasAssetRowView({
|
|
asset,
|
|
assetPointerDragRef,
|
|
suppressAssetClickRef,
|
|
isAssetSelectionMode,
|
|
selectedAssetIds,
|
|
renamingAsset,
|
|
setRenamingAsset,
|
|
setUploadDropTarget,
|
|
setAssetPointerDrag,
|
|
setSelectedAssetIds,
|
|
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 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 (!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;
|
|
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;
|
|
const actions = isUploadingAsset ? (
|
|
<div className="image-canvas-editor__asset-upload-status">
|
|
<span>{asset.uploadMessage ?? '上传中'}</span>
|
|
<strong>{Math.round(uploadProgress)}%</strong>
|
|
</div>
|
|
) : isRenaming ? (
|
|
<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>
|
|
) : (
|
|
<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}
|
|
onContextMenu={(event) => {
|
|
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={
|
|
isUploadingAsset
|
|
? `上传中${asset.label}`
|
|
: isFailedUpload
|
|
? `上传失败${asset.label}`
|
|
: isAssetSelectionMode
|
|
? `选择素材${asset.label}`
|
|
: `添加${asset.label}`
|
|
}
|
|
onPrimaryClick={() => {
|
|
if (isUploadingAsset || isFailedUpload) {
|
|
return;
|
|
}
|
|
closeMenu();
|
|
if (suppressAssetClickRef.current) {
|
|
return;
|
|
}
|
|
if (isAssetSelectionMode) {
|
|
toggleAssetSelected(asset.id);
|
|
return;
|
|
}
|
|
addAssetLayer(asset);
|
|
}}
|
|
selected={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={!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 (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 (event.button !== 0) {
|
|
return;
|
|
}
|
|
const target = event.target as HTMLElement;
|
|
if (isAssetSelectionMode) {
|
|
if (target.closest('button')) {
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
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);
|
|
}}
|
|
onPointerEnter={(event) => {
|
|
if (isAssetSelectionMode && event.buttons === 1) {
|
|
setSelectedAssetIds((currentIds) => {
|
|
const nextIds = new Set(currentIds);
|
|
nextIds.add(asset.id);
|
|
return nextIds;
|
|
});
|
|
}
|
|
}}
|
|
onDragOver={(event) => {
|
|
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) => {
|
|
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>
|
|
);
|
|
}
|