合并最新master到画板抠图背景分支

更新本地 master 到 origin/master 后合入当前 feat/BGfilter 分支。

解决决策记录和图片画布 MVP 文档冲突,保留画布 Agent 与 BGFilter 背景色说明。

补齐画布 Agent 调用生图接口时的默认 screenColor=auto 和 segModel=birefnet。
This commit is contained in:
2026-07-06 12:31:36 +00:00
221 changed files with 19875 additions and 1223 deletions
@@ -11,6 +11,7 @@ import { CreationLandingView } from './CreationLandingView';
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
const listPublicEditorProjectResourcesMock = vi.hoisted(() => vi.fn());
const createEditorProjectMock = vi.hoisted(() => vi.fn());
const toggleEditorShowcaseAssetLikeMock = vi.hoisted(() => vi.fn());
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
@@ -48,6 +49,7 @@ vi.mock('../../services/image-editor/editorProjectClient', () => ({
listEditorProjects: listEditorProjectsMock,
listPublicEditorProjectResources: listPublicEditorProjectResourcesMock,
createEditorProject: createEditorProjectMock,
toggleEditorShowcaseAssetLike: toggleEditorShowcaseAssetLikeMock,
}));
const projectItems = [
@@ -123,12 +125,10 @@ function installIntersectionObserverMock() {
return observers;
}
function triggerIntersection(
observer: {
callback: IntersectionObserverCallback;
instance: IntersectionObserver;
},
) {
function triggerIntersection(observer: {
callback: IntersectionObserverCallback;
instance: IntersectionObserver;
}) {
act(() => {
observer.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
@@ -146,6 +146,7 @@ describe('CreationLandingView', () => {
listEditorProjectsMock.mockReset();
listPublicEditorProjectResourcesMock.mockReset();
createEditorProjectMock.mockReset();
toggleEditorShowcaseAssetLikeMock.mockReset();
vi.unstubAllGlobals();
});
@@ -154,19 +155,20 @@ describe('CreationLandingView', () => {
renderCreationLanding();
expect(
screen.getByRole('main', { name: '陶泥儿创作主页' }),
).toBeTruthy();
expect(screen.getByRole('main', { name: '陶泥儿创作主页' })).toBeTruthy();
expect(
screen.getByRole('heading', {
name: '陶泥儿 - 开启全民精品游戏创作',
}),
).toBeTruthy();
expect(screen.getByText('登录即送100泥点,可以免费制作50个素材')).toBeTruthy();
expect(
screen.getByText('登录即送100泥点,可以免费制作50个素材'),
).toBeTruthy();
expect(screen.getByRole('heading', { name: '创作工具' })).toBeTruthy();
expect(screen.getByText('游戏视觉规范')).toBeTruthy();
expect(screen.getByRole('heading', { name: '陶泥儿精选' })).toBeTruthy();
expect(screen.getByRole('tab', { name: '素材包' })).toBeTruthy();
expect(screen.getByRole('tab', { name: '全部' })).toBeTruthy();
expect(screen.queryByRole('tab', { name: '素材包' })).toBeNull();
expect(screen.queryByRole('tab', { name: '特效' })).toBeNull();
expect(screen.queryByRole('tab', { name: '场景' })).toBeNull();
expect(screen.queryByRole('tab', { name: '游戏' })).toBeNull();
@@ -190,7 +192,9 @@ describe('CreationLandingView', () => {
</AuthUiContext.Provider>,
);
expect(await screen.findByRole('heading', { name: '最近项目' })).toBeTruthy();
expect(
await screen.findByRole('heading', { name: '最近项目' }),
).toBeTruthy();
expect(screen.getByText('最新项目')).toBeTruthy();
expect(screen.getByText('旧项目')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '查看全部' }));
@@ -299,10 +303,7 @@ describe('CreationLandingView', () => {
render(
<AuthUiContext.Provider value={createAuthValue()}>
<CreationLandingView
onOpenProject={vi.fn()}
onOpenProjects={vi.fn()}
/>
<CreationLandingView onOpenProject={vi.fn()} onOpenProjects={vi.fn()} />
</AuthUiContext.Provider>,
);
@@ -372,12 +373,75 @@ describe('CreationLandingView', () => {
const assetCard = await screen.findByText('角色英雄');
const card = assetCard.closest('.creation-landing__asset-card');
expect(card).toBeTruthy();
expect(within(card as HTMLElement).getByText('character hero prompt')).toBeTruthy();
expect(
within(card as HTMLElement).getByText('character hero prompt'),
).toBeTruthy();
expect(within(card as HTMLElement).getByText('创作者A')).toBeTruthy();
expect(within(card as HTMLElement).queryByText('user-internal-author')).toBeNull();
expect(
within(card as HTMLElement).queryByText('user-internal-author'),
).toBeNull();
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
});
it('likes a featured asset from the waterfall card action', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
resourceId: 'resource-character',
projectId: 'project-newest',
showcaseId: 'showcase-character',
label: '角色英雄',
imageSrc: 'data:image/png;base64,character',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'character hero prompt',
actualPrompt: null,
model: 'character-model',
provider: 'provider',
taskId: 'task-1',
showcaseCategory: 'characters',
likeCount: 3,
generationCostMudPoints: 20,
},
]);
toggleEditorShowcaseAssetLikeMock.mockResolvedValueOnce({
showcaseId: 'showcase-character',
likeCount: 4,
});
renderCreationLanding();
await user.click(screen.getByRole('tab', { name: '角色' }));
const likeButton = await screen.findByRole('button', {
name: '点赞:角色英雄',
});
expect(
likeButton.querySelector(
'img[src="/creation-home/showcase-like-thumb.png"]',
),
).toBeTruthy();
await user.click(likeButton);
await waitFor(() => {
expect(toggleEditorShowcaseAssetLikeMock).toHaveBeenCalledWith(
'showcase-character',
true,
);
});
expect(
screen.getByRole('button', { name: '取消点赞:角色英雄' }),
).toBeTruthy();
expect(
within(
screen
.getByText('角色英雄')
.closest('.creation-landing__asset-card') as HTMLElement,
).getByText('4'),
).toBeTruthy();
});
it('opens a fullscreen asset preview modal with thumbnail navigation', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
@@ -438,30 +502,68 @@ describe('CreationLandingView', () => {
expect(within(dialog).getByText('生成总成本')).toBeTruthy();
expect(within(dialog).getByText('60泥点')).toBeTruthy();
expect(within(dialog).getByText('创作者A')).toBeTruthy();
expect(
within(dialog).getByLabelText('素材缩略图导航'),
).toBeTruthy();
expect(within(dialog).getByLabelText('素材缩略图导航')).toBeTruthy();
await user.click(within(dialog).getByRole('button', { name: '查看待机动画' }));
await user.click(
within(dialog).getByRole('button', { name: '查看待机动画' }),
);
expect(
within(dialog).getByRole('button', { name: '查看待机动画' }).getAttribute(
'aria-current',
),
within(dialog)
.getByRole('button', { name: '查看待机动画' })
.getAttribute('aria-current'),
).toBe('true');
});
it('uses the unified audio cover image for featured audio assets', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
resourceId: 'resource-music',
projectId: 'project-newest',
label: '背景音乐',
imageSrc: '/generated-editor-audios/music.mp3',
objectKey: 'generated-editor-audios/music.mp3',
width: 1,
height: 1,
sourceType: 'generated',
prompt: '舒缓背景音乐',
actualPrompt: null,
model: 'audio1.0',
provider: 'ark',
taskId: 'task-audio-1',
assetKind: 'background-music',
priceMudPoints: 5,
},
]);
renderCreationLanding();
await user.click(screen.getByRole('tab', { name: '音乐' }));
const card = (await screen.findByText('背景音乐')).closest(
'.creation-landing__asset-card',
);
expect(card).toBeTruthy();
const image = within(card as HTMLElement).getByRole('img', {
name: '背景音乐',
});
expect(image.getAttribute('src')).toBe(
'/creation-home/audio-asset-cover.png',
);
});
it('uses Taonier featured as the generated project resource waterfall instead of creation entries', async () => {
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
resourceId: 'resource-pack-a',
projectId: 'project-newest',
label: '素材 A',
label: '精选素材 A',
imageSrc: 'data:image/png;base64,a',
width: 512,
height: 640,
sourceType: 'generated',
prompt: '素材提示词',
prompt: '精选素材提示词',
actualPrompt: null,
model: null,
provider: null,
@@ -484,7 +586,7 @@ describe('CreationLandingView', () => {
{
resourceId: 'resource-pack-b',
projectId: 'project-old',
label: '素材 B',
label: '精选素材 B',
imageSrc: 'data:image/png;base64,b',
width: 512,
height: 512,
@@ -499,12 +601,12 @@ describe('CreationLandingView', () => {
const { container } = renderCreationLanding();
expect(await screen.findByText('素材 A')).toBeTruthy();
expect(screen.getByText('素材 B')).toBeTruthy();
expect(await screen.findByText('精选素材 A')).toBeTruthy();
expect(screen.getByText('精选素材 B')).toBeTruthy();
expect(screen.queryByText('上传素材')).toBeNull();
expect(
screen.getByLabelText('用户素材瀑布流').className,
).toContain('creation-landing__asset-waterfall');
expect(screen.getByLabelText('用户素材瀑布流').className).toContain(
'creation-landing__asset-waterfall',
);
const firstPreview = container.querySelector(
'.creation-landing__asset-preview',
) as HTMLElement | null;
@@ -522,7 +624,7 @@ describe('CreationLandingView', () => {
{
resourceId: 'resource-pack-a',
projectId: 'project-newest',
label: '素材 A',
label: '精选素材 A',
imageSrc: 'data:image/png;base64,a',
width: 512,
height: 640,
@@ -541,7 +643,7 @@ describe('CreationLandingView', () => {
{
resourceId: 'resource-pack-b',
projectId: 'project-old',
label: '素材 B',
label: '精选素材 B',
imageSrc: 'data:image/png;base64,b',
width: 512,
height: 512,
@@ -558,12 +660,12 @@ describe('CreationLandingView', () => {
renderCreationLanding();
expect(await screen.findByText('素材 A')).toBeTruthy();
expect(await screen.findByText('精选素材 A')).toBeTruthy();
await waitFor(() => expect(observers).toHaveLength(1));
expect(screen.queryByText('素材 B')).toBeNull();
expect(screen.queryByText('精选素材 B')).toBeNull();
triggerIntersection(observers[0]!);
triggerIntersection(observers[0]!);
expect(await screen.findByText('素材 B')).toBeTruthy();
expect(await screen.findByText('精选素材 B')).toBeTruthy();
await waitFor(() =>
expect(listPublicEditorProjectResourcesMock).toHaveBeenLastCalledWith({
cursor: 'cursor-page-2',
@@ -581,7 +683,7 @@ describe('CreationLandingView', () => {
{
resourceId: 'resource-pack-a',
projectId: 'project-newest',
label: '素材 A',
label: '精选素材 A',
imageSrc: 'data:image/png;base64,a',
width: 512,
height: 640,
@@ -599,12 +701,14 @@ describe('CreationLandingView', () => {
renderCreationLanding();
expect(await screen.findByText('素材 A')).toBeTruthy();
expect(await screen.findByText('精选素材 A')).toBeTruthy();
await waitFor(() => expect(observers).toHaveLength(1));
triggerIntersection(observers[0]!);
expect(await screen.findByRole('button', { name: '读取更多素材失败,点击重试' })).toBeTruthy();
expect(screen.getByText('素材包 A')).toBeTruthy();
expect(
await screen.findByRole('button', { name: '读取更多素材失败,点击重试' }),
).toBeTruthy();
expect(screen.getByText('精选素材 A')).toBeTruthy();
expect(screen.getByLabelText('用户素材瀑布流')).toBeTruthy();
});
File diff suppressed because it is too large Load Diff
@@ -26,7 +26,7 @@ function createResource(
describe('creationShowcaseModel', () => {
it('only builds showcase items from generated project resources', () => {
const items = buildCreationShowcaseItems({
activeTab: 'packs',
activeTab: 'all',
projectResources: [
createResource({
resourceId: 'generated-1',
@@ -53,9 +53,9 @@ describe('creationShowcaseModel', () => {
expect(items[0]?.label).toBe('画布生成素材');
});
it('filters out generated resources that are not public showcase enabled', () => {
it('filters out generated showcase records with display disabled', () => {
const items = buildCreationShowcaseItems({
activeTab: 'packs',
activeTab: 'all',
projectResources: [
createResource({
resourceId: 'public-generated',
@@ -76,6 +76,74 @@ describe('creationShowcaseModel', () => {
expect(items[0]?.label).toBe('公开素材');
});
it('prepends enabled campaign cards and keeps showcase metrics', () => {
const items = buildCreationShowcaseItems({
activeTab: 'all',
campaign: {
enabled: true,
title: '活动精选',
imageSrc: '/campaign.png',
prompt: '活动提示词',
author: '运营',
costText: '50泥点',
},
projectResources: [
createResource({
resourceId: 'showcase-1',
showcaseId: 'showcase-1',
assetId: 'asset-1',
label: '审核通过素材',
sourceType: 'generated',
publicShowcaseEnabled: true,
likeCount: 12,
generationCostMudPoints: 20,
}),
],
});
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
id: 'campaign:global',
label: '活动精选',
campaign: true,
cost: '50泥点',
});
expect(items[1]).toMatchObject({
id: 'asset:asset-1',
label: '审核通过素材',
likeCount: 12,
showcaseId: 'showcase-1',
cost: '20泥点',
});
});
it('uses manually assigned showcase categories before asset kind grouping', () => {
const projectResources = [
createResource({
resourceId: 'manual-ui-category',
showcaseId: 'showcase-ui',
label: '人工分类 UI',
sourceType: 'generated',
assetKind: 'character',
showcaseCategory: 'ui',
generationCostMudPoints: 20,
}),
];
expect(
buildCreationShowcaseItems({
activeTab: 'ui',
projectResources,
}).map((item) => item.label),
).toEqual(['人工分类 UI']);
expect(
buildCreationShowcaseItems({
activeTab: 'characters',
projectResources,
}),
).toHaveLength(0);
});
it('uses public author fields without falling back to internal owner user id', () => {
const items = buildCreationShowcaseItems({
activeTab: 'characters',
@@ -147,9 +215,9 @@ describe('creationShowcaseModel', () => {
expect(items[0]?.previews[0]?.id).toBe('resource-original');
});
it('groups pack items by the same referenced spec image and sums known cost', () => {
it('shows every real asset in the all tab without requiring a category', () => {
const items = buildCreationShowcaseItems({
activeTab: 'packs',
activeTab: 'all',
projectResources: [
createResource({
resourceId: 'spec-1',
@@ -179,51 +247,55 @@ describe('creationShowcaseModel', () => {
],
});
expect(items).toHaveLength(1);
expect(items[0]?.label).toBe('角色规范图');
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
'角色规范图',
'角色主图',
]);
expect(items.map((item) => item.label)).toEqual(['角色规范图', '角色主图']);
expect(items[0]?.prompt).toBe('规范提示词');
expect(items[0]?.author).toBe('创作者A');
expect(items[0]?.cost).toBe('17泥点');
expect(items[0]?.cost).toBe('5泥点');
expect(items[1]?.cost).toBe('12泥点');
});
it('groups assets when references point to the source project resource', () => {
it('keeps unset category visible in all tab and out of concrete category tabs', () => {
const items = buildCreationShowcaseItems({
activeTab: 'packs',
activeTab: 'all',
projectResources: [
createResource({
resourceId: 'spec-1',
label: '项目规范图',
label: '未设置分类素材',
assetKind: 'spec',
sourceResourceId: 'resource-spec-1',
showcaseCategory: null,
}),
createResource({
resourceId: 'character-1',
label: '项目角色图',
resourceId: 'ui-1',
label: '人工 UI 素材',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '项目角色提示词' }],
references: [
{
title: '角色规范',
label: '项目规范图',
refType: 'project-resource',
refId: 'resource-spec-1',
},
],
},
showcaseCategory: 'ui',
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.previews.map((preview) => preview.label)).toEqual([
'项目规范图',
'项目角色图',
expect(items.map((item) => item.label)).toEqual([
'未设置分类素材',
'人工 UI 素材',
]);
expect(
buildCreationShowcaseItems({
activeTab: 'ui',
projectResources: [
createResource({
resourceId: 'spec-1',
label: '未设置分类素材',
assetKind: 'spec',
showcaseCategory: null,
}),
createResource({
resourceId: 'ui-1',
label: '人工 UI 素材',
assetKind: 'character',
showcaseCategory: 'ui',
}),
],
}).map((item) => item.label),
).toEqual(['人工 UI 素材']);
});
it('groups character animations under the referenced character image', () => {
@@ -390,6 +462,9 @@ describe('creationShowcaseModel', () => {
expect(items).toHaveLength(1);
expect(items[0]?.previews[0]?.mediaType).toBe('audio');
expect(items[0]?.previews[0]?.coverSrc).toBe(
'/creation-home/audio-asset-cover.png',
);
expect(items[0]?.author).toBe('当前用户');
expect(items[0]?.cost).toBe('5泥点');
});
@@ -2,6 +2,7 @@ import type {
EditorAssetGenerationInputReference,
EditorAssetSnapshot,
EditorProjectResourceSnapshot,
EditorShowcaseCampaignSnapshot,
} from '../../services/image-editor/editorProjectClient';
import {
calculateCharacterAnimationPrice,
@@ -16,12 +17,7 @@ import {
DEFAULT_VIDEO_MODEL,
} from '../image-editor/ImageCanvasGenerationModel';
export type ShowcaseTabId =
| 'packs'
| 'characters'
| 'ui'
| 'music'
| 'marketing';
export type ShowcaseTabId = 'all' | 'characters' | 'ui' | 'music' | 'marketing';
export type ShowcaseAssetMediaType = 'image' | 'video' | 'audio';
@@ -29,6 +25,7 @@ export type ShowcaseAssetPreview = {
id: string;
label: string;
src: string;
coverSrc?: string | null;
objectKey?: string | null;
mediaType: ShowcaseAssetMediaType;
width?: number;
@@ -42,6 +39,9 @@ export type ShowcaseAssetItem = {
prompt: string;
author: string;
cost: string;
likeCount?: number | null;
showcaseId?: string | null;
campaign?: boolean;
};
type ShowcaseAssetGroup = {
@@ -51,6 +51,7 @@ type ShowcaseAssetGroup = {
};
const UNKNOWN_META_VALUE = '-';
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
const SHOWCASE_COST_KEYS = [
'priceMudPoints',
@@ -59,16 +60,23 @@ const SHOWCASE_COST_KEYS = [
'mudPointCost',
] as const;
const SHOWCASE_AUTHOR_KEYS = ['authorDisplayName', 'authorPublicUserCode'] as const;
const SHOWCASE_AUTHOR_KEYS = [
'authorDisplayName',
'authorPublicUserCode',
] as const;
export const SHOWCASE_TABS: Array<{ id: ShowcaseTabId; label: string }> = [
{ id: 'packs', label: '素材包' },
{ id: 'all', label: '全部' },
{ id: 'characters', label: '角色' },
{ id: 'ui', label: 'UI' },
{ id: 'music', label: '音乐' },
{ id: 'marketing', label: '美宣' },
];
const SHOWCASE_CATEGORY_IDS = new Set(
SHOWCASE_TABS.filter((tab) => tab.id !== 'all').map((tab) => tab.id),
);
function assetRecord(asset: EditorAssetSnapshot) {
return asset as Record<string, unknown>;
}
@@ -175,7 +183,10 @@ function projectResourceToShowcaseAsset(
return {
...record,
assetId: resource.resourceId,
assetId:
resource.assetId?.trim() ||
resource.showcaseId?.trim() ||
resource.resourceId,
folderId: `project:${resource.projectId}`,
label: fallbackLabel,
imageSrc: resource.imageSrc,
@@ -192,12 +203,27 @@ function projectResourceToShowcaseAsset(
durationSeconds: resource.durationSeconds,
sourceResourceId: resource.sourceResourceId,
assetKind: resource.assetKind,
showcaseCategory: normalizeShowcaseCategory(
normalizeText(record.showcaseCategory),
),
generationInputs: resource.generationInputs,
createdAt: resource.createdAt,
updatedAt: resource.updatedAt,
} as EditorAssetSnapshot;
}
function normalizeShowcaseCategory(value: string): ShowcaseTabId | null {
return SHOWCASE_CATEGORY_IDS.has(value as Exclude<ShowcaseTabId, 'all'>)
? (value as ShowcaseTabId)
: null;
}
function resolveAssetShowcaseCategory(asset: EditorAssetSnapshot) {
return normalizeShowcaseCategory(
normalizeText(assetRecord(asset).showcaseCategory),
);
}
function normalizeShowcaseMediaRef(value: string | null | undefined) {
const trimmedValue = value?.trim().split(/[?#]/u)[0]?.trim() ?? '';
if (!trimmedValue) {
@@ -286,11 +312,15 @@ function findSpecReference(asset: EditorAssetSnapshot) {
}
function findCharacterReference(asset: EditorAssetSnapshot) {
return findReference(asset, (reference) => /|/u.test(reference.title));
return findReference(asset, (reference) =>
/|/u.test(reference.title),
);
}
function findUiDesignReference(asset: EditorAssetSnapshot) {
return findReference(asset, (reference) => /UI|UI |UI/u.test(reference.title));
return findReference(asset, (reference) =>
/UI|UI |UI/u.test(reference.title),
);
}
function isAssetKind(asset: EditorAssetSnapshot, kinds: readonly string[]) {
@@ -315,7 +345,9 @@ function isVideoAsset(asset: EditorAssetSnapshot) {
);
}
function resolveAssetMediaType(asset: EditorAssetSnapshot): ShowcaseAssetMediaType {
function resolveAssetMediaType(
asset: EditorAssetSnapshot,
): ShowcaseAssetMediaType {
if (isAudioAsset(asset)) {
return 'audio';
}
@@ -414,11 +446,16 @@ function mergeAssetAliasGroups(
function sortGroupAssets(assets: EditorAssetSnapshot[]) {
return [...assets].sort((left, right) => {
const leftRank = isSpecAsset(left) || isCharacterAsset(left) || isAssetKind(left, ['ui-design'])
? 0
: 1;
const leftRank =
isSpecAsset(left) ||
isCharacterAsset(left) ||
isAssetKind(left, ['ui-design'])
? 0
: 1;
const rightRank =
isSpecAsset(right) || isCharacterAsset(right) || isAssetKind(right, ['ui-design'])
isSpecAsset(right) ||
isCharacterAsset(right) ||
isAssetKind(right, ['ui-design'])
? 0
: 1;
if (leftRank !== rightRank) {
@@ -428,33 +465,6 @@ function sortGroupAssets(assets: EditorAssetSnapshot[]) {
});
}
function groupPackAssets(assets: EditorAssetSnapshot[]) {
const groups = new Map<string, ShowcaseAssetGroup>();
assets.forEach((asset) => {
const specReference = findSpecReference(asset);
if (specReference) {
addAssetToGroup(groups, getReferenceKey(specReference), asset, specReference.label);
return;
}
if (isSpecAsset(asset)) {
addAssetToGroup(groups, getSelfAssetKey(asset), asset, asset.label);
return;
}
addAssetToGroup(groups, `standalone:${asset.assetId}`, asset, asset.label);
});
const specAssets = assets.filter(isSpecAsset);
specAssets.forEach((asset) => {
const group = mergeAssetAliasGroups(groups, asset, asset.label);
if (group && !group.assets.some((groupAsset) => groupAsset.assetId === asset.assetId)) {
group.assets.unshift(asset);
}
});
return [...groups.values()].map((group) => ({
...group,
assets: sortGroupAssets(group.assets),
}));
}
function groupCharacterAssets(assets: EditorAssetSnapshot[]) {
const groups = new Map<string, ShowcaseAssetGroup>();
assets
@@ -479,12 +489,20 @@ function groupCharacterAssets(assets: EditorAssetSnapshot[]) {
);
return;
}
addAssetToGroup(groups, `standalone:${asset.assetId}`, asset, asset.label);
});
addAssetToGroup(
groups,
`standalone:${asset.assetId}`,
asset,
asset.label,
);
});
const characterAssets = assets.filter(isCharacterAsset);
characterAssets.forEach((asset) => {
const group = mergeAssetAliasGroups(groups, asset, asset.label);
if (group && !group.assets.some((groupAsset) => groupAsset.assetId === asset.assetId)) {
if (
group &&
!group.assets.some((groupAsset) => groupAsset.assetId === asset.assetId)
) {
group.assets.unshift(asset);
}
});
@@ -497,23 +515,43 @@ function groupCharacterAssets(assets: EditorAssetSnapshot[]) {
function groupUiAssets(assets: EditorAssetSnapshot[]) {
const groups = new Map<string, ShowcaseAssetGroup>();
assets
.filter((asset) => isUiAsset(asset) || /\bui\b|||icon/u.test(getAssetHaystack(asset)))
.filter(
(asset) =>
isUiAsset(asset) ||
/\bui\b|||icon/u.test(getAssetHaystack(asset)),
)
.forEach((asset) => {
if (isAssetKind(asset, ['ui-design'])) {
addAssetToGroup(groups, getSelfAssetKey(asset), asset, asset.label);
return;
}
const uiReference = findUiDesignReference(asset) ?? findSpecReference(asset);
const uiReference =
findUiDesignReference(asset) ?? findSpecReference(asset);
if (uiReference) {
addAssetToGroup(groups, getReferenceKey(uiReference), asset, uiReference.label);
addAssetToGroup(
groups,
getReferenceKey(uiReference),
asset,
uiReference.label,
);
return;
}
addAssetToGroup(groups, `standalone:${asset.assetId}`, asset, asset.label);
});
const uiDesignAssets = assets.filter((asset) => isAssetKind(asset, ['ui-design']));
addAssetToGroup(
groups,
`standalone:${asset.assetId}`,
asset,
asset.label,
);
});
const uiDesignAssets = assets.filter((asset) =>
isAssetKind(asset, ['ui-design']),
);
uiDesignAssets.forEach((asset) => {
const group = mergeAssetAliasGroups(groups, asset, asset.label);
if (group && !group.assets.some((groupAsset) => groupAsset.assetId === asset.assetId)) {
if (
group &&
!group.assets.some((groupAsset) => groupAsset.assetId === asset.assetId)
) {
group.assets.unshift(asset);
}
});
@@ -535,8 +573,14 @@ function groupSingleAssets(
}
function getGroupsForTab(assets: EditorAssetSnapshot[], tab: ShowcaseTabId) {
if (tab === 'packs') {
return groupPackAssets(assets);
if (tab === 'all') {
return groupSingleAssets(assets, () => true);
}
if (assets.some((asset) => resolveAssetShowcaseCategory(asset))) {
return groupSingleAssets(
assets,
(asset) => resolveAssetShowcaseCategory(asset) === tab,
);
}
if (tab === 'characters') {
return groupCharacterAssets(assets);
@@ -561,13 +605,18 @@ function resolveAssetPrompt(asset: EditorAssetSnapshot) {
const generationFields = asset.generationInputs?.fields ?? [];
const firstPromptField =
generationFields.find((field) => /|||prompt/u.test(field.title)) ??
generationFields[0];
generationFields.find((field) =>
/|||prompt/u.test(field.title),
) ?? generationFields[0];
return firstPromptField?.value?.trim() || UNKNOWN_META_VALUE;
}
function resolveGroupPrompt(assets: EditorAssetSnapshot[]) {
return assets.map(resolveAssetPrompt).find((value) => value !== UNKNOWN_META_VALUE) ?? UNKNOWN_META_VALUE;
return (
assets
.map(resolveAssetPrompt)
.find((value) => value !== UNKNOWN_META_VALUE) ?? UNKNOWN_META_VALUE
);
}
function resolveAssetAuthor(
@@ -645,7 +694,12 @@ function inferAssetCost(asset: EditorAssetSnapshot) {
| '480p'
| '720p'
| '1080p',
normalizeInt(asset.durationSeconds, DEFAULT_VIDEO_DURATION_SECONDS, 4, 15),
normalizeInt(
asset.durationSeconds,
DEFAULT_VIDEO_DURATION_SECONDS,
4,
15,
),
);
}
if (asset.sourceType === 'generated' || kind) {
@@ -688,12 +742,14 @@ function resolveGroupCost(assets: EditorAssetSnapshot[]) {
}
function toAssetPreview(asset: EditorAssetSnapshot): ShowcaseAssetPreview {
const mediaType = resolveAssetMediaType(asset);
return {
id: asset.assetId,
label: asset.label,
src: asset.imageSrc,
coverSrc: mediaType === 'audio' ? AUDIO_ASSET_COVER_SRC : null,
objectKey: asset.objectKey,
mediaType: resolveAssetMediaType(asset),
mediaType,
width: asset.width,
height: asset.height,
};
@@ -704,11 +760,16 @@ function toLibraryShowcaseItem(
fallbackAuthorName?: string | null,
): ShowcaseAssetItem | null {
const sortedAssets = sortGroupAssets(group.assets);
const previews = sortedAssets.map(toAssetPreview).filter((preview) => preview.src.trim());
const previews = sortedAssets
.map(toAssetPreview)
.filter((preview) => preview.src.trim());
if (!previews.length) {
return null;
}
const primaryAsset = sortedAssets[0];
if (!primaryAsset) {
return null;
}
const label =
group.label?.trim() ||
primaryAsset?.label?.trim() ||
@@ -720,16 +781,48 @@ function toLibraryShowcaseItem(
prompt: resolveGroupPrompt(sortedAssets),
author: resolveGroupAuthor(sortedAssets, fallbackAuthorName),
cost: resolveGroupCost(sortedAssets),
likeCount: normalizeNumber(assetRecord(primaryAsset).likeCount),
showcaseId: normalizeText(assetRecord(primaryAsset).showcaseId),
};
}
function campaignToShowcaseItem(
campaign?: EditorShowcaseCampaignSnapshot | null,
): ShowcaseAssetItem | null {
const objectKey = campaign?.imageObjectKey?.trim().replace(/^\/+/u, '') ?? '';
const imageSrc =
campaign?.imageSrc.trim() || (objectKey ? `/${objectKey}` : '');
if (!campaign?.enabled || !imageSrc) {
return null;
}
return {
id: 'campaign:global',
label: campaign.title.trim() || '陶泥儿精选',
previews: [
{
id: 'campaign:global:image',
label: campaign.title.trim() || '陶泥儿精选',
src: imageSrc,
objectKey: objectKey || null,
mediaType: 'image',
},
],
prompt: campaign.prompt.trim() || UNKNOWN_META_VALUE,
author: campaign.author.trim() || UNKNOWN_META_VALUE,
cost: campaign.costText.trim() || UNKNOWN_META_VALUE,
campaign: true,
};
}
export function buildCreationShowcaseItems({
activeTab,
projectResources,
campaign,
fallbackAuthorName,
}: {
activeTab: ShowcaseTabId;
projectResources: EditorProjectResourceSnapshot[];
campaign?: EditorShowcaseCampaignSnapshot | null;
fallbackAuthorName?: string | null;
}) {
const projectItems = getGroupsForTab(
@@ -739,5 +832,6 @@ export function buildCreationShowcaseItems({
.map((group) => toLibraryShowcaseItem(group, fallbackAuthorName))
.filter((item): item is ShowcaseAssetItem => item !== null);
return projectItems;
const campaignItem = campaignToShowcaseItem(campaign);
return campaignItem ? [campaignItem, ...projectItems] : projectItems;
}
@@ -0,0 +1,411 @@
/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView';
import type { EditorAgentConversationClient } from './useEditorAgentConversation';
function createClient(): EditorAgentConversationClient {
return {
listConversations: vi.fn().mockResolvedValue([
{
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
},
]),
createConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '新对话',
messages: [],
createdAt: '2026-07-03T00:01:00.000Z',
updatedAt: '2026-07-03T00:01:00.000Z',
}),
getConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [
{
id: 'assistant-old',
role: 'assistant',
kind: 'chat',
text: '已经看到画布内容',
attachments: [],
generations: [],
status: 'completed',
createdAt: '2026-07-03T00:00:10.000Z',
},
],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
}),
deleteConversation: vi.fn().mockResolvedValue({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:00.000Z',
}),
streamMessage: vi
.fn()
.mockImplementation(async (_conversationId, _payload, options) => {
options.onEvent?.({
event: 'stage',
data: { conversationId: _conversationId, stage: 'responding' },
});
options.onEvent?.({
event: 'message_delta',
data: {
conversationId: _conversationId,
messageId: 'assistant-stream',
role: 'assistant',
kind: 'chat',
textDelta: '收到,我会参考这张图。',
},
});
options.onEvent?.({
event: 'done',
data: { conversationId: _conversationId, title: null },
});
}),
};
}
describe('EditorAgentConversationPanelView', () => {
it('manages conversations, attachments and message sending inside the panel', async () => {
const client = createClient();
render(
<EditorAgentConversationPanelView
projectId="project-1"
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-1',
resourceId: 'resource-1',
title: '角色图层',
src: '/generated/role.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated',
},
{
id: 'layer-local-only',
resourceId: '',
title: '未入库图层',
src: '/local-only.png',
x: 0,
y: 0,
width: 256,
height: 256,
originalWidth: 256,
originalHeight: 256,
zIndex: 2,
sourceType: 'uploaded',
},
]}
assets={[
{
id: 'asset-1',
label: '素材库角色',
src: '/assets/role.png',
width: 512,
height: 512,
folderId: 'project',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
},
]}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
await waitFor(() => {
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
expect(
within(attachmentDialog).queryByRole('checkbox', {
name: '选择画布图片 未入库图层',
}),
).toBeNull();
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: '选择画布图片 角色图层',
}),
);
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
expect(screen.getByText('角色图层')).toBeTruthy();
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '参考附件做像素风' },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(screen.getByText('收到,我会参考这张图。')).toBeTruthy();
});
expect(client.streamMessage).toHaveBeenCalledWith(
'conversation-2',
expect.objectContaining({
text: '参考附件做像素风',
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-1',
}),
],
}),
expect.any(Object),
);
});
it('sends selected attachments even when the text input is empty', async () => {
const client = createClient();
render(
<EditorAgentConversationPanelView
projectId="project-1"
open
onToggleOpen={vi.fn()}
client={client}
layers={[
{
id: 'layer-1',
resourceId: 'resource-1',
title: '角色图层',
src: '/generated/role.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'generated',
},
]}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
});
fireEvent.click(
within(attachmentDialog).getByRole('checkbox', {
name: '选择画布图片 角色图层',
}),
);
fireEvent.click(
within(attachmentDialog).getByRole('button', { name: '应用' }),
);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(client.streamMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
text: '',
attachments: [
expect.objectContaining({
source: 'canvas_resource',
referenceId: 'resource-1',
}),
],
}),
expect.any(Object),
);
});
});
it('keeps streamed reply and generating state when the panel is collapsed and reopened', async () => {
const client = createClient();
let finishStream = () => {};
vi.mocked(client.streamMessage).mockImplementation(
async (conversationId, _payload, options) => {
options.onEvent?.({
event: 'message_delta',
data: {
conversationId,
messageId: 'assistant-stream',
role: 'assistant',
kind: 'chat',
textDelta: '我来生成图片。',
},
});
options.onEvent?.({
event: 'stage',
data: { conversationId, stage: 'generating' },
});
options.onEvent?.({
event: 'tool_started',
data: {
conversationId,
messageId: 'assistant-stream',
toolCallId: 'tool-call-generating',
toolName: 'generate_image',
taskId: 'task-generating',
model: 'gpt-image-2',
},
});
await new Promise<void>((resolve) => {
finishStream = resolve;
});
},
);
const { rerender } = render(
<EditorAgentConversationPanelView
projectId="project-1"
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '生成一张图片' },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(screen.getByText('我来生成图片。')).toBeTruthy();
});
expect(screen.getByText('生成中')).toBeTruthy();
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy();
rerender(
<EditorAgentConversationPanelView
projectId="project-1"
open={false}
onToggleOpen={vi.fn()}
client={client}
/>,
);
expect(screen.getByRole('button', { name: '打开画布 Agent' })).toBeTruthy();
rerender(
<EditorAgentConversationPanelView
projectId="project-1"
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
expect(screen.getByText('我来生成图片。')).toBeTruthy();
expect(screen.getByText('生成中')).toBeTruthy();
expect(screen.getByRole('button', { name: '停止' })).toBeTruthy();
await act(async () => {
finishStream();
});
});
it('keeps wheel scrolling inside the message history and input', async () => {
const client = createClient();
const parentWheel = vi.fn();
render(
<div onWheel={parentWheel}>
<EditorAgentConversationPanelView
projectId="project-1"
open
onToggleOpen={vi.fn()}
client={client}
/>
</div>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
const messageWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
});
fireEvent(
screen.getByRole('log', { name: '画布 Agent 消息流' }),
messageWheel,
);
expect(parentWheel).not.toHaveBeenCalled();
expect(messageWheel.defaultPrevented).toBe(false);
const inputWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
});
fireEvent(screen.getByLabelText('发送给画布 Agent'), inputWheel);
expect(parentWheel).not.toHaveBeenCalled();
expect(inputWheel.defaultPrevented).toBe(false);
});
it('confirms conversation deletion from an independent dialog', async () => {
const client = createClient();
render(
<EditorAgentConversationPanelView
projectId="project-1"
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '删除当前对话' }));
const confirmDialog = screen.getByRole('dialog', { name: '删除对话' });
fireEvent.click(
within(confirmDialog).getByRole('button', { name: '确认删除' }),
);
await waitFor(() => {
expect(client.deleteConversation).toHaveBeenCalledWith('conversation-1');
});
});
});
File diff suppressed because it is too large Load Diff
@@ -60,10 +60,7 @@ type ImageCanvasAssetFolderSectionViewProps = {
startRenamingAsset: (asset: EditorAsset) => void;
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
setAssetPublicShowcaseEnabled: (
asset: EditorAsset,
publicShowcaseEnabled: boolean,
) => void;
submitAssetToShowcase: (asset: EditorAsset) => void;
toggleAssetSelected: (assetId: string) => void;
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
@@ -95,7 +92,7 @@ export function ImageCanvasAssetFolderSectionView({
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
submitAssetToShowcase,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset,
@@ -255,7 +252,7 @@ export function ImageCanvasAssetFolderSectionView({
startRenamingAsset={startRenamingAsset}
commitAssetRename={commitAssetRename}
deleteUploadedAsset={deleteUploadedAsset}
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
submitAssetToShowcase={submitAssetToShowcase}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
onDownloadAsset={onDownloadAsset}
@@ -90,10 +90,7 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
startRenamingAsset: (asset: EditorAsset) => void;
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
setAssetPublicShowcaseEnabled: (
asset: EditorAsset,
publicShowcaseEnabled: boolean,
) => void;
submitAssetToShowcase: (asset: EditorAsset) => void;
toggleAssetSelected: (assetId: string) => void;
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
@@ -141,7 +138,7 @@ export function ImageCanvasAssetLibraryPanelView({
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
submitAssetToShowcase,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset,
@@ -233,7 +230,7 @@ export function ImageCanvasAssetLibraryPanelView({
startRenamingAsset={startRenamingAsset}
commitAssetRename={commitAssetRename}
deleteUploadedAsset={deleteUploadedAsset}
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
submitAssetToShowcase={submitAssetToShowcase}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
onDownloadAsset={onDownloadAsset}
@@ -24,23 +24,33 @@ vi.mock('../common/PlatformMediaFrame', () => ({
objectKey?: string | null;
alt: string;
previewOverlay?: ReactNode;
}) => (
<div className="platform-media-frame">
{src?.trim() ? (
<img
src={src}
alt={alt}
data-object-key={objectKey?.trim() || undefined}
/>
) : null}
{previewOverlay}
</div>
),
}) => {
const imageSrc =
src?.trim() ||
(objectKey?.trim()
? `https://signed.example.com/${objectKey.trim()}`
: '');
return (
<div className="platform-media-frame">
{imageSrc ? (
<img
src={imageSrc}
alt={alt}
data-object-key={objectKey?.trim() || undefined}
/>
) : null}
{previewOverlay}
</div>
);
},
}));
beforeEach(() => {
useResolvedAssetReadUrlMock.mockImplementation(
(source: string | null | undefined, options?: { objectKey?: string | null }) => ({
(
source: string | null | undefined,
options?: { objectKey?: string | null },
) => ({
resolvedUrl: options?.objectKey
? `https://signed.example.com/${options.objectKey}`
: (source ?? ''),
@@ -75,7 +85,7 @@ function renderAssetRow({
toggleAssetSelected = vi.fn(),
commitAssetRename = vi.fn(),
deleteUploadedAsset = vi.fn(),
setAssetPublicShowcaseEnabled = vi.fn(),
submitAssetToShowcase = vi.fn(),
onDownloadAsset = vi.fn(),
}: {
asset?: EditorAsset;
@@ -87,10 +97,7 @@ function renderAssetRow({
toggleAssetSelected?: (assetId: string) => void;
commitAssetRename?: (asset: EditorAsset) => void;
deleteUploadedAsset?: (asset: EditorAsset) => void;
setAssetPublicShowcaseEnabled?: (
asset: EditorAsset,
publicShowcaseEnabled: boolean,
) => void;
submitAssetToShowcase?: (asset: EditorAsset) => void;
onDownloadAsset?: (asset: EditorAsset) => void;
} = {}) {
const props = {
@@ -110,7 +117,7 @@ function renderAssetRow({
startRenamingAsset: vi.fn(),
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
submitAssetToShowcase,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset,
@@ -136,9 +143,7 @@ describe('ImageCanvasAssetRowView', () => {
const onDownloadAsset = vi.fn();
renderAssetRow({ asset, onDownloadAsset });
fireEvent.click(
screen.getByRole('button', { name: '下载素材账号素材A' }),
);
fireEvent.click(screen.getByRole('button', { name: '下载素材账号素材A' }));
expect(onDownloadAsset).toHaveBeenCalledWith(asset);
});
@@ -152,20 +157,26 @@ describe('ImageCanvasAssetRowView', () => {
const onDownloadAsset = vi.fn();
renderAssetRow({ asset, addAssetLayer, onDownloadAsset });
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
});
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
expect(screen.getByRole('menu')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '下载素材账号素材A' }));
expect(onDownloadAsset).toHaveBeenCalledWith(asset);
expect(screen.queryByRole('menu')).toBeNull();
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
});
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
fireEvent.click(screen.getByRole('button', { name: '添加账号素材A' }));
expect(addAssetLayer).toHaveBeenCalledWith(asset);
expect(screen.queryByRole('menu')).toBeNull();
@@ -197,7 +208,9 @@ describe('ImageCanvasAssetRowView', () => {
fireEvent.keyDown(input, { key: 'Enter' });
expect(input.className).toContain('platform-text-field');
expect(input.className).toContain('image-canvas-editor__asset-rename-input');
expect(input.className).toContain(
'image-canvas-editor__asset-rename-input',
);
expect(commitAssetRename).toHaveBeenCalledWith(
expect.objectContaining({ id: 'asset-1' }),
);
@@ -224,7 +237,7 @@ describe('ImageCanvasAssetRowView', () => {
).toBe('42');
});
it('uses icon thumbnails for audio assets instead of rendering media as images', () => {
it('uses the audio cover thumbnail for audio assets', () => {
renderAssetRow({
asset: createAsset({
label: '胜利音效.mp3',
@@ -234,13 +247,13 @@ describe('ImageCanvasAssetRowView', () => {
});
expect(screen.getByText('音频')).toBeTruthy();
expect(screen.queryByRole('img', { name: '素材:胜利音效.mp3' })).toBeNull();
const image = screen.getByRole('img', { name: '素材:胜利音效.mp3' });
expect(image.getAttribute('src')).toBe(
'/creation-home/audio-asset-cover.png',
);
expect(
document.querySelector('.image-canvas-editor__asset-media-overlay'),
).toBeTruthy();
expect(
document.querySelector('.image-canvas-editor__media-preview--audio'),
).toBeTruthy();
).toBeNull();
});
it('uses a stable visual preview for video asset cards without image thumbnails', () => {
@@ -248,13 +261,16 @@ describe('ImageCanvasAssetRowView', () => {
asset: createAsset({
label: '生成视频.mp4',
src: '/generated-character-drafts/editor/asset-library/video/demo.mp4',
objectKey: 'generated-character-drafts/editor/asset-library/video/demo.mp4',
objectKey:
'generated-character-drafts/editor/asset-library/video/demo.mp4',
mediaType: 'video',
}),
});
expect(screen.getByText('视频')).toBeTruthy();
expect(screen.queryByRole('img', { name: '素材:生成视频.mp4' })).toBeNull();
expect(
screen.queryByRole('img', { name: '素材:生成视频.mp4' }),
).toBeNull();
const video = screen.getByLabelText(
'视频素材预览:生成视频.mp4',
) as HTMLVideoElement;
@@ -280,83 +296,107 @@ describe('ImageCanvasAssetRowView', () => {
);
});
it('renders image thumbnails when the persisted asset only has objectKey', () => {
renderAssetRow({
asset: createAsset({
label: '规范图',
src: '',
objectKey: 'generated-character-drafts/editor/spec.png',
mediaType: 'image',
}),
});
const image = screen.getByRole('img', { name: '素材:规范图' });
expect(image.getAttribute('src')).toBe(
'https://signed.example.com/generated-character-drafts/editor/spec.png',
);
});
it('opens asset actions from the right click menu', () => {
const asset = createAsset({
sourceResourceId: 'resource-asset-1',
publicShowcaseEnabled: true,
sourceType: 'generated',
});
const deleteUploadedAsset = vi.fn();
const setAssetPublicShowcaseEnabled = vi.fn();
const submitAssetToShowcase = vi.fn();
renderAssetRow({
asset,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
submitAssetToShowcase,
});
expect(
screen.queryByRole('button', { name: '删除素材账号素材A' }),
).toBeNull();
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
const submitMenuItem = screen.getByRole('menuitem', {
name: //u,
});
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
name: //u,
});
expect(publicMenuItem.getAttribute('aria-checked')).toBe('true');
fireEvent.click(submitMenuItem);
expect(submitAssetToShowcase).toHaveBeenCalledWith(asset);
fireEvent.click(publicMenuItem);
expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false);
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
});
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
expect(deleteUploadedAsset).toHaveBeenCalledWith(asset);
});
it('disables public showcase toggle when the asset is not linked to a project resource', () => {
const setAssetPublicShowcaseEnabled = vi.fn();
renderAssetRow({ setAssetPublicShowcaseEnabled });
it('disables showcase submit when the asset is not generated', () => {
const submitAssetToShowcase = vi.fn();
renderAssetRow({ submitAssetToShowcase });
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
const submitMenuItem = screen.getByRole('menuitem', {
name: //u,
});
expect((submitMenuItem as HTMLButtonElement).disabled).toBe(true);
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
name: //u,
});
expect((publicMenuItem as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(publicMenuItem);
expect(setAssetPublicShowcaseEnabled).not.toHaveBeenCalled();
fireEvent.click(submitMenuItem);
expect(submitAssetToShowcase).not.toHaveBeenCalled();
});
it('keeps public showcase toggle enabled for legacy generated assets once a source resource is inferred', () => {
it('enables showcase submit for persisted generated assets', () => {
const asset = createAsset({
sourceType: 'generated',
sourceResourceId: 'resource-inferred-from-media',
publicShowcaseEnabled: null,
});
const setAssetPublicShowcaseEnabled = vi.fn();
renderAssetRow({ asset, setAssetPublicShowcaseEnabled });
const submitAssetToShowcase = vi.fn();
renderAssetRow({ asset, submitAssetToShowcase });
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
const submitMenuItem = screen.getByRole('menuitem', {
name: //u,
});
expect((submitMenuItem as HTMLButtonElement).disabled).toBe(false);
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
name: //u,
});
expect((publicMenuItem as HTMLButtonElement).disabled).toBe(false);
expect(publicMenuItem.getAttribute('aria-checked')).toBe('true');
fireEvent.click(publicMenuItem);
expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false);
fireEvent.click(submitMenuItem);
expect(submitAssetToShowcase).toHaveBeenCalledWith(asset);
});
});
@@ -1,4 +1,4 @@
import { Check, Download, Music2, Pencil, Video, X } from 'lucide-react';
import { Check, Download, Pencil, Video, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
@@ -24,6 +24,8 @@ import {
getCanvasMediaPreviewMarker,
} from './ImageCanvasMediaModel';
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
export type ImageCanvasAssetRowViewProps = {
asset: EditorAsset;
assetPointerDragRef: { current: AssetPointerDragState | null };
@@ -38,15 +40,15 @@ export type ImageCanvasAssetRowViewProps = {
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
updateAssetMoveDropFolder: (folderId: string | null) => void;
addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void;
addUploadedFiles: (
files: FileList | File[],
options?: UploadFilesOptions,
) => void;
moveAssetToFolder: (assetId: string, folderId: string) => void;
startRenamingAsset: (asset: EditorAsset) => void;
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
setAssetPublicShowcaseEnabled: (
asset: EditorAsset,
publicShowcaseEnabled: boolean,
) => void;
submitAssetToShowcase: (asset: EditorAsset) => void;
toggleAssetSelected: (assetId: string) => void;
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
@@ -69,7 +71,7 @@ export function ImageCanvasAssetRowView({
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
submitAssetToShowcase,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset,
@@ -82,8 +84,21 @@ export function ImageCanvasAssetRowView({
const isRenaming = renamingAsset?.assetId === asset.id;
const isUploadingAsset = asset.uploadStatus === 'uploading';
const isFailedUpload = asset.uploadStatus === 'failed';
const canShowcaseAsset = Boolean(asset.sourceResourceId);
const publicShowcaseEnabled = asset.publicShowcaseEnabled ?? true;
const showcaseReviewStatus = asset.showcaseReviewStatus ?? null;
const canSubmitShowcaseAsset =
asset.sourceType === 'generated' &&
asset.persisted &&
!showcaseReviewStatus;
const showcaseMenuLabel =
showcaseReviewStatus === 'pending'
? '精选审核中'
: showcaseReviewStatus === 'approved'
? asset.showcaseDisplayEnabled === false
? '已入选精选,暂未展示'
: '已入选精选'
: showcaseReviewStatus === 'rejected'
? '精选未通过'
: '提交精选审核';
const closeMenu = useCallback(() => {
setMenuPosition(null);
@@ -145,13 +160,7 @@ export function ImageCanvasAssetRowView({
? '视频'
: `${asset.width} x ${asset.height}`;
const mediaOverlay =
asset.mediaType === 'audio' ? (
<div
className={`image-canvas-editor__asset-media-overlay ${mediaPreviewClassName}`}
>
<Music2 className="h-5 w-5" />
</div>
) : asset.mediaType === 'video' ? (
asset.mediaType === 'video' ? (
<div
className={`image-canvas-editor__asset-media-overlay ${mediaPreviewClassName}`}
>
@@ -271,9 +280,7 @@ export function ImageCanvasAssetRowView({
rowClassName={[
'image-canvas-editor__asset-row',
isUploadingAsset ? 'image-canvas-editor__asset-row--uploading' : '',
isFailedUpload
? 'image-canvas-editor__asset-row--upload-failed'
: '',
isFailedUpload ? 'image-canvas-editor__asset-row--upload-failed' : '',
]
.filter(Boolean)
.join(' ')}
@@ -283,12 +290,18 @@ export function ImageCanvasAssetRowView({
imageSrc={
asset.mediaType === 'image'
? asset.src
: (asset.thumbnailSrc ?? '')
: asset.mediaType === 'audio'
? AUDIO_ASSET_COVER_SRC
: (asset.thumbnailSrc ?? '')
}
objectKey={asset.mediaType === 'image' ? asset.objectKey : undefined}
previewNode={videoPreview}
titleNode={
isUploadingAsset || isFailedUpload ? <span>{asset.label}</span> : titleNode
isUploadingAsset || isFailedUpload ? (
<span>{asset.label}</span>
) : (
titleNode
)
}
actions={actions}
draggable={!isRenaming && !isUploadingAsset && !isFailedUpload}
@@ -444,22 +457,21 @@ export function ImageCanvasAssetRowView({
>
<button
type="button"
role="menuitemcheckbox"
role="menuitem"
className="image-canvas-editor__asset-context-menu-toggle"
aria-checked={publicShowcaseEnabled}
disabled={!canShowcaseAsset}
disabled={!canSubmitShowcaseAsset}
onClick={() => {
if (!canShowcaseAsset) {
if (!canSubmitShowcaseAsset) {
return;
}
setAssetPublicShowcaseEnabled(asset, !publicShowcaseEnabled);
submitAssetToShowcase(asset);
closeMenu();
}}
>
<span className="image-canvas-editor__asset-context-menu-label">
{showcaseMenuLabel}
</span>
{publicShowcaseEnabled ? (
{showcaseReviewStatus === 'approved' ? (
<span
aria-hidden="true"
className="image-canvas-editor__asset-context-menu-check"
@@ -100,7 +100,11 @@ export function ImageCanvasContextMenusView({
? [
'context',
contextMenu.kind,
contextMenu.kind === 'layer' ? contextMenu.layerId : 'blank',
contextMenu.kind === 'layer'
? contextMenu.layerId
: contextMenu.kind === 'generation-dialog'
? contextMenu.dialogId
: 'blank',
contextMenu.x,
contextMenu.y,
isImageSequenceLayer ? 'sequence' : 'single',
@@ -259,7 +263,11 @@ export function ImageCanvasContextMenusView({
className={contextMenuClassName}
role="menu"
aria-label={
contextMenu.kind === 'blank' ? '画布右键菜单' : '图片功能面板'
contextMenu.kind === 'blank'
? '画布右键菜单'
: contextMenu.kind === 'generation-dialog'
? '生成器右键菜单'
: '图片功能面板'
}
style={{
left: currentMeasuredMenuLayout?.x ?? contextMenu.x,
@@ -308,6 +316,15 @@ export function ImageCanvasContextMenusView({
</button>
</>
) : contextMenu.kind === 'generation-dialog' ? (
<button
type="button"
role="menuitem"
className="image-canvas-editor__context-menu-danger"
onClick={onDeleteContextLayers}
>
</button>
) : (
<>
<button
@@ -48,6 +48,7 @@ export const CONTEXT_MENU_VIEWPORT_MARGIN = 8;
export const CONTEXT_MENU_SIZE = {
blank: { width: 188, height: 176 },
layer: { width: 188, height: 492 },
'generation-dialog': { width: 188, height: 64 },
} as const;
export const CANVAS_BACKGROUND_OPTIONS = [
{ label: '白色', value: '#ffffff' },
@@ -744,6 +745,11 @@ export function mapAssetLibrarySnapshot(
thumbnailSrc: asset.thumbnailSrc ?? undefined,
sourceResourceId: asset.sourceResourceId ?? null,
publicShowcaseEnabled: asset.publicShowcaseEnabled ?? null,
generationCostMudPoints: asset.generationCostMudPoints ?? 0,
showcaseId: asset.showcaseId ?? null,
showcaseReviewStatus: asset.showcaseReviewStatus ?? null,
showcaseDisplayEnabled: asset.showcaseDisplayEnabled ?? null,
showcaseLikeCount: asset.showcaseLikeCount ?? null,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
durationSeconds: asset.durationSeconds ?? undefined,
@@ -77,7 +77,7 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
startRenamingAsset: vi.fn(),
commitAssetRename: vi.fn(),
deleteUploadedAsset: vi.fn(),
setAssetPublicShowcaseEnabled: vi.fn(),
submitAssetToShowcase: vi.fn(),
toggleAssetSelected: vi.fn(),
addAssetLayer: vi.fn(),
onDownloadAsset: vi.fn(),
@@ -136,8 +136,11 @@ function createStageProps(): ImageCanvasStageViewProps {
hoveredLayerId: null,
canvasMarquee: null,
canvasGenerationDialogs: [],
editorAgentAssets: [],
taskListRefreshKey: 0,
isTaskSidebarOpen: false,
isAgentConversationEnabled: false,
isAgentConversationOpen: false,
generateDialog: null,
cropExpandPanel: null,
cropExpandSourceLayer: null,
@@ -178,9 +181,11 @@ function createStageProps(): ImageCanvasStageViewProps {
onOpenLayerMetadata: vi.fn(),
onUpdateLayerAssetKind: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onGenerationFrameContextMenu: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onFocusExternalTask: vi.fn(),
onToggleTaskSidebar: vi.fn(),
onToggleAgentConversation: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
onOpenQuickEditPanel: vi.fn(),
onOpenRedrawPanel: vi.fn(),
@@ -49,6 +49,11 @@ export type EditorAsset = {
assetObjectId?: string;
sourceResourceId?: string | null;
publicShowcaseEnabled?: boolean | null;
generationCostMudPoints?: number;
showcaseId?: string | null;
showcaseReviewStatus?: string | null;
showcaseDisplayEnabled?: boolean | null;
showcaseLikeCount?: number | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
@@ -287,6 +292,13 @@ export type CanvasContextMenuState =
y: number;
layerId: string;
canvasPoint: { x: number; y: number };
}
| {
kind: 'generation-dialog';
x: number;
y: number;
dialogId: string;
canvasPoint: { x: number; y: number };
};
export type QuickEditPanelState = {
File diff suppressed because it is too large Load Diff
@@ -9,14 +9,16 @@ import {
useState,
} from 'react';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import type { EditorAgentGenerationResultEvent } from '../../../packages/shared/src/contracts/editorAgent';
import {
createEditorAsset,
createEditorProjectResource,
type EditorAssetSnapshot,
type EditorProjectSnapshot,
loadEditorProject,
loadEditorGenerationPricing,
} from '../../services/image-editor/editorProjectClient';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -48,6 +50,7 @@ import type {
EditorAsset,
ImageContextMenuState,
QuickEditPanelState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
@@ -55,11 +58,11 @@ import {
applyEditorGenerationPricingConfig,
isCanvasGenerationDialog,
} from './ImageCanvasGenerationModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
import {
isCanvasGenerationComposerVisible,
resolveQuickEditFocusViewport,
} from './ImageCanvasOverlayModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
import {
getCanvasGenerationSelectionId,
getSelectedGenerationDialogIds,
@@ -110,6 +113,16 @@ const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
'publication-cover',
];
type ImageCanvasEditorViewProps = {
onProjectAccessLost?: () => void;
};
function resolveEditorAgentSidebarEnabled(
rawValue = import.meta.env.VITE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR,
): boolean {
return rawValue === 'true' || rawValue === '1';
}
function isCanvasStartupTool(value: string | null): value is CanvasStartupTool {
return CANVAS_STARTUP_TOOLS.includes(value as CanvasStartupTool);
}
@@ -162,7 +175,9 @@ function openCanvasStartupTool(
return;
}
if (tool === 'publication-cover') {
generationSurface.openPublicationGenerationDialog('publication-cover-image');
generationSurface.openPublicationGenerationDialog(
'publication-cover-image',
);
return;
}
generationSurface.openGenerateDialog();
@@ -278,7 +293,9 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
};
}
export function ImageCanvasEditorView() {
export function ImageCanvasEditorView({
onProjectAccessLost,
}: ImageCanvasEditorViewProps = {}) {
const authUi = useAuthUi();
const [, setGenerationPricingVersion] = useState(0);
const [walletBalanceLabel, setWalletBalanceLabel] = useState<string | null>(
@@ -460,7 +477,7 @@ export function ImageCanvasEditorView() {
});
}, [authUi]);
useEffect(() => {
if (!authUi?.canAccessProtectedData || !authUi.user) {
if (!authUi?.canAccessProtectedData || !authUi.user?.id) {
setWalletBalanceLabel(null);
setIsWalletBalanceLoading(false);
return;
@@ -590,7 +607,7 @@ export function ImageCanvasEditorView() {
toggleAssetFolder,
commitNewAssetFolder,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
submitAssetToShowcase,
startRenamingFolder,
commitFolderRename,
deleteAssetFolder,
@@ -987,16 +1004,17 @@ export function ImageCanvasEditorView() {
appendCanvasLayersWithResources,
applyProjectSnapshot,
} = useImageCanvasProjectPersistence({
refs: projectPersistenceRefs,
setters: projectPersistenceSetters,
layers,
canvasGenerationDialogs,
viewport,
isViewportInteracting,
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
currentUserId: currentEditorUserId,
openEditorLoginModal,
});
refs: projectPersistenceRefs,
setters: projectPersistenceSetters,
layers,
canvasGenerationDialogs,
viewport,
isViewportInteracting,
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
currentUserId: currentEditorUserId,
openEditorLoginModal,
onProjectAccessLost,
});
const applyGeneratedProjectSnapshot = useCallback(
(project: EditorProjectSnapshot) => {
applyProjectSnapshot(project);
@@ -1004,6 +1022,20 @@ export function ImageCanvasEditorView() {
},
[applyProjectSnapshot, refreshAssetLibrary],
);
const handleEditorAgentGenerationResult = useCallback(
(event: EditorAgentGenerationResultEvent) => {
const hasGeneratedResource = event.images.some((image) =>
image.resourceId?.trim(),
);
if (!projectId || !hasGeneratedResource) {
return;
}
void loadEditorProject(projectId)
.then(applyGeneratedProjectSnapshot)
.catch(() => undefined);
},
[applyGeneratedProjectSnapshot, projectId],
);
const persistUpdatedLayerResource = useCallback(
(layer: CanvasLayer) => {
if (!projectId) {
@@ -1135,6 +1167,55 @@ export function ImageCanvasEditorView() {
applyProjectSnapshot: applyGeneratedProjectSnapshot,
onWalletBalanceMayHaveChanged: refreshEditorWalletBalance,
});
const isAgentConversationEnabled = resolveEditorAgentSidebarEnabled();
const [isAgentConversationOpen, setIsAgentConversationOpen] = useState(
() => isAgentConversationEnabled,
);
const effectiveIsAgentConversationOpen =
isAgentConversationEnabled && isAgentConversationOpen;
const toggleAgentConversation = useCallback(() => {
if (!isAgentConversationEnabled) {
return;
}
const nextOpen = !isAgentConversationOpen;
if (nextOpen && generationSurface.isTaskSidebarOpen) {
generationSurface.toggleTaskSidebar();
}
setIsAgentConversationOpen(nextOpen);
}, [generationSurface, isAgentConversationEnabled, isAgentConversationOpen]);
const toggleTaskSidebar = useCallback(() => {
if (
!generationSurface.isTaskSidebarOpen &&
effectiveIsAgentConversationOpen
) {
setIsAgentConversationOpen(false);
}
generationSurface.toggleTaskSidebar();
}, [effectiveIsAgentConversationOpen, generationSurface]);
const toggleCanvasSidebarPanel = useCallback(
(panel: SidebarPanel) => {
toggleSidebarPanel(panel);
},
[toggleSidebarPanel],
);
useEffect(() => {
if (!isAgentConversationEnabled && isAgentConversationOpen) {
setIsAgentConversationOpen(false);
return;
}
if (
isAgentConversationEnabled &&
generationSurface.isTaskSidebarOpen &&
isAgentConversationOpen
) {
generationSurface.toggleTaskSidebar();
}
}, [
generationSurface,
generationSurface.isTaskSidebarOpen,
isAgentConversationEnabled,
isAgentConversationOpen,
]);
const {
setQuickEditPanel,
setCropExpandPanel,
@@ -1243,11 +1324,14 @@ export function ImageCanvasEditorView() {
clearCanvasFocus,
handleCanvasContextMenu,
handleLayerContextMenu,
handleGenerationFrameContextMenu,
} = useImageCanvasStageController({
layers,
canvasGenerationDialogs,
selectedLayerId,
selectedLayerIds,
setSelectedLayerId,
setSelectedLayerIds,
imageContextMenu,
setImageContextMenu,
contextMenu,
@@ -1795,7 +1879,7 @@ export function ImageCanvasEditorView() {
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
submitAssetToShowcase,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset: downloadAsset,
@@ -1854,9 +1938,12 @@ export function ImageCanvasEditorView() {
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
editorAgentAssets: assets,
isToolbarGuideVisible,
taskListRefreshKey: generationSurface.taskListRefreshKey,
isTaskSidebarOpen: generationSurface.isTaskSidebarOpen,
isAgentConversationEnabled,
isAgentConversationOpen: effectiveIsAgentConversationOpen,
generateDialog,
cropExpandPanel: generationSurface.cropExpandPanel,
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
@@ -1902,9 +1989,12 @@ export function ImageCanvasEditorView() {
},
onUpdateLayerAssetKind: updateLayerAssetKind,
onGenerationFramePointerDown: handleGenerationFramePointerDown,
onGenerationFrameContextMenu: handleGenerationFrameContextMenu,
onActivateGenerationDialog: activateCanvasGenerationDialog,
onFocusExternalTask: focusExternalGenerationTask,
onToggleTaskSidebar: generationSurface.toggleTaskSidebar,
onEditorAgentGenerationResult: handleEditorAgentGenerationResult,
onToggleTaskSidebar: toggleTaskSidebar,
onToggleAgentConversation: toggleAgentConversation,
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
onOpenQuickEditPanel: openQuickEditPanel,
onOpenRedrawPanel: openRedrawPanel,
@@ -1952,7 +2042,7 @@ export function ImageCanvasEditorView() {
onToggleBackgroundSettings: toggleBackgroundSettings,
onApplyCanvasBackgroundColor: applyCanvasBackgroundColor,
onCanvasBackgroundHexChange: handleCanvasBackgroundHexChange,
onToggleSidebarPanel: toggleSidebarPanel,
onToggleSidebarPanel: toggleCanvasSidebarPanel,
onToggleMinimap: toggleMinimap,
onMinimapPointerDown: handleMinimapPointerDown,
onSwitchTool: switchTool,
@@ -3,6 +3,7 @@ import type {
CanvasContextMenuState,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
export type CanvasLayerMoveMode = 'up' | 'down' | 'top' | 'bottom';
@@ -31,12 +32,14 @@ export function resolveContextTargetLayerIds(
menu: CanvasContextMenuState | null,
selectedLayerIds: string[],
) {
if (menu?.kind !== 'layer') {
if (!menu || menu.kind === 'blank') {
return [];
}
return selectedLayerIds.includes(menu.layerId)
? [...selectedLayerIds]
: [menu.layerId];
const targetId =
menu.kind === 'generation-dialog'
? getCanvasGenerationSelectionId(menu.dialogId)
: menu.layerId;
return selectedLayerIds.includes(targetId) ? [...selectedLayerIds] : [targetId];
}
export function getCanvasLayersByIds(
@@ -7,6 +7,8 @@ import type {
ImageContextMenuState,
} from './ImageCanvasEditorTypes';
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
export type ImageCanvasLayerPanelViewProps = {
layers: CanvasLayer[];
selectedLayerId: string | null;
@@ -52,8 +54,12 @@ export function ImageCanvasLayerPanelView({
]
.filter(Boolean)
.join(' · ')}
imageSrc={layer.src}
objectKey={layer.mediaType === 'image' ? layer.objectKey : undefined}
imageSrc={
layer.mediaType === 'audio' ? AUDIO_ASSET_COVER_SRC : layer.src
}
objectKey={
layer.mediaType === 'image' ? layer.objectKey : undefined
}
imageAlt={`图层缩略图:${layer.title}`}
selected={selectedLayerId === layer.id}
primaryLabel={`选择图层${layer.title}`}
@@ -18,6 +18,8 @@ function renderPanelDock(
isZoomMenuOpen: false,
isBackgroundSettingsOpen: false,
activeSidebarPanel: null,
isAgentConversationEnabled: true,
isAgentConversationOpen: false,
isMinimapOpen: false,
minimapModel: null,
onFitLayers: vi.fn(),
@@ -30,6 +32,7 @@ function renderPanelDock(
onApplyCanvasBackgroundColor: vi.fn(),
onCanvasBackgroundHexChange: vi.fn(),
onToggleSidebarPanel: vi.fn(),
onToggleAgentConversation: vi.fn(),
onToggleMinimap: vi.fn(),
onMinimapPointerDown: vi.fn(),
...overrides,
@@ -55,14 +58,19 @@ describe('ImageCanvasPanelDockView', () => {
const toolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
expect(toolbar.className).toContain(
'image-canvas-editor__panel-dock--sidebar-open',
);
expect(
within(toolbar).getByRole('button', { name: '打开素材' }).getAttribute(
'aria-pressed',
),
within(toolbar)
.getByRole('button', { name: '打开素材' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
(
within(toolbar).getByRole('button', { name: '重做' }) as HTMLButtonElement
within(toolbar).getByRole('button', {
name: '重做',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(screen.getByRole('button', { name: '画布小地图' })).toBeTruthy();
@@ -70,14 +78,26 @@ describe('ImageCanvasPanelDockView', () => {
fireEvent.click(screen.getByRole('button', { name: '重置画布视图' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '撤销' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '打开图层' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '切换小地图' }));
fireEvent.click(
within(toolbar).getByRole('button', { name: '画布 Agent' }),
);
fireEvent.click(
within(toolbar).getByRole('button', { name: '切换小地图' }),
);
expect(props.onFitLayers).toHaveBeenCalledTimes(1);
expect(props.onUndoCanvasChange).toHaveBeenCalledTimes(1);
expect(props.onToggleSidebarPanel).toHaveBeenCalledWith('layers');
expect(props.onToggleAgentConversation).toHaveBeenCalledTimes(1);
expect(props.onToggleMinimap).toHaveBeenCalledTimes(1);
});
it('hides the Agent entry when the Agent sidebar is disabled', () => {
renderPanelDock({ isAgentConversationEnabled: false });
expect(screen.queryByRole('button', { name: '画布 Agent' })).toBeNull();
});
it('renders zoom and background settings with callback wiring', () => {
const props = renderPanelDock({
viewport: { x: 0, y: 0, scale: 0.5 },
@@ -110,7 +130,9 @@ describe('ImageCanvasPanelDockView', () => {
target: { value: '#abc' },
});
fireEvent.click(within(panel).getByRole('button', { name: '恢复默认' }));
fireEvent.click(within(panel).getByRole('button', { name: '关闭画布背景设置' }));
fireEvent.click(
within(panel).getByRole('button', { name: '关闭画布背景设置' }),
);
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#f3f0ea');
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#ffffff');
@@ -2,6 +2,7 @@ import {
ImagePlus,
Layers,
Map as MapIcon,
MessageCircle,
Redo2,
RotateCcw,
Undo2,
@@ -9,18 +10,21 @@ import {
} from 'lucide-react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { PlatformFloatingMenu, PlatformFloatingMenuItem } from '../common/PlatformFloatingMenu';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
import {
canvasDisplayScaleToViewportScale,
CANVAS_BACKGROUND_OPTIONS,
canvasDisplayScaleToViewportScale,
DEFAULT_CANVAS_BACKGROUND_COLOR,
formatCanvasDisplayScalePercent,
} from './ImageCanvasEditorModel';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasViewport, SidebarPanel } from './ImageCanvasEditorTypes';
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
type ImageCanvasPanelDockViewProps = {
viewport: CanvasViewport;
@@ -31,6 +35,8 @@ type ImageCanvasPanelDockViewProps = {
isZoomMenuOpen: boolean;
isBackgroundSettingsOpen: boolean;
activeSidebarPanel: SidebarPanel | null;
isAgentConversationEnabled: boolean;
isAgentConversationOpen: boolean;
isMinimapOpen: boolean;
minimapModel: StageMinimapModel | null;
onFitLayers: () => void;
@@ -43,6 +49,7 @@ type ImageCanvasPanelDockViewProps = {
onApplyCanvasBackgroundColor: (color: string) => void;
onCanvasBackgroundHexChange: (value: string) => void;
onToggleSidebarPanel: (panel: SidebarPanel) => void;
onToggleAgentConversation: () => void;
onToggleMinimap: () => void;
onMinimapPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => void;
};
@@ -56,6 +63,8 @@ export function ImageCanvasPanelDockView({
isZoomMenuOpen,
isBackgroundSettingsOpen,
activeSidebarPanel,
isAgentConversationEnabled,
isAgentConversationOpen,
isMinimapOpen,
minimapModel,
onFitLayers,
@@ -68,6 +77,7 @@ export function ImageCanvasPanelDockView({
onApplyCanvasBackgroundColor,
onCanvasBackgroundHexChange,
onToggleSidebarPanel,
onToggleAgentConversation,
onToggleMinimap,
onMinimapPointerDown,
}: ImageCanvasPanelDockViewProps) {
@@ -82,7 +92,14 @@ export function ImageCanvasPanelDockView({
/>
<div
className="image-canvas-editor__panel-dock"
className={[
'image-canvas-editor__panel-dock',
activeSidebarPanel
? 'image-canvas-editor__panel-dock--sidebar-open'
: '',
]
.filter(Boolean)
.join(' ')}
role="toolbar"
aria-label="画布面板入口"
onPointerDown={(event) => event.stopPropagation()}
@@ -259,7 +276,9 @@ export function ImageCanvasPanelDockView({
type="button"
className="image-canvas-editor__background-reset"
onClick={() =>
onApplyCanvasBackgroundColor(DEFAULT_CANVAS_BACKGROUND_COLOR)
onApplyCanvasBackgroundColor(
DEFAULT_CANVAS_BACKGROUND_COLOR,
)
}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
@@ -283,6 +302,15 @@ export function ImageCanvasPanelDockView({
pressed={activeSidebarPanel === 'layers'}
onClick={() => onToggleSidebarPanel('layers')}
/>
{isAgentConversationEnabled ? (
<EditorIconButton
label="画布 Agent"
title="画布 Agent"
icon={MessageCircle}
pressed={isAgentConversationOpen}
onClick={onToggleAgentConversation}
/>
) : null}
<EditorIconButton
label="切换小地图"
title="小地图"
@@ -115,7 +115,7 @@ function createSidebarProps(
startRenamingAsset: vi.fn(),
commitAssetRename: vi.fn(),
deleteUploadedAsset: vi.fn(),
setAssetPublicShowcaseEnabled: vi.fn(),
submitAssetToShowcase: vi.fn(),
toggleAssetSelected: vi.fn(),
addAssetLayer: vi.fn(),
onDownloadAsset: vi.fn(),
@@ -157,6 +157,8 @@ function SidebarTabsHarness() {
isZoomMenuOpen={false}
isBackgroundSettingsOpen={false}
activeSidebarPanel={chrome.activeSidebarPanel}
isAgentConversationEnabled={false}
isAgentConversationOpen={false}
isMinimapOpen={false}
minimapModel={null}
onFitLayers={vi.fn()}
@@ -169,6 +171,7 @@ function SidebarTabsHarness() {
onApplyCanvasBackgroundColor={vi.fn()}
onCanvasBackgroundHexChange={vi.fn()}
onToggleSidebarPanel={chrome.toggleSidebarPanel}
onToggleAgentConversation={vi.fn()}
onToggleMinimap={vi.fn()}
onMinimapPointerDown={vi.fn()}
/>
@@ -258,8 +261,12 @@ describe('ImageCanvasSidebarView', () => {
target: { value: '火焰' },
});
expect(within(sidebar).getByRole('button', { name: '添加火焰按钮' })).toBeTruthy();
expect(within(sidebar).queryByRole('button', { name: '添加账号素材A' })).toBeNull();
expect(
within(sidebar).getByRole('button', { name: '添加火焰按钮' }),
).toBeTruthy();
expect(
within(sidebar).queryByRole('button', { name: '添加账号素材A' }),
).toBeNull();
});
it('renders the layer panel with grouped and locked layer details', () => {
@@ -291,6 +298,28 @@ describe('ImageCanvasSidebarView', () => {
).toBeTruthy();
});
it('uses the transparent audio cover for audio layer thumbnails', () => {
render(
<ImageCanvasSidebarView
{...createSidebarProps({
activeSidebarPanel: 'layers',
layers: [
createLayer({
title: '胜利音效',
src: '/generated-character-drafts/editor-audios/win.mp3',
mediaType: 'audio',
}),
],
})}
/>,
);
const image = screen.getByAltText('图层缩略图:胜利音效');
expect(image.getAttribute('src')).toBe(
'/creation-home/audio-asset-cover.png',
);
});
it('filters canvas layers from the sidebar search field', () => {
render(
<ImageCanvasSidebarView
@@ -320,6 +349,12 @@ describe('ImageCanvasSidebarView', () => {
it('keeps sidebar tab switching and repeated-click closing behavior', () => {
render(<SidebarTabsHarness />);
expect(
screen.queryByRole('complementary', { name: '图片资源栏' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy();
expect(screen.getByRole('button', { name: '添加账号素材A' })).toBeTruthy();
@@ -96,10 +96,7 @@ export type ImageCanvasSidebarViewProps = {
startRenamingAsset: (asset: EditorAsset) => void;
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
setAssetPublicShowcaseEnabled: (
asset: EditorAsset,
publicShowcaseEnabled: boolean,
) => void;
submitAssetToShowcase: (asset: EditorAsset) => void;
toggleAssetSelected: (assetId: string) => void;
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
@@ -165,7 +162,7 @@ export function ImageCanvasSidebarView({
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
submitAssetToShowcase,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset,
@@ -320,7 +317,7 @@ export function ImageCanvasSidebarView({
startRenamingAsset={startRenamingAsset}
commitAssetRename={commitAssetRename}
deleteUploadedAsset={deleteUploadedAsset}
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
submitAssetToShowcase={submitAssetToShowcase}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
onDownloadAsset={onDownloadAsset}
@@ -135,3 +135,22 @@ export function createLayerCanvasContextMenus({
},
};
}
export function createGenerationDialogCanvasContextMenu({
clientX,
clientY,
dialogId,
canvasPoint,
}: {
clientX: number;
clientY: number;
dialogId: string;
canvasPoint: { x: number; y: number };
}): CanvasContextMenuState {
return {
kind: 'generation-dialog',
dialogId,
...resolveContextMenuPosition(clientX, clientY, 'generation-dialog'),
canvasPoint,
};
}
@@ -7,9 +7,11 @@ import type {
RefObject,
} from 'react';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import type { EditorAgentGenerationResultEvent } from '../../../packages/shared/src/contracts/editorAgent';
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView';
import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import type {
CanvasAssetKind,
CanvasClipboard,
@@ -21,6 +23,7 @@ import type {
CanvasViewport,
CropExpandPanelState,
CropExpandResizeHandle,
EditorAsset,
GenerateDialogState,
ImageContextMenuState,
SidebarPanel,
@@ -55,9 +58,12 @@ export type ImageCanvasStageViewProps = {
hoveredLayerId: string | null;
canvasMarquee: CanvasMarqueeState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
editorAgentAssets?: EditorAsset[];
isToolbarGuideVisible?: boolean;
taskListRefreshKey: number;
isTaskSidebarOpen: boolean;
isAgentConversationEnabled: boolean;
isAgentConversationOpen: boolean;
generateDialog: GenerateDialogState | null;
cropExpandPanel: CropExpandPanelState | null;
cropExpandSourceLayer: CanvasLayer | null;
@@ -114,9 +120,17 @@ export type ImageCanvasStageViewProps = {
event: ReactPointerEvent<HTMLDivElement>,
dialog: CanvasGenerationDialogState,
) => void;
onGenerationFrameContextMenu: (
event: ReactMouseEvent<HTMLDivElement>,
dialog: CanvasGenerationDialogState,
) => void;
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
onEditorAgentGenerationResult?: (
event: EditorAgentGenerationResultEvent,
) => void;
onToggleTaskSidebar: () => void;
onToggleAgentConversation: () => void;
onCropExpandHandlePointerDown: (
event: ReactPointerEvent<HTMLButtonElement>,
handle: CropExpandResizeHandle,
@@ -197,9 +211,12 @@ export function ImageCanvasStageView({
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
editorAgentAssets = [],
isToolbarGuideVisible = false,
taskListRefreshKey,
isTaskSidebarOpen,
isAgentConversationEnabled,
isAgentConversationOpen,
generateDialog,
cropExpandPanel,
cropExpandSourceLayer,
@@ -241,9 +258,12 @@ export function ImageCanvasStageView({
onOpenLayerMetadata,
onUpdateLayerAssetKind,
onGenerationFramePointerDown,
onGenerationFrameContextMenu,
onActivateGenerationDialog,
onFocusExternalTask,
onEditorAgentGenerationResult,
onToggleTaskSidebar,
onToggleAgentConversation,
onCropExpandHandlePointerDown,
onOpenQuickEditPanel,
onOpenRedrawPanel,
@@ -341,6 +361,7 @@ export function ImageCanvasStageView({
onOpenLayerMetadata={onOpenLayerMetadata}
onUpdateLayerAssetKind={onUpdateLayerAssetKind}
onGenerationFramePointerDown={onGenerationFramePointerDown}
onGenerationFrameContextMenu={onGenerationFrameContextMenu}
onActivateGenerationDialog={onActivateGenerationDialog}
onCropExpandHandlePointerDown={onCropExpandHandlePointerDown}
/>
@@ -428,6 +449,8 @@ export function ImageCanvasStageView({
isZoomMenuOpen={isZoomMenuOpen}
isBackgroundSettingsOpen={isBackgroundSettingsOpen}
activeSidebarPanel={activeSidebarPanel}
isAgentConversationEnabled={isAgentConversationEnabled}
isAgentConversationOpen={isAgentConversationOpen}
isMinimapOpen={isMinimapOpen}
minimapModel={minimapModel}
onFitLayers={onFitLayers}
@@ -440,6 +463,7 @@ export function ImageCanvasStageView({
onApplyCanvasBackgroundColor={onApplyCanvasBackgroundColor}
onCanvasBackgroundHexChange={onCanvasBackgroundHexChange}
onToggleSidebarPanel={onToggleSidebarPanel}
onToggleAgentConversation={onToggleAgentConversation}
onToggleMinimap={onToggleMinimap}
onMinimapPointerDown={onMinimapPointerDown}
/>
@@ -452,6 +476,17 @@ export function ImageCanvasStageView({
onFocusExternalTask={onFocusExternalTask}
/>
{isAgentConversationEnabled ? (
<EditorAgentConversationPanelView
projectId={projectId}
open={isAgentConversationOpen}
onToggleOpen={onToggleAgentConversation}
layers={layers}
assets={editorAgentAssets}
onGenerationResult={onEditorAgentGenerationResult}
/>
) : null}
{isToolbarGuideVisible ? (
<div
className="image-canvas-editor__toolbar-guide"
@@ -85,6 +85,7 @@ function renderWorldView(
onOpenLayerMetadata: vi.fn(),
onUpdateLayerAssetKind: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onGenerationFrameContextMenu: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
...overrides,

Some files were not shown because too many files have changed in this diff Show More