Files
Genarrative/src/components/image-editor/ImageCanvasProjectAssetPickerDialog.tsx
T
suzmii de502e9bc3 替换面板改为非模态浮层,支持在画布上点选替换目标
- 共享选择组件新增 opt-in nonModal:不铺遮罩、不做焦点陷阱、限高 + 内部滚动,Esc 在 document 阶段截断后取消,关掉即卸载;网页端美术画布不传,弹窗行为逐字不变。
- 新增 initialSelectionRevision:面板开着时宿主换了目标才重同步当前选择,面板里的搜索词与分类筛选不被重置。
- AGC 侧把候选面板锚到画布右上角(任务开关下方),壳样式放共享样式表、宿主只负责锚定;面板开着时画布照常可点,点中合法候选落成面板当前选择,写入仍只由「确认」发起。
- 退役旧的「点选替换」按钮、画布提示条与 resourceReplacementPickMode:同一个功能不留两条 UI 路径;合法性判据仍复用 resolveResourceReplacementPick,写入仍是同一个 confirm 函数。
- 确认时用画布点选结果兜底,避免点完当帧按确认误报「请选择一个替换素材」。
- 测试:替换用例 4 条点选路径改写为非模态 + 面板点选(含四类非法目标在面板内报因、Esc 只收面板),新增「画布点选不清掉面板搜索/分类」;共享组件用例新增 nonModal 形态与浮层壳声明级契约。
- 文档:PRD 5.3 / 7.8 第 8 条改写、验收用例 S15a 改写、decision-log 新增 2026-09-21 条目、待办文档登记三项并勾掉对应需求。
2026-09-21 03:48:40 +08:00

