Files
Genarrative/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx
T
suzmii dc243b0d57
Project CI / Repository checks (pull_request) Successful in 2m48s
Project CI / Frontend tests (pull_request) Successful in 3m14s
Project CI / Backend tests (pull_request) Successful in 7m49s
Project CI / Native shell tests (pull_request) Successful in 18m27s
资源工作台「替换素材」候选窗接上预览管线,补齐候选排序、禁用态可读性与删除素材入口
- 「替换素材」候选卡缩略图接入资源卡同一条预览管线:新增 ResourcePreviewMedia,复用同一份预览身份、同一条队列与 LRU 缓存,不再只渲染类型占位图标
- 预览 hook 暴露 sweepVisiblePreviews:弹窗内容挂在 portal 下、不在资源画本 observer 的 root 子树内,改为在「弹窗打开」与「候选区滚动」两个几何变化点显式按同一套视口几何判据兜底放行,不新增第二条加载通路
- 候选排序抽出纯函数 sortResourceReplacementCandidates:可替换(无硬门禁失败)的排最前,其余按后端原名次跟随且仍渲染但禁用;判据与弹窗禁用状态同源,不会出现「排最前却点不动」
- 抽出纯函数 projectResourcesByManifestAssetId,让弹窗素材映射与候选缩略图共用同一份资源投影口径
- 共享选择弹窗的禁用态不再整卡压透明度:变灰只压在缩略图与名称上,「分类不同」这类禁用原因保持全对比度可读(网页端美术画布不传禁用原因,视觉逐字不变)
- 资源卡选中工具条末位新增「删除素材」,前置共享工具条同一套分隔线与前面的非破坏性动作隔开,只在 manifestAssetId 存在时才渲染
- 删除流程抽成 useResourceAssetDeleteFlow(读引用信息 → ResourceAssetDeleteDialog 二次确认 → delete_local_project_asset 的 deleteReferencedVersions 三分支),工具条与面板共用同一份实现,没有第二套删除
- 「编辑素材标签」面板底部只留右下角一个「添加」:点它把输入框内容(含未按回车的尾巴)落成 pill 并按现有写入路径保存,分类仍原样回传落盘原值;删除素材/取消/保存标签三个按钮移除,关闭仍走头部 ×
- 标签 pill 内的删除标签按钮保持不变
- 更新与补充用例:候选排序、排序与禁用同源、候选缩略图真链路与滚动兜底放行、工具条删除入口位置与 IPC 载荷、连带删除分支、面板无删除入口与「添加」语义、禁用态可读性类名契约、工具条动作清单
2026-09-12 15:30:38 +08:00

318 lines
12 KiB
TypeScript

