修复 PR #316 review(前端 TS/TSX):图片编辑器素材弹窗

- ImageCanvasProjectAssetPickerDialog.tsx:打开时的重置 effect 只依赖 `open`,父级每次渲染重建 `selectedAssetIds` 不再清空进行中的搜索 / 筛选 / 选择
- ImageCanvasProjectAssetPickerDialog.tsx:素材卡媒体渲染抽成 `AssetCardMedia`,顺带去掉该处嵌套三元
- projectAssetReferencePickerModel.ts:`image-sequence` 单列一类(新增「序列帧」页签),不再混进「图片」——参考图链路只接受 `mediaType === 'image'`,混进去就是「按图片选中、按图片文案拒掉」;缩略图口径由 `projectAssetPickerUsesThumbnail` 收口
- 用例:父级重渲染不清空在选状态;序列帧分类归属与其被参考图链路过滤掉的事实
This commit is contained in:
2026-09-12 20:49:37 +08:00
parent d1581892d7
commit a9a5fba92b
4 changed files with 170 additions and 18 deletions
@@ -173,6 +173,59 @@ describe('ImageCanvasProjectAssetPickerDialog', () => {
expect(screen.getByText('已选 1 个')).toBeTruthy();
});
/**
* 弹窗里的搜索 / 分类 / 在选中的选择是用户正在做的工作,不能被父级的一次普通重渲染清掉。
*
* 调用方(`useImageCanvasGenerationSurface`)每次渲染都会重算 `selectedAssetIds`
* 把它的身份放进重置 effect 的依赖里,就等于「父级一渲染就清空」。
*
* 变异验证:把 `selectedAssetIds` 加回 effect 依赖,本用例必须失败。
*/
it('keeps the in-progress search and selection across parent re-renders', () => {
const { rerender } = render(
<ImageCanvasProjectAssetPickerDialog
open
assets={ASSETS}
selectedAssetIds={[]}
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
fireEvent.change(screen.getByLabelText('搜索参考图素材'), {
target: { value: '主角' },
});
fireEvent.click(screen.getByRole('option', { name: '选择参考图主角立绘' }));
expect(screen.getByText('已选 1 个')).toBeTruthy();
expect(
screen.queryByRole('option', { name: '选择参考图小镇背景' }),
).toBeNull();
// 父级重渲染:`selectedAssetIds` 换了一个新数组身份,内容不变。
rerender(
<ImageCanvasProjectAssetPickerDialog
open
assets={ASSETS}
selectedAssetIds={[...[]]}
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
expect(
(screen.getByLabelText('搜索参考图素材') as HTMLInputElement).value,
).toBe('主角');
expect(screen.getByText('已选 1 个')).toBeTruthy();
expect(
screen
.getByRole('option', { name: '选择参考图主角立绘' })
.getAttribute('aria-selected'),
).toBe('true');
expect(
screen.queryByRole('option', { name: '选择参考图小镇背景' }),
).toBeNull();
});
it('shows an empty state when the project has no registered assets', () => {
render(
<ImageCanvasProjectAssetPickerDialog
@@ -11,6 +11,8 @@ import {
projectAssetMatchesPickerFilter,
type ProjectAssetPickerCategory,
projectAssetPickerCategory,
type ProjectAssetPickerMediaCategory,
projectAssetPickerUsesThumbnail,
} from './projectAssetReferencePickerModel';
type ImageCanvasProjectAssetPickerDialogProps = {
@@ -58,8 +60,7 @@ type ImageCanvasProjectAssetPickerDialogProps = {
errorMessage?: string | null;
};
function assetIcon(asset: EditorAsset) {
const category = projectAssetPickerCategory(asset);
function assetIcon(category: ProjectAssetPickerCategory) {
if (category === 'video') {
return <Video className="h-4 w-4" aria-hidden="true" />;
}
@@ -69,6 +70,30 @@ function assetIcon(asset: EditorAsset) {
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);
}
/**
* 参考图选择弹窗。
*
@@ -94,12 +119,18 @@ export function ImageCanvasProjectAssetPickerDialog({
const [selection, setSelection] = useState<string[]>([]);
// 每次打开都从调用方给的初始选择重新开始,取消不写回。
//
// 只依赖 `open`:调用方每次渲染都会重建 `selectedAssetIds` 数组(例如
// `useImageCanvasGenerationSurface` 里是即时 `.filter().flatMap()`),把它放进依赖
// 会让「父级任何一次重渲染」都重新清空搜索词、分类与在选中的选择。
// `open` 翻成 true 的那一帧本身已经带着最新的初始选择,读到的就是它。
useEffect(() => {
if (!open) return;
setQuery('');
setCategory('all');
setSelection([...selectedAssetIds]);
}, [open, selectedAssetIds]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- 只在打开的那一帧重置
}, [open]);
const visibleAssets = useMemo(
() =>
@@ -195,7 +226,7 @@ export function ImageCanvasProjectAssetPickerDialog({
className="platform-category-chip gap-1 px-2 text-xs"
onClick={() => toggleAsset(asset.id)}
>
{assetIcon(asset)}
{assetIcon(projectAssetPickerCategory(asset))}
<span>{asset.label}</span>
</button>
))}
@@ -226,6 +257,7 @@ export function ImageCanvasProjectAssetPickerDialog({
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
@@ -262,17 +294,11 @@ export function ImageCanvasProjectAssetPickerDialog({
.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)
)}
<AssetCardMedia
asset={asset}
category={category}
renderAssetMedia={renderAssetMedia}
/>
</span>
<span
className={[
@@ -5,12 +5,16 @@ import type {
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import { replaceProjectAssetPickerReferences } from './ImageCanvasGenerationDialogModel';
import { createGenerationInputReference } from './ImageCanvasGenerationModel';
import {
appendLimitedImageReferences,
createGenerationInputReference,
} from './ImageCanvasGenerationModel';
import {
PROJECT_ASSET_PICKER_CATEGORY_OPTIONS,
projectAssetMatchesPickerFilter,
projectAssetPickerCategory,
projectAssetPickerReferences,
projectAssetPickerUsesThumbnail,
} from './projectAssetReferencePickerModel';
function createAsset(overrides: Partial<EditorAsset>): EditorAsset {
@@ -35,9 +39,54 @@ describe('参考图弹窗素材筛选与快照', () => {
expect(projectAssetPickerCategory({ mediaType: 'video' })).toBe('video');
expect(projectAssetPickerCategory({ mediaType: 'audio' })).toBe('audio');
expect(projectAssetPickerCategory({})).toBe('image');
// 序列帧单列一类:它不是静态图片,参考图链路也不接受它(见下一条用例)。
expect(projectAssetPickerCategory({ mediaType: 'image-sequence' })).toBe(
'image-sequence',
);
expect(
PROJECT_ASSET_PICKER_CATEGORY_OPTIONS.map((item) => item.id),
).toEqual(['all', 'image', 'video', 'audio']);
).toEqual(['all', 'image', 'video', 'audio', 'image-sequence']);
});
it('keeps image-sequence assets out of the image category', () => {
const sequence = createAsset({ mediaType: 'image-sequence' });
expect(
projectAssetMatchesPickerFilter(sequence, {
category: 'image',
query: '',
}),
).toBe(false);
expect(
projectAssetMatchesPickerFilter(sequence, {
category: 'image-sequence',
query: '',
}),
).toBe(true);
expect(
projectAssetMatchesPickerFilter(sequence, { category: 'all', query: '' }),
).toBe(true);
// 缩略图口径跟分类走:序列帧的 src 也是图片字节,照旧画出来。
expect(projectAssetPickerUsesThumbnail('image-sequence')).toBe(true);
expect(projectAssetPickerUsesThumbnail('image')).toBe(true);
expect(projectAssetPickerUsesThumbnail('video')).toBe(false);
expect(projectAssetPickerUsesThumbnail('audio')).toBe(false);
});
it('参考图链路只接受图片:序列帧引用会被 appendLimitedImageReferences 过滤掉', () => {
// 这条用例把「分类展示」与「参考图接受口径」的边界显式钉住:序列帧可以看见、
// 可以选中(与音频 / 视频一致),但不会作为参考图进入提交数据。
const sequence = createAsset({
id: 'asset-sequence',
mediaType: 'image-sequence',
label: '勇者挥手',
});
const references = projectAssetPickerReferences(
[sequence],
['asset-sequence'],
);
expect(references[0]?.mediaType).toBe('image-sequence');
expect(appendLimitedImageReferences(undefined, references, 4)).toEqual([]);
});
it('stacks the category filter with the search query', () => {
@@ -7,8 +7,17 @@ import {
/**
* 参考图弹窗的分类口径:只按素材的媒体类型分组,与画布的「功能分类」各自独立
* (画布用 manifest `category`,这里是编辑器素材库自己的可读分类)。
*
* `image-sequence` 单列一类,不并进「图片」:序列帧是逐帧动画资产,不是静态图片,
* 而参考图链路(`appendLimitedImageReferences`)只接受 `mediaType === 'image'`。
* 混进「图片」会变成「按图片选中、按图片的文案拒掉」——用户看到的分类与它为什么被拒
* 互相矛盾。单列之后行为与音频 / 视频一致:看得见、选得中、给了明确提示。
*/
export type ProjectAssetPickerMediaCategory = 'image' | 'video' | 'audio';
export type ProjectAssetPickerMediaCategory =
| 'image'
| 'video'
| 'audio'
| 'image-sequence';
export type ProjectAssetPickerCategory =
| 'all'
@@ -20,6 +29,7 @@ export const PROJECT_ASSET_PICKER_CATEGORY_OPTIONS: readonly PlatformResourceFil
{ id: 'image', label: '图片' },
{ id: 'video', label: '视频' },
{ id: 'audio', label: '音频' },
{ id: 'image-sequence', label: '序列帧' },
];
export function projectAssetPickerCategory(
@@ -28,9 +38,23 @@ export function projectAssetPickerCategory(
const mediaType = asset.mediaType ?? 'image';
if (mediaType === 'video') return 'video';
if (mediaType === 'audio') return 'audio';
if (mediaType === 'image-sequence') return 'image-sequence';
return 'image';
}
/**
* 卡片缩略图能不能直接画:图片与序列帧的 `src` 都是图片字节
* (序列帧是首帧 / 当前帧),其它媒体类型退回类型图标。
*
* 这条只决定「怎么画」,不决定「能不能当参考图」——后者由参考图链路按
* `mediaType === 'image'` 收口。
*/
export function projectAssetPickerUsesThumbnail(
category: ProjectAssetPickerMediaCategory,
): boolean {
return category === 'image' || category === 'image-sequence';
}
/**
* 弹窗筛选判据:分类 + 搜索词,两者叠加。
* 与资源画布、`@` 面板的筛选状态无关,调用方各自持有输入值。