451 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Check, ImageIcon, Music, Search, Video } from 'lucide-react';
import {
type ReactNode,
useEffect,
useLayoutEffect,
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 { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { UnifiedModal } from '../common/UnifiedModal';
import type { EditorAsset } from './ImageCanvasEditorTypes';
import {
PROJECT_ASSET_PICKER_CATEGORY_OPTIONS,
projectAssetMatchesPickerFilter,
type ProjectAssetPickerCategory,
projectAssetPickerCategory,
type ProjectAssetPickerMediaCategory,
projectAssetPickerUsesThumbnail,
} 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;
/**
* 非模态浮层:不铺全屏遮罩、不做焦点陷阱,宿主画布保持可点。
*
* AGC 的「替换素材」用它:面板开着的时候直接在资源画布上点目标素材,点中的候选落进面板的
* 当前选择,写入仍然只由面板的「确认」发起。默认 `false` → 网页端美术画布的弹窗行为逐字不变。
*
* 打开期间 Esc 仍等于「取消」(document 阶段截断,宿主画布的全局 Esc 不随之触发);
* 点外部不关闭——非模态面板与画布是同一屏的两半,点画布是要选目标,不是要关面板。
*/
nonModal?: boolean;
/**
* 「初值换了」的信号:宿主在面板**开着**的时候又给了新的 `selectedAssetIds`AGC 里是在
* 画布上点选目标素材),序号一变就按新初值重同步当前选择。
*
* 不能用 `selectedAssetIds` 的引用当信号:调用方每次渲染都会重建那个数组,把它放进依赖会
* 让「父级任何一次重渲染」都清掉用户的选择(组件里原本就是这么写的)。所以同步点交给这个
* 显式序号:只有宿主真的换了目标才变化。缺省 `0`(网页端美术画布不传,行为逐字不变)。
*/
initialSelectionRevision?: number;
};
function assetIcon(category: ProjectAssetPickerCategory) {
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" />;
}
function AssetCardMedia({
asset,
category,
renderAssetMedia,
}: {
asset: EditorAsset;
category: ProjectAssetPickerMediaCategory;
renderAssetMedia?: (asset: EditorAsset) => ReactNode;
}) {
if (renderAssetMedia) {
return <>{renderAssetMedia(asset)}</>;
}
if (projectAssetPickerUsesThumbnail(category)) {
return (
<img
src={asset.thumbnailSrc || asset.src}
alt=""
className="h-full w-full object-cover"
/>
);
}
return assetIcon(category);
}
/**
* 参考图选择弹窗。
*
* 画布筛选条在这里被完整复用,但筛选状态由本弹窗自己持有,
* 与资源画布、`@` 面板互不影响。确认时只回传素材 id 列表,
* 由调用方把 id 快照写进本次提交数据。
*/
export function ImageCanvasProjectAssetPickerDialog({
open,
assets,
selectedAssetIds,
onCancel,
onConfirm,
singleSelect = false,
assetBlockedReasons,
assetHints,
renderAssetMedia,
selectionNoun = '参考图',
errorMessage,
nonModal = false,
initialSelectionRevision = 0,
}: ImageCanvasProjectAssetPickerDialogProps) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState<ProjectAssetPickerCategory>('all');
const [selection, setSelection] = useState<string[]>([]);
// 每次打开都从调用方给的初始选择重新开始,取消不写回。
//
// 只依赖 `open`:调用方每次渲染都会重建 `selectedAssetIds` 数组(例如
// `useImageCanvasGenerationSurface` 里是即时 `.filter().flatMap()`),把它放进依赖
// 会让「父级任何一次重渲染」都重新清空搜索词、分类与在选中的选择。
// `open` 翻成 true 的那一帧本身已经带着最新的初始选择,读到的就是它。
// 用 layout effect 在首次绘制前完成初始化,避免用户在初始化 effect 执行前点击素材,
// 随后又被初始化逻辑清空选择。
useLayoutEffect(() => {
if (!open) return;
setQuery('');
setCategory('all');
setSelection([...selectedAssetIds]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- 只在打开的那一帧重置
}, [open]);
/**
* 面板开着时宿主换了初值(AGC 在画布上点选目标素材):只同步这一项,别的不动。
*
* 搜索词与分类保持原样——用户在面板里筛到一半、又去画布上点一张,回来不该被重置成「全部」。
* 选择直接落成新初值(单选场景就是那一项)。
*/
useLayoutEffect(() => {
if (!open) return;
setSelection([...selectedAssetIds]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- 只认显式序号,不认数组引用
}, [initialSelectionRevision, open]);
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];
});
}
/**
* 非模态下的 Esc = 取消。
*
* 挂在 **document** 并 `stopPropagation`:宿主画布的全局 Esc 挂在 window 上(清画布焦点 =
* 清选中 + 收浮层),document 在冒泡路径上早于 window,这里截断才能做到「Esc 只收替换面板、
* 不连带清画布选中」(与「浮层打开时 Escape 归浮层所有」同一口径)。
*/
useEffect(() => {
if (!open || !nonModal) {
return undefined;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return;
}
event.stopPropagation();
onCancel();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [nonModal, onCancel, open]);
const dialogLabel = `选择${selectionNoun}`;
const pickerFooter = (
<>
<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>
</>
);
const pickerBody = (
<>
{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(projectAssetPickerCategory(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 category = projectAssetPickerCategory(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(' ')}
>
<AssetCardMedia
asset={asset}
category={category}
renderAssetMedia={renderAssetMedia}
/>
</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}
</>
);
/**
* 非模态浮层:不铺遮罩、不抢焦点,宿主画布保持可点(AGC 的「替换素材」用它——面板开着时
* 直接在画布上点目标素材,点中的候选落进这里的当前选择,写入仍然只由「确认」发起)。
*/
if (nonModal) {
// 关掉就是卸载(模态那条路由 `UnifiedModal` 自己按 `open` 返回空):与弹窗同一判据,
// 否则宿主把 `open` 置回 false 之后画布右上角还留着半块面板。
if (!open) {
return null;
}
return (
<div
className="image-canvas-editor__project-asset-picker image-canvas-editor__project-asset-picker--floating"
role="dialog"
aria-label={dialogLabel}
>
<header className="image-canvas-editor__project-asset-picker-header">
<strong>{dialogLabel}</strong>
<PlatformModalCloseButton
variant="platformIcon"
placement="inline"
label={`关闭选择${selectionNoun}`}
onClick={onCancel}
/>
</header>
<div className="image-canvas-editor__project-asset-picker-scroll">
{pickerBody}
</div>
<footer className="image-canvas-editor__project-asset-picker-actions">
{pickerFooter}
</footer>
</div>
);
}
return (
<UnifiedModal
open={open}
title={dialogLabel}
size="lg"
portalTheme="light"
closeLabel={`关闭选择${selectionNoun}`}
onClose={onCancel}
panelClassName="image-canvas-editor__project-asset-picker"
bodyClassName="image-canvas-editor__project-asset-picker-body"
footer={pickerFooter}
>
{pickerBody}
</UnifiedModal>
);
}