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