合并最新master到画板抠图背景分支
更新本地 master 到 origin/master 后合入当前 feat/BGfilter 分支。 解决决策记录和图片画布 MVP 文档冲突,保留画布 Agent 与 BGFilter 背景色说明。 补齐画布 Agent 调用生图接口时的默认 screenColor=auto 和 segModel=birefnet。
This commit is contained in:
@@ -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
Reference in New Issue
Block a user