修复 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:
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗筛选判据:分类 + 搜索词,两者叠加。
|
||||
* 与资源画布、`@` 面板的筛选状态无关,调用方各自持有输入值。
|
||||
|
||||
Reference in New Issue
Block a user