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 条目、待办文档登记三项并勾掉对应需求。
451 lines
17 KiB
TypeScript
451 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|