import { Check, ImageIcon, Music, Search, Video } from 'lucide-react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { PlatformActionButton } from '../../../packages/shared/src/components/PlatformActionButton';
import { PlatformResourceFilterBar } from '../../../packages/shared/src/components/PlatformResourceFilterBar';
import { PlatformStatusMessage } from '../../../packages/shared/src/components/PlatformStatusMessage';
import { UnifiedModal } from '../common/UnifiedModal';
import type { EditorAsset } from './ImageCanvasEditorTypes';
import {
PROJECT_ASSET_PICKER_CATEGORY_OPTIONS,
projectAssetMatchesPickerFilter,
type ProjectAssetPickerCategory,
projectAssetPickerCategory,
} from './projectAssetReferencePickerModel';
type ImageCanvasProjectAssetPickerDialogProps = {
open: boolean;
/** 项目已登记素材;弹窗只负责选择,不改素材本身。 */
assets: readonly EditorAsset[];
/** 打开时的初始选择,取消时原选择保持不变。 */
selectedAssetIds: readonly string[];
onCancel: () => void;
onConfirm: (assetIds: string[]) => void;
/**
* 单选模式:点击即整组替换当前选择,不再渲染「已选」chip 行。
* 默认 `false`,网页端美术画布的参考图多选行为逐字不变。
*/
singleSelect?: boolean;
/**
* 被禁用的素材 id → 用户可见的禁用原因。默认空,即所有素材都可选。
*
* 禁用项仍然渲染(不隐藏):隐藏会让用户以为"素材不存在",而真实原因是它不可替换。
*/
assetBlockedReasons?: Readonly<Record<string, string>>;
/**
* 可选素材 id → 非阻断提示(例如"格式与源素材不同")。默认空,即不显示任何提示。
*
* 与 `assetBlockedReasons` 的区别:提示不改变可点性,只把差异说清楚。
*/
assetHints?: Readonly<Record<string, string>>;
/**
* 素材缩略图渲染器。默认 `undefined` → 沿用 `<img src={thumbnailSrc || src}>`。
*
* 宿主(如 AGC 资源工作台)没有同步 `src` 时必须传它:AGC 的预览读取走带 scope 的
* 原生调度器 + Blob URL,弹窗内取不到,直接给空 `src` 会挂破图。
*/
renderAssetMedia?: (asset: EditorAsset) => ReactNode;
/**
* 选择对象的中文名词,用于拼弹窗标题与可访问名称。默认「参考图」,
* 即网页端美术画布的现有文案逐字不变。
*/
selectionNoun?: string;
/**
* 宿主的失败原因(例如后端拒绝了这次替换)。默认 `undefined` → 不渲染。
*
* 用于在弹窗内说明"为什么这次操作没成功",而不是静默关闭弹窗让用户以为成功了。
*/
errorMessage?: string | null;
};
function assetIcon(asset: EditorAsset) {
const category = projectAssetPickerCategory(asset);
if (category === 'video') {
return <Video className="h-4 w-4" aria-hidden="true" />;
}
if (category === 'audio') {
return <Music className="h-4 w-4" aria-hidden="true" />;
}
return <ImageIcon className="h-4 w-4" aria-hidden="true" />;
}
/**
* 参考图选择弹窗。
*
* 画布筛选条在这里被完整复用,但筛选状态由本弹窗自己持有,
* 与资源画布、`@` 面板互不影响。确认时只回传素材 id 列表,
* 由调用方把 id 快照写进本次提交数据。
*/
export function ImageCanvasProjectAssetPickerDialog({
open,
assets,
selectedAssetIds,
onCancel,
onConfirm,
singleSelect = false,
assetBlockedReasons,
assetHints,
renderAssetMedia,
selectionNoun = '参考图',
errorMessage,
}: ImageCanvasProjectAssetPickerDialogProps) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState<ProjectAssetPickerCategory>('all');
const [selection, setSelection] = useState<string[]>([]);
// 每次打开都从调用方给的初始选择重新开始,取消不写回。
useEffect(() => {
if (!open) return;
setQuery('');
setCategory('all');
setSelection([...selectedAssetIds]);
}, [open, selectedAssetIds]);
const visibleAssets = useMemo(
() =>
assets.filter((asset) =>
projectAssetMatchesPickerFilter(asset, { category, query }),
),
[assets, category, query],
);
const assetById = useMemo(
() => new Map(assets.map((asset) => [asset.id, asset])),
[assets],
);
const selectedAssets = selection.flatMap((assetId) => {
const asset = assetById.get(assetId);
return asset ? [asset] : [];
});
function toggleAsset(assetId: string) {
if (assetBlockedReasons?.[assetId]) return;
setSelection((current) => {
if (singleSelect) {
return current.includes(assetId) ? [] : [assetId];
}
return current.includes(assetId)
? current.filter((item) => item !== assetId)
: [...current, assetId];
});
}
return (
<UnifiedModal
open={open}
title={`选择${selectionNoun}`}
size="lg"
portalTheme="light"
closeLabel={`关闭选择${selectionNoun}`}
onClose={onCancel}
panelClassName="image-canvas-editor__project-asset-picker"
bodyClassName="image-canvas-editor__project-asset-picker-body"
footer={
<>
<span className="mr-auto text-xs text-[var(--platform-text-base)]">
已选 {selection.length}
</span>
<PlatformActionButton
type="button"
tone="secondary"
size="sm"
disabled={selection.length === 0}
onClick={() => setSelection([])}
>
清空
</PlatformActionButton>
<PlatformActionButton
type="button"
tone="secondary"
size="sm"
onClick={onCancel}
>
取消
</PlatformActionButton>
<PlatformActionButton
type="button"
size="sm"
aria-label={`确认选择${selectionNoun}`}
onClick={() => onConfirm(selection)}
>
确认
</PlatformActionButton>
</>
}
>
{errorMessage ? (
<PlatformStatusMessage
tone="error"
size="xs"
className="mb-3"
role="alert"
>
{errorMessage}
</PlatformStatusMessage>
) : null}
{selectedAssets.length > 0 && !singleSelect ? (
<div
className="mb-3 flex flex-wrap gap-1.5"
aria-label={`已选${selectionNoun}`}
>
{selectedAssets.map((asset) => (
<button
key={asset.id}
type="button"
aria-label={`取消${selectionNoun}${asset.label}`}
className="platform-category-chip gap-1 px-2 text-xs"
onClick={() => toggleAsset(asset.id)}
>
{assetIcon(asset)}
<span>{asset.label}</span>
</button>
))}
</div>
) : null}
<PlatformResourceFilterBar
ariaLabel="参考图筛选"
search={{
value: query,
label: '搜索参考图素材',
placeholder: '搜索素材名称',
onChange: setQuery,
}}
categoryItems={PROJECT_ASSET_PICKER_CATEGORY_OPTIONS}
activeCategoryId={category}
onCategoryChange={(nextCategory) => setCategory(nextCategory)}
className="mb-3"
/>
{visibleAssets.length === 0 ? (
<p className="py-6 text-center text-sm text-[var(--platform-text-base)]">
{assets.length === 0 ? '当前项目还没有已登记素材' : '没有匹配的素材'}
</p>
) : (
<div
className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4"
role="listbox"
aria-label={`${selectionNoun}素材`}
aria-multiselectable={!singleSelect}
>
{visibleAssets.map((asset) => {
const selected = selection.includes(asset.id);
const blockedReason = assetBlockedReasons?.[asset.id] ?? null;
const hint = blockedReason
? null
: (assetHints?.[asset.id] ?? null);
return (
<button
key={asset.id}
type="button"
role="option"
aria-selected={selected}
aria-label={`选择${selectionNoun}${asset.label}`}
disabled={blockedReason !== null}
title={blockedReason ?? hint ?? undefined}
className={[
'relative flex min-h-[7.5rem] flex-col overflow-hidden rounded-[0.9rem] border text-left transition',
selected
? 'border-[var(--platform-accent)] bg-white shadow-sm'
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:bg-white',
// 禁用项的"变灰"只压在缩略图与名称上,**不压禁用原因**:
// 「分类不同」这类小字压在整卡 `opacity-60` 下几乎读不出来,而它恰恰是
// 用户唯一能看到的解释。视觉上的禁用感由缩略图与名称承担。
blockedReason !== null ? 'cursor-not-allowed' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => toggleAsset(asset.id)}
>
<span
className={[
'flex h-20 w-full items-center justify-center overflow-hidden bg-white/60',
blockedReason !== null ? 'opacity-50' : '',
]
.filter(Boolean)
.join(' ')}
>
{renderAssetMedia ? (
renderAssetMedia(asset)
) : projectAssetPickerCategory(asset) === 'image' ? (
<img
src={asset.thumbnailSrc || asset.src}
alt=""
className="h-full w-full object-cover"
/>
) : (
assetIcon(asset)
)}
</span>
<span
className={[
'flex min-w-0 items-center gap-1 px-2 py-1.5',
blockedReason !== null ? 'opacity-60' : '',
]
.filter(Boolean)
.join(' ')}
>
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--platform-text-strong)]">
{asset.label}
</span>
{selected ? (
<Check
className="h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
) : null}
</span>
{blockedReason !== null ? (
<span className="block px-2 pb-1.5 text-[0.6875rem] leading-snug font-medium text-[var(--platform-text-strong)]">
{blockedReason}
</span>
) : hint !== null ? (
<span className="block px-2 pb-1.5 text-[0.6875rem] leading-snug text-[var(--platform-text-base)]">
{hint}
</span>
) : null}
</button>
);
})}
</div>
)}
{visibleAssets.length > 0 ? (
<p className="mt-2 text-center text-xs text-[var(--platform-text-base)]">
<Search className="mr-1 inline h-3 w-3" aria-hidden="true" />
{visibleAssets.length} / {assets.length}
</p>
) : null}
</UnifiedModal>
);
}