合并 master 更新到 AI 游戏创作分支

合入 master 的画布 Agent、精选素材审核、外部编辑器 API 与认证投影更新
保留当前分支的 AI 游戏创作壳、配置项和 LLM 运行接口改造
解决图片编辑器、LLM、去背景完成、外部生成排序和文档冲突
沿用 master 的 SpacetimeDB auth_store_projection 迁移与生成绑定
This commit is contained in:
AIGameCreator App
2026-07-06 11:47:19 +08:00
309 changed files with 24952 additions and 3100 deletions
+50 -19
View File
@@ -636,7 +636,7 @@ test('phone login result is not overwritten by an older guest hydrate', async ()
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('auth gate hides register entry and opens invite modal for new sms account', async () => {
test('auth gate auto redeems link invite code for new sms account', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
authMocks.getAuthLoginOptions.mockResolvedValue({
@@ -646,7 +646,14 @@ test('auth gate hides register entry and opens invite modal for new sms account'
token: 'jwt-phone-new',
user: mockUser,
created: true,
referral: null,
referral: {
ok: true,
message: '邀请码已绑定',
inviteeRewardGranted: true,
inviterRewardGranted: false,
inviteeBalanceAfter: 30,
inviterBalanceAfter: 0,
},
});
render(
@@ -674,30 +681,54 @@ test('auth gate hides register entry and opens invite modal for new sms account'
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
'13800000000',
'123456',
'SPRING2026',
);
});
expect(screen.queryByRole('dialog', { name: '请填写邀请码' })).toBeNull();
expect(authMocks.redeemRegistrationInviteCode).not.toHaveBeenCalled();
});
test('auth gate keeps invite modal open when link invite auto redeem fails', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValueOnce({
token: 'jwt-phone-new',
user: mockUser,
created: true,
referral: {
ok: false,
message: '邀请码无效,已继续注册',
inviteeRewardGranted: false,
inviterRewardGranted: false,
inviteeBalanceAfter: null,
inviterBalanceAfter: null,
},
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = await screen.findByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await acceptLegalConsent(user, dialog);
await user.click(within(dialog).getByRole('button', { name: '登录' }));
const inviteDialog = await screen.findByRole('dialog', {
name: '请填写邀请码',
});
expect(inviteDialog.className).toContain('platform-auth-card');
expect(inviteDialog.className).toContain('platform-modal-shell');
const inviteCodeInput = within(inviteDialog).getByLabelText(
'邀请码',
) as HTMLInputElement;
expect(inviteCodeInput.value).toBe('SPRING2026');
expect(inviteCodeInput.className).toContain('platform-text-field');
expect(
within(inviteDialog).getByRole('button', { name: '提交' }),
).toBeTruthy();
await user.click(within(inviteDialog).getByRole('button', { name: '提交' }));
await waitFor(() => {
expect(authMocks.redeemRegistrationInviteCode).toHaveBeenCalledWith(
'SPRING2026',
);
});
(within(inviteDialog).getByLabelText('邀请码') as HTMLInputElement).value,
).toBe('SPRING2026');
expect(within(inviteDialog).getByText('邀请码无效,已继续注册')).toBeTruthy();
});
test('registration invite modal can skip when invite code is empty', async () => {
+18 -3
View File
@@ -966,11 +966,26 @@ export function AuthGate({ children }: AuthGateProps) {
setLoggingIn(true);
setError('');
try {
const response = await loginWithPhoneCode(phone, code);
const registrationInviteCode =
pendingInviteCode || readInviteCodeFromLocation();
const response = registrationInviteCode
? await loginWithPhoneCode(phone, code, registrationInviteCode)
: await loginWithPhoneCode(phone, code);
const autoRedeemedInvite = response.referral?.ok === true;
setStoredLastLoginPhone(phone);
setLoginCaptchaChallenge(null);
setShowRegistrationInviteModal(response.created);
setRegistrationInviteError('');
setShowRegistrationInviteModal(
response.created && !autoRedeemedInvite,
);
setRegistrationInviteError(
response.referral?.ok === false
? (response.referral.message ??
'邀请码绑定失败,请确认后重试。')
: '',
);
if (autoRedeemedInvite) {
setPendingInviteCode('');
}
activateReadyUser(response.user);
} catch (loginError) {
setError(
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor, within } from '@testing-library/react';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ContextType } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -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 = [
@@ -89,6 +91,54 @@ function renderCreationLanding({
return result;
}
function installIntersectionObserverMock() {
const observers: Array<{
callback: IntersectionObserverCallback;
instance: IntersectionObserver;
}> = [];
class MockIntersectionObserver {
readonly root = null;
readonly rootMargin: string;
readonly thresholds: ReadonlyArray<number>;
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn(() => []);
constructor(
callback: IntersectionObserverCallback,
options: IntersectionObserverInit = {},
) {
this.rootMargin = options.rootMargin ?? '';
this.thresholds = Array.isArray(options.threshold)
? options.threshold
: [options.threshold ?? 0];
observers.push({
callback,
instance: this as unknown as IntersectionObserver,
});
}
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
return observers;
}
function triggerIntersection(
observer: {
callback: IntersectionObserverCallback;
instance: IntersectionObserver;
},
) {
act(() => {
observer.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
observer.instance,
);
});
}
describe('CreationLandingView', () => {
beforeEach(() => {
listPublicEditorProjectResourcesMock.mockResolvedValue([]);
@@ -98,6 +148,8 @@ describe('CreationLandingView', () => {
listEditorProjectsMock.mockReset();
listPublicEditorProjectResourcesMock.mockReset();
createEditorProjectMock.mockReset();
toggleEditorShowcaseAssetLikeMock.mockReset();
vi.unstubAllGlobals();
});
it('renders the Taonier creation landing modules without external branding', async () => {
@@ -195,10 +247,81 @@ describe('CreationLandingView', () => {
await user.click(screen.getByRole('button', { name: /开始创作/u }));
await waitFor(() => {
expect(onOpenProject).toHaveBeenCalledWith('created-project');
expect(onOpenProject).toHaveBeenCalledWith('created-project', {
guide: true,
});
});
});
it.each([
['游戏视觉规范', 'icon-spec'],
['游戏角色', 'character-spec'],
['游戏UI', 'ui-design'],
['游戏音乐', 'background-music'],
['游戏场景', 'image'],
['游戏美宣', 'publication-cover'],
])(
'creates a new project from %s with its canvas tool intent',
async (label, expectedTool) => {
const user = userEvent.setup();
const onOpenProject = vi.fn();
listEditorProjectsMock.mockResolvedValueOnce([]);
createEditorProjectMock.mockResolvedValueOnce({
projectId: 'visual-spec-project',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-18T10:00:00.000Z',
});
render(
<AuthUiContext.Provider value={createAuthValue()}>
<CreationLandingView
onOpenProject={onOpenProject}
onOpenProjects={vi.fn()}
/>
</AuthUiContext.Provider>,
);
await user.click(
screen.getByRole('button', { name: new RegExp(label, 'u') }),
);
await waitFor(() => {
expect(onOpenProject).toHaveBeenCalledWith('visual-spec-project', {
tool: expectedTool,
});
});
},
);
it('shows the pending feature dialog for unavailable creation tool cards', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce([]);
render(
<AuthUiContext.Provider value={createAuthValue()}>
<CreationLandingView
onOpenProject={vi.fn()}
onOpenProjects={vi.fn()}
/>
</AuthUiContext.Provider>,
);
await user.click(screen.getByRole('button', { name: /游戏特效/u }));
const dialog = screen.getByRole('dialog', { name: '抱歉' });
expect(dialog.textContent).toContain('功能还在调试中');
expect(dialog.textContent).toContain('暂未开放');
expect(dialog.closest('.platform-theme--light')).toBeTruthy();
expect(createEditorProjectMock).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '期待一下' }));
expect(screen.queryByRole('dialog', { name: '抱歉' })).toBeNull();
});
it('opens the community entry from the hero without starting project creation', async () => {
const user = userEvent.setup();
const onOpenCommunity = vi.fn();
@@ -258,6 +381,65 @@ describe('CreationLandingView', () => {
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);
@@ -393,6 +575,101 @@ describe('CreationLandingView', () => {
expect(screen.queryByText('拼图关卡创作')).toBeNull();
});
it('loads more featured resources when the waterfall reaches the end', async () => {
const observers = installIntersectionObserverMock();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock
.mockResolvedValueOnce({
resources: [
{
resourceId: 'resource-pack-a',
projectId: 'project-newest',
label: '素材包 A',
imageSrc: 'data:image/png;base64,a',
width: 512,
height: 640,
sourceType: 'generated',
prompt: '第一页素材',
actualPrompt: null,
model: null,
provider: null,
taskId: null,
},
],
nextCursor: 'cursor-page-2',
})
.mockResolvedValueOnce({
resources: [
{
resourceId: 'resource-pack-b',
projectId: 'project-old',
label: '素材包 B',
imageSrc: 'data:image/png;base64,b',
width: 512,
height: 512,
sourceType: 'generated',
prompt: '第二页素材',
actualPrompt: null,
model: null,
provider: null,
taskId: null,
},
],
nextCursor: null,
});
renderCreationLanding();
expect(await screen.findByText('素材包 A')).toBeTruthy();
await waitFor(() => expect(observers).toHaveLength(1));
expect(screen.queryByText('素材包 B')).toBeNull();
triggerIntersection(observers[0]!);
triggerIntersection(observers[0]!);
expect(await screen.findByText('素材包 B')).toBeTruthy();
await waitFor(() =>
expect(listPublicEditorProjectResourcesMock).toHaveBeenLastCalledWith({
cursor: 'cursor-page-2',
}),
);
expect(listPublicEditorProjectResourcesMock).toHaveBeenCalledTimes(2);
});
it('keeps rendered featured resources when loading more fails', async () => {
const observers = installIntersectionObserverMock();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock
.mockResolvedValueOnce({
resources: [
{
resourceId: 'resource-pack-a',
projectId: 'project-newest',
label: '素材包 A',
imageSrc: 'data:image/png;base64,a',
width: 512,
height: 640,
sourceType: 'generated',
prompt: '第一页素材',
actualPrompt: null,
model: null,
provider: null,
taskId: null,
},
],
nextCursor: 'cursor-page-2',
})
.mockRejectedValueOnce(new Error('网络失败'));
renderCreationLanding();
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(screen.getByLabelText('用户素材瀑布流')).toBeTruthy();
});
it('keeps the featured waterfall empty when project resources are empty', async () => {
renderCreationLanding({
authValue: createAuthValue({
File diff suppressed because it is too large Load Diff
@@ -53,7 +53,7 @@ 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',
projectResources: [
@@ -76,6 +76,74 @@ describe('creationShowcaseModel', () => {
expect(items[0]?.label).toBe('公开素材');
});
it('prepends enabled campaign cards and keeps showcase metrics', () => {
const items = buildCreationShowcaseItems({
activeTab: 'packs',
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: 'standalone: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',
@@ -2,6 +2,7 @@ import type {
EditorAssetGenerationInputReference,
EditorAssetSnapshot,
EditorProjectResourceSnapshot,
EditorShowcaseCampaignSnapshot,
} from '../../services/image-editor/editorProjectClient';
import {
calculateCharacterAnimationPrice,
@@ -42,6 +43,9 @@ export type ShowcaseAssetItem = {
prompt: string;
author: string;
cost: string;
likeCount?: number | null;
showcaseId?: string | null;
campaign?: boolean;
};
type ShowcaseAssetGroup = {
@@ -69,6 +73,10 @@ export const SHOWCASE_TABS: Array<{ id: ShowcaseTabId; label: string }> = [
{ id: 'marketing', label: '美宣' },
];
const SHOWCASE_TAB_IDS = new Set<ShowcaseTabId>(
SHOWCASE_TABS.map((tab) => tab.id),
);
function assetRecord(asset: EditorAssetSnapshot) {
return asset as Record<string, unknown>;
}
@@ -175,7 +183,7 @@ 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 +200,25 @@ 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_TAB_IDS.has(value as ShowcaseTabId)
? (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) {
@@ -535,6 +556,12 @@ function groupSingleAssets(
}
function getGroupsForTab(assets: EditorAssetSnapshot[], tab: ShowcaseTabId) {
if (assets.some((asset) => resolveAssetShowcaseCategory(asset))) {
return groupSingleAssets(
assets,
(asset) => resolveAssetShowcaseCategory(asset) === tab,
);
}
if (tab === 'packs') {
return groupPackAssets(assets);
}
@@ -709,6 +736,9 @@ function toLibraryShowcaseItem(
return null;
}
const primaryAsset = sortedAssets[0];
if (!primaryAsset) {
return null;
}
const label =
group.label?.trim() ||
primaryAsset?.label?.trim() ||
@@ -720,16 +750,44 @@ 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 {
if (!campaign?.enabled || !campaign.imageSrc.trim()) {
return null;
}
return {
id: 'campaign:global',
label: campaign.title.trim() || '陶泥儿精选',
previews: [
{
id: 'campaign:global:image',
label: campaign.title.trim() || '陶泥儿精选',
src: campaign.imageSrc,
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 +797,6 @@ export function buildCreationShowcaseItems({
.map((group) => toLibraryShowcaseItem(group, fallbackAuthorName))
.filter((item): item is ShowcaseAssetItem => item !== null);
return projectItems.slice(0, 36);
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,18 +24,23 @@ vi.mock('../common/PlatformMediaFrame', () => ({
objectKey?: string | null;
alt: string;
previewOverlay?: ReactNode;
}) => (
<div className="platform-media-frame">
{src?.trim() ? (
}) => {
const imageSrc =
src?.trim() ||
(objectKey?.trim() ? `https://signed.example.com/${objectKey.trim()}` : '');
return (
<div className="platform-media-frame">
{imageSrc ? (
<img
src={src}
src={imageSrc}
alt={alt}
data-object-key={objectKey?.trim() || undefined}
/>
) : null}
{previewOverlay}
</div>
),
);
},
}));
beforeEach(() => {
@@ -75,7 +80,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 +92,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 +112,7 @@ function renderAssetRow({
startRenamingAsset: vi.fn(),
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
submitAssetToShowcase,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset,
@@ -280,17 +282,33 @@ 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(
@@ -302,13 +320,12 @@ describe('ImageCanvasAssetRowView', () => {
clientY: 80,
});
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
name: /公开展示该素材/u,
const submitMenuItem = screen.getByRole('menuitem', {
name: /提交精选审核/u,
});
expect(publicMenuItem.getAttribute('aria-checked')).toBe('true');
fireEvent.click(publicMenuItem);
expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false);
fireEvent.click(submitMenuItem);
expect(submitAssetToShowcase).toHaveBeenCalledWith(asset);
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
@@ -318,45 +335,42 @@ describe('ImageCanvasAssetRowView', () => {
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,
});
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
name: /公开展示该素材/u,
const submitMenuItem = screen.getByRole('menuitem', {
name: /提交精选审核/u,
});
expect((publicMenuItem as HTMLButtonElement).disabled).toBe(true);
expect((submitMenuItem 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,
});
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
name: /公开展示该素材/u,
const submitMenuItem = screen.getByRole('menuitem', {
name: /提交精选审核/u,
});
expect((publicMenuItem as HTMLButtonElement).disabled).toBe(false);
expect(publicMenuItem.getAttribute('aria-checked')).toBe('true');
expect((submitMenuItem as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(publicMenuItem);
expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false);
fireEvent.click(submitMenuItem);
expect(submitAssetToShowcase).toHaveBeenCalledWith(asset);
});
});
@@ -43,10 +43,7 @@ export type ImageCanvasAssetRowViewProps = {
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 +66,7 @@ export function ImageCanvasAssetRowView({
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
submitAssetToShowcase,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset,
@@ -82,8 +79,19 @@ 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);
@@ -444,22 +452,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"
@@ -183,6 +183,48 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
expect(submitButton.textContent).toBe('生成5泥点');
});
it('keeps quick edit to one prompt box and model selection', () => {
render(
<BasicGenerationHarness
initialDialog={createDialog({
mode: 'quick-edit',
sourceLayerId: 'layer-source',
generationReferences: [
{
id: 'ref-a',
label: '旧参考图',
src: 'data:image/png;base64,cmVmQQ==',
},
],
imageModel: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
})}
/>,
);
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
expect(
within(panel).getByRole('textbox', { name: '快速编辑提示词' }),
).toBeTruthy();
expect(
within(panel).queryByRole('button', { name: '添加参考图' }),
).toBeNull();
expect(within(panel).queryByText('旧参考图')).toBeNull();
expect(
within(panel).queryByRole('button', {
name: /快速编辑图片尺寸/u,
}),
).toBeNull();
expect(
within(panel).getByRole('button', {
name: '快速编辑图片模型 gpt-image-2',
}),
).toBeTruthy();
expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy();
});
it('clicks the parent generation panel to collapse an opened option panel', () => {
render(<BasicGenerationHarness />);
@@ -145,13 +145,23 @@ export function ImageCanvasBasicGenerationComposerView({
promptLabel ?? (isQuickEdit ? '快速编辑提示词' : '生成提示词');
const resolvedPromptPlaceholder =
promptPlaceholder ??
(isQuickEdit ? '想怎么编辑这张图?' : '今天想生成什么画面?');
(isQuickEdit ? '写下每个编号要怎么改' : '今天想生成什么画面?');
const resolvedOptionLabelPrefix =
optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
const resolvedReferenceButtonLabel = referenceButtonLabel;
const resolvedReferenceButtonAriaLabel =
referenceButtonAriaLabel ?? `添加${resolvedReferenceButtonLabel}`;
const hasReferenceMenu = includeReferences && onRequestUpload;
const shouldIncludeReferences = isQuickEdit ? false : includeReferences;
const shouldIncludeDimensions = isQuickEdit ? false : includeDimensions;
const resolvedSubmitLabel =
isQuickEdit && submitLabel === '生成' ? '修改' : submitLabel;
const resolvedSubmitAriaLabel =
isQuickEdit && submitAriaLabel === '生成' ? '修改' : submitAriaLabel;
const resolvedSubmittingStatusLabel =
isQuickEdit && submittingStatusLabel === '生成中'
? '修改中'
: submittingStatusLabel;
const hasReferenceMenu = shouldIncludeReferences && onRequestUpload;
const finalFormClassName =
formClassName ??
'image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image';
@@ -179,7 +189,7 @@ export function ImageCanvasBasicGenerationComposerView({
}
}}
>
{includeReferences ? (
{shouldIncludeReferences ? (
<div className="image-canvas-editor__reference-strip">
{references.map((reference, index) => {
const label =
@@ -259,7 +269,7 @@ export function ImageCanvasBasicGenerationComposerView({
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
setGenerateDialog={setGenerateDialog}
includeDimensions={includeDimensions}
includeDimensions={shouldIncludeDimensions}
includeModel={includeModel}
onRememberImageModel={onRememberImageModel}
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
@@ -269,8 +279,8 @@ export function ImageCanvasBasicGenerationComposerView({
model: dialog.imageModel,
imageSize: dialog.imageSize,
})}
submitLabel={submitLabel}
submitAriaLabel={submitAriaLabel}
submitLabel={resolvedSubmitLabel}
submitAriaLabel={resolvedSubmitAriaLabel}
submitButtonClassName={submitButtonClassName}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
@@ -284,7 +294,7 @@ export function ImageCanvasBasicGenerationComposerView({
className="image-canvas-editor__generate-status"
role="status"
>
{submittingStatusLabel}
{resolvedSubmittingStatusLabel}
</PlatformStatusMessage>
) : null}
{dialog.status === 'failed' ? (
@@ -23,6 +23,7 @@ type ImageCanvasBottomToolbarViewProps = {
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
effectiveTool: CanvasTool;
highlight?: boolean;
onSwitchTool: (tool: CanvasTool) => void;
onOpenToolOptions: (tool: ToolbarOptionTool) => void;
onCloseToolOptions: (tool: ToolbarOptionTool) => void;
@@ -70,13 +71,19 @@ export function ImageCanvasBottomToolbarView({
musicToolWrapRef,
publicationToolWrapRef,
effectiveTool,
highlight = false,
onSwitchTool,
onOpenToolOptions,
onCloseToolOptions,
}: ImageCanvasBottomToolbarViewProps) {
return (
<div
className="image-canvas-editor__bottom-toolbar"
className={[
'image-canvas-editor__bottom-toolbar',
highlight ? 'image-canvas-editor__bottom-toolbar--guided' : '',
]
.filter(Boolean)
.join(' ')}
role="toolbar"
aria-label="AI画布工具栏"
onPointerDown={(event) => event.stopPropagation()}
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
@@ -63,6 +63,27 @@ function renderContextMenus(
return props;
}
function createDomRect(
width: number,
height: number,
overrides: Partial<DOMRect> = {},
): DOMRect {
const left = overrides.left ?? 0;
const top = overrides.top ?? 0;
return {
x: 0,
y: 0,
width,
height,
top,
left,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
...overrides,
} as DOMRect;
}
describe('ImageCanvasContextMenusView', () => {
it('renders blank canvas commands and forwards the canvas point', () => {
const canvasPoint = { x: 18, y: 24 };
@@ -162,6 +183,59 @@ describe('ImageCanvasContextMenusView', () => {
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
});
it('keeps measured layer menus and submenus inside the viewport', async () => {
const originalInnerWidth = window.innerWidth;
const originalInnerHeight = window.innerHeight;
window.innerWidth = 500;
window.innerHeight = 640;
const getBoundingClientRectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getBoundingClientRect(this: HTMLElement) {
if (this.classList.contains('image-canvas-editor__context-menu')) {
return createDomRect(188, 620);
}
if (
this.classList.contains('image-canvas-editor__context-submenu-panel')
) {
return createDomRect(160, 72);
}
if (this.classList.contains('image-canvas-editor__context-submenu')) {
return createDomRect(188, 29, { top: 586 });
}
return createDomRect(0, 0);
});
try {
const layer = createLayer({ mediaType: 'image-sequence' });
renderContextMenus({
contextMenu: {
kind: 'layer',
x: 490,
y: 500,
layerId: layer.id,
canvasPoint: { x: 40, y: 42 },
},
contextMenuLayer: layer,
});
const menu = screen.getByRole('menu', { name: '图片功能面板' });
await waitFor(() => {
expect(menu.style.left).toBe('304px');
expect(menu.style.top).toBe('12px');
expect(menu.className).toContain(
'image-canvas-editor__context-menu--submenu-left',
);
expect(menu.className).toContain(
'image-canvas-editor__context-menu--submenu-up',
);
});
} finally {
getBoundingClientRectSpy.mockRestore();
window.innerWidth = originalInnerWidth;
window.innerHeight = originalInnerHeight;
}
});
it('closes the standalone image menu after opening character animation', () => {
const layer = createLayer({ assetKind: 'character' });
const props = renderContextMenus({
@@ -1,9 +1,16 @@
import { useCallback, useEffect, useRef } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { clamp, CONTEXT_MENU_VIEWPORT_MARGIN } from './ImageCanvasEditorModel';
import type {
CanvasClipboard,
CanvasContextMenuState,
@@ -44,10 +51,18 @@ type ImageCanvasContextMenusViewProps = {
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
};
type MeasuredMenuLayout = {
key: string;
x: number;
y: number;
submenuDirection: 'left' | 'right';
submenuVerticalDirection: 'down' | 'up';
};
export function ImageCanvasContextMenusView({
viewport,
contextMenu,
canvasClipboard,
canvasClipboard: _canvasClipboard,
imageContextMenu,
imageContextMenuLayer,
contextMenuLayer,
@@ -75,10 +90,143 @@ export function ImageCanvasContextMenusView({
}: ImageCanvasContextMenusViewProps) {
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
const menuRef = useRef<HTMLDivElement | null>(null);
const [measuredMenuLayout, setMeasuredMenuLayout] =
useState<MeasuredMenuLayout | null>(null);
const closeMenus = useCallback(() => {
onCloseContextMenu();
onCloseImageContextMenu();
}, [onCloseContextMenu, onCloseImageContextMenu]);
const activeMenuLayoutKey = contextMenu
? [
'context',
contextMenu.kind,
contextMenu.kind === 'layer'
? contextMenu.layerId
: contextMenu.kind === 'generation-dialog'
? contextMenu.dialogId
: 'blank',
contextMenu.x,
contextMenu.y,
isImageSequenceLayer ? 'sequence' : 'single',
imageContextMenuLayer ? 'image-actions' : 'layer-actions',
imageContextMenuLayer?.assetKind ?? 'asset',
].join(':')
: imageContextMenu
? [
'image',
imageContextMenu.layerId,
imageContextMenu.x,
imageContextMenu.y,
imageContextMenuLayer?.assetKind ?? 'asset',
].join(':')
: null;
const activeMenuX = contextMenu?.x ?? imageContextMenu?.x ?? null;
const activeMenuY = contextMenu?.y ?? imageContextMenu?.y ?? null;
const currentMeasuredMenuLayout =
measuredMenuLayout?.key === activeMenuLayoutKey ? measuredMenuLayout : null;
const contextMenuClassName = [
'image-canvas-editor__context-menu',
currentMeasuredMenuLayout?.submenuDirection === 'left'
? 'image-canvas-editor__context-menu--submenu-left'
: null,
currentMeasuredMenuLayout?.submenuVerticalDirection === 'up'
? 'image-canvas-editor__context-menu--submenu-up'
: null,
]
.filter(Boolean)
.join(' ');
useLayoutEffect(() => {
const menuElement = menuRef.current;
if (
typeof window === 'undefined' ||
!activeMenuLayoutKey ||
activeMenuX === null ||
activeMenuY === null ||
!menuElement
) {
setMeasuredMenuLayout(null);
return;
}
const menuRect = menuElement.getBoundingClientRect();
const menuWidth = menuRect.width || menuElement.offsetWidth;
const menuHeight = menuRect.height || menuElement.offsetHeight;
const x = clamp(
activeMenuX,
CONTEXT_MENU_VIEWPORT_MARGIN,
Math.max(
CONTEXT_MENU_VIEWPORT_MARGIN,
window.innerWidth - menuWidth - CONTEXT_MENU_VIEWPORT_MARGIN,
),
);
const y = clamp(
activeMenuY,
CONTEXT_MENU_VIEWPORT_MARGIN,
Math.max(
CONTEXT_MENU_VIEWPORT_MARGIN,
window.innerHeight - menuHeight - CONTEXT_MENU_VIEWPORT_MARGIN,
),
);
const submenuElement = menuElement.querySelector<HTMLElement>(
'.image-canvas-editor__context-submenu-panel',
);
const submenuRect = submenuElement?.getBoundingClientRect();
const submenuWidth = submenuRect?.width || submenuElement?.offsetWidth || 0;
const submenuHeight =
submenuRect?.height || submenuElement?.offsetHeight || 0;
const submenuGap = 8;
const submenuVerticalOffset = 6;
const submenuDirection =
submenuWidth > 0 &&
x + menuWidth + submenuGap + submenuWidth >
window.innerWidth - CONTEXT_MENU_VIEWPORT_MARGIN &&
x - submenuGap - submenuWidth >= CONTEXT_MENU_VIEWPORT_MARGIN
? 'left'
: 'right';
const submenuAnchorRect =
submenuElement?.parentElement?.getBoundingClientRect();
const submenuAnchorOffsetTop =
submenuAnchorRect && submenuElement
? submenuAnchorRect.top - menuRect.top
: 0;
const submenuAnchorHeight =
submenuAnchorRect?.height ||
submenuElement?.parentElement?.offsetHeight ||
0;
const submenuDefaultBottom =
y + submenuAnchorOffsetTop - submenuVerticalOffset + submenuHeight;
const submenuFlippedTop =
y +
submenuAnchorOffsetTop +
submenuAnchorHeight +
submenuVerticalOffset -
submenuHeight;
const submenuVerticalDirection =
submenuHeight > 0 &&
submenuDefaultBottom >
window.innerHeight - CONTEXT_MENU_VIEWPORT_MARGIN &&
submenuFlippedTop >= CONTEXT_MENU_VIEWPORT_MARGIN
? 'up'
: 'down';
setMeasuredMenuLayout((previous) => {
if (
previous?.key === activeMenuLayoutKey &&
previous.x === x &&
previous.y === y &&
previous.submenuDirection === submenuDirection &&
previous.submenuVerticalDirection === submenuVerticalDirection
) {
return previous;
}
return {
key: activeMenuLayoutKey,
x,
y,
submenuDirection,
submenuVerticalDirection,
};
});
}, [activeMenuLayoutKey, activeMenuX, activeMenuY]);
useEffect(() => {
if (!contextMenu && !imageContextMenu) {
@@ -112,14 +260,18 @@ export function ImageCanvasContextMenusView({
{contextMenu ? (
<div
ref={menuRef}
className="image-canvas-editor__context-menu"
className={contextMenuClassName}
role="menu"
aria-label={
contextMenu.kind === 'blank' ? '画布右键菜单' : '图片功能面板'
contextMenu.kind === 'blank'
? '画布右键菜单'
: contextMenu.kind === 'generation-dialog'
? '生成器右键菜单'
: '图片功能面板'
}
style={{
left: contextMenu.x,
top: contextMenu.y,
left: currentMeasuredMenuLayout?.x ?? contextMenu.x,
top: currentMeasuredMenuLayout?.y ?? contextMenu.y,
}}
onContextMenu={(event) => event.preventDefault()}
onPointerDown={(event) => event.stopPropagation()}
@@ -164,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
@@ -370,10 +531,10 @@ export function ImageCanvasContextMenusView({
{imageContextMenu && imageContextMenuLayer && !contextMenu ? (
<div
ref={menuRef}
className="image-canvas-editor__context-menu"
className={contextMenuClassName}
style={{
left: imageContextMenu.x,
top: imageContextMenu.y,
left: currentMeasuredMenuLayout?.x ?? imageContextMenu.x,
top: currentMeasuredMenuLayout?.y ?? imageContextMenu.y,
}}
onPointerDown={(event) => event.stopPropagation()}
>
@@ -566,14 +566,12 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
expect(
await screen.findByRole('dialog', { name: '生成角色形象' }),
await screen.findByRole('dialog', { name: '快速编辑图片' }),
).toBeTruthy();
expect(
(screen.getByLabelText('角色设定') as HTMLTextAreaElement).value,
).toBe('黑衣剑士');
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
});
it('opens the matching generator from image context quick edit for tagged uploaded images', async () => {
it('opens quick edit from image context for tagged uploaded images', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-context-tagged-layer',
title: '右键标签素材画布',
@@ -617,13 +615,10 @@ describe('ImageCanvasEditorView generation integration', () => {
),
);
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
expect(
await screen.findByRole('dialog', { name: '生成角色形象' }),
await screen.findByRole('dialog', { name: '快速编辑图片' }),
).toBeTruthy();
expect(
(screen.getByLabelText('角色设定') as HTMLTextAreaElement).value,
).toBe('红披风骑士');
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
});
it('restores publication material composer fields and references from saved generator snapshots', async () => {
@@ -642,6 +642,45 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('drops restored generator references owned by another user', () => {
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-owner',
mode: 'generate',
prompt: '不应复用别人素材',
status: 'idle',
composerOpen: true,
generationReferences: [
{
id: 'foreign-reference',
label: '别人账号的素材',
src: 'data:image/png;base64,foreign',
resourceId: 'resource-foreign',
},
],
};
const { generationDialogs } = splitCanvasLayoutItems(
serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [dialog],
}),
new Map([
[
'resource-foreign',
{
imageSrc: '/read/foreign.png',
objectKey: 'generated/foreign.png',
ownerUserId: 'user-b',
},
],
]),
'user-a',
);
expect(generationDialogs).toHaveLength(1);
expect(generationDialogs[0]?.generationReferences).toEqual([]);
});
it('snaps moving layers to nearby canvas and layer guides', () => {
const movingLayer: CanvasLayer = {
id: 'moving',
@@ -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' },
@@ -315,6 +316,18 @@ function isPersistedReferenceAssetId(assetId: string | null | undefined) {
return Boolean(normalizedAssetId && !normalizedAssetId.startsWith('upload-'));
}
function normalizedCurrentUserId(userId: string | null | undefined) {
return userId?.trim() || null;
}
function isReferencePointerSrc(src: string | null | undefined) {
const normalizedSrc = src?.trim() ?? '';
return (
normalizedSrc.startsWith('ref:project-resource:') ||
normalizedSrc.startsWith('ref:asset:')
);
}
function resolveReferencePointer(reference: CharacterReferenceImage) {
const resourceId = reference.resourceId?.trim();
if (isPersistedReferenceResourceId(resourceId)) {
@@ -435,6 +448,7 @@ export function isCanvasGenerationDialogLayoutItem(
export function splitCanvasLayoutItems(
items: EditorProjectLayerSnapshot[],
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
currentUserId?: string | null,
): {
layerItems: EditorProjectLayerSnapshot[];
generationDialogs: CanvasGenerationDialogState[];
@@ -444,7 +458,11 @@ export function splitCanvasLayoutItems(
items.forEach((item) => {
if (isCanvasGenerationDialogLayoutItem(item)) {
const dialog = hydrateCanvasGenerationDialog(item.dialog, resourcesById);
const dialog = hydrateCanvasGenerationDialog(
item.dialog,
resourcesById,
currentUserId,
);
if (dialog) {
generationDialogs.push(dialog);
}
@@ -459,6 +477,7 @@ export function splitCanvasLayoutItems(
export function hydrateCanvasGenerationDialog(
value: unknown,
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
currentUserId?: string | null,
): CanvasGenerationDialogState | null {
if (!value || typeof value !== 'object') {
return null;
@@ -488,22 +507,27 @@ export function hydrateCanvasGenerationDialog(
specReference: hydrateCharacterReference(
snapshot.specReference,
resourcesById,
currentUserId,
),
generationReferences: hydrateCharacterReferences(
snapshot.generationReferences,
resourcesById,
currentUserId,
),
characterSpecReference: hydrateCharacterReference(
snapshot.characterSpecReference,
resourcesById,
currentUserId,
),
characterReferences: hydrateCharacterReferences(
snapshot.characterReferences,
resourcesById,
currentUserId,
),
iconSpecReference: hydrateCharacterReference(
snapshot.iconSpecReference,
resourcesById,
currentUserId,
),
iconDescriptions: Array.isArray(snapshot.iconDescriptions)
? snapshot.iconDescriptions.filter(
@@ -520,10 +544,12 @@ export function hydrateCanvasGenerationDialog(
publicationReferences: hydrateCharacterReferences(
snapshot.publicationReferences,
resourcesById,
currentUserId,
),
uiDesignSpecReference: hydrateCharacterReference(
snapshot.uiDesignSpecReference,
resourcesById,
currentUserId,
),
imageModel: stringOrUndefined(snapshot.imageModel),
videoModel:
@@ -718,6 +744,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,
@@ -775,6 +806,7 @@ function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
export type CanvasLayerResourceMetadata = {
resourceId?: string | null;
ownerUserId?: string | null;
imageSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
@@ -1171,9 +1203,16 @@ function hydrateSpecFormValues(
function resolveHydratedReferenceResource(
resourceId: string | undefined,
resourcesById: Map<string, CanvasLayerResourceMetadata>,
currentUserId?: string | null,
) {
if (resourceId) {
return resourcesById.get(resourceId);
const resource = resourcesById.get(resourceId);
const currentUser = normalizedCurrentUserId(currentUserId);
const ownerUserId = resource?.ownerUserId?.trim() || null;
if (currentUser && ownerUserId && ownerUserId !== currentUser) {
return null;
}
return resource;
}
return undefined;
}
@@ -1181,6 +1220,7 @@ function resolveHydratedReferenceResource(
function hydrateCharacterReference(
value: unknown,
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
currentUserId?: string | null,
): CharacterReferenceImage | null {
if (!value || typeof value !== 'object') {
return null;
@@ -1199,7 +1239,18 @@ function hydrateCharacterReference(
const sizeBytes = numberFromSnapshot(snapshot.sizeBytes, 0) || undefined;
const durationSeconds =
numberFromSnapshot(snapshot.durationSeconds, 0) || undefined;
const resource = resolveHydratedReferenceResource(resourceId, resourcesById);
const resource = resolveHydratedReferenceResource(
resourceId,
resourcesById,
currentUserId,
);
if (
resource === null ||
(currentUserId && resourceId && !resource) ||
(!resource && isReferencePointerSrc(src))
) {
return null;
}
const objectKey =
stringOrUndefined(snapshot.objectKey) ?? stringOrUndefined(resource?.objectKey);
const assetObjectId =
@@ -1226,10 +1277,15 @@ function hydrateCharacterReference(
function hydrateCharacterReferences(
value: unknown,
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
currentUserId?: string | null,
): CharacterReferenceImage[] | undefined {
return Array.isArray(value)
? value.flatMap((reference) => {
const hydrated = hydrateCharacterReference(reference, resourcesById);
const hydrated = hydrateCharacterReference(
reference,
resourcesById,
currentUserId,
);
return hydrated ? [hydrated] : [];
})
: 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,10 @@ function createStageProps(): ImageCanvasStageViewProps {
hoveredLayerId: null,
canvasMarquee: null,
canvasGenerationDialogs: [],
editorAgentAssets: [],
taskListRefreshKey: 0,
isTaskSidebarOpen: false,
isAgentConversationOpen: false,
generateDialog: null,
cropExpandPanel: null,
cropExpandSourceLayer: null,
@@ -178,9 +180,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;
@@ -285,6 +290,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,15 +50,19 @@ import type {
EditorAsset,
ImageContextMenuState,
QuickEditPanelState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
import { applyEditorGenerationPricingConfig } from './ImageCanvasGenerationModel';
import {
applyEditorGenerationPricingConfig,
isCanvasGenerationDialog,
} from './ImageCanvasGenerationModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
import {
isCanvasGenerationComposerVisible,
resolveQuickEditFocusViewport,
} from './ImageCanvasOverlayModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
import {
getCanvasGenerationSelectionId,
getSelectedGenerationDialogIds,
@@ -90,6 +96,87 @@ const TASK_FOCUS_BOTTOM_INSET = 90;
const TASK_FOCUS_DIALOG_BOTTOM_INSET = 280;
const TASK_FOCUS_PADDING = 18;
type CanvasStartupTool =
| 'character-spec'
| 'icon-spec'
| 'ui-design'
| 'background-music'
| 'image'
| 'publication-cover';
const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
'character-spec',
'icon-spec',
'ui-design',
'background-music',
'image',
'publication-cover',
];
type ImageCanvasEditorViewProps = {
onProjectAccessLost?: () => void;
};
function isCanvasStartupTool(value: string | null): value is CanvasStartupTool {
return CANVAS_STARTUP_TOOLS.includes(value as CanvasStartupTool);
}
function readCanvasStartupIntent() {
if (typeof window === 'undefined') {
return { showToolbarGuide: false, tool: null };
}
const params = new URLSearchParams(window.location.search);
const rawTool = params.get('tool');
return {
showToolbarGuide: params.get('guide') === 'toolbar',
tool: isCanvasStartupTool(rawTool) ? rawTool : null,
};
}
function clearCanvasStartupIntent() {
if (typeof window === 'undefined') {
return;
}
const params = new URLSearchParams(window.location.search);
params.delete('tool');
params.delete('guide');
const search = params.toString();
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash}`,
);
}
function openCanvasStartupTool(
tool: CanvasStartupTool,
generationSurface: ReturnType<typeof useImageCanvasGenerationSurface>,
) {
if (tool === 'character-spec') {
generationSurface.openSpecDialog('character');
return;
}
if (tool === 'icon-spec') {
generationSurface.openSpecDialog('ui');
return;
}
if (tool === 'ui-design') {
generationSurface.openUiDesignGenerationDialog();
return;
}
if (tool === 'background-music') {
generationSurface.openBackgroundMusicGenerationDialog();
return;
}
if (tool === 'publication-cover') {
generationSurface.openPublicationGenerationDialog(
'publication-cover-image',
);
return;
}
generationSurface.openGenerateDialog();
}
function resolveLayerProjectResourceImageSrc(layer: CanvasLayer) {
const objectKey = layer.objectKey?.trim();
if (objectKey) {
@@ -200,7 +287,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>(
@@ -227,8 +316,11 @@ export function ImageCanvasEditorView() {
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const startupIntentConsumedRef = useRef(false);
const selectedLayerIdRef = useRef<string | null>(null);
const showRechargeEntry = shouldShowRechargeEntry();
const currentEditorUserId = authUi?.user?.id ?? null;
const [isToolbarGuideVisible, setIsToolbarGuideVisible] = useState(false);
useEffect(() => {
let isMounted = true;
@@ -379,7 +471,7 @@ export function ImageCanvasEditorView() {
});
}, [authUi]);
useEffect(() => {
if (!authUi?.canAccessProtectedData || !authUi.user) {
if (!authUi?.canAccessProtectedData || !authUi.user?.id) {
setWalletBalanceLabel(null);
setIsWalletBalanceLoading(false);
return;
@@ -509,7 +601,7 @@ export function ImageCanvasEditorView() {
toggleAssetFolder,
commitNewAssetFolder,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
submitAssetToShowcase,
startRenamingFolder,
commitFolderRename,
deleteAssetFolder,
@@ -900,17 +992,23 @@ export function ImageCanvasEditorView() {
setViewport,
],
);
const { projectId, appendCanvasLayersWithResources, applyProjectSnapshot } =
useImageCanvasProjectPersistence({
refs: projectPersistenceRefs,
setters: projectPersistenceSetters,
layers,
canvasGenerationDialogs,
viewport,
isViewportInteracting,
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
openEditorLoginModal,
});
const {
projectId,
isProjectReady,
appendCanvasLayersWithResources,
applyProjectSnapshot,
} = useImageCanvasProjectPersistence({
refs: projectPersistenceRefs,
setters: projectPersistenceSetters,
layers,
canvasGenerationDialogs,
viewport,
isViewportInteracting,
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
currentUserId: currentEditorUserId,
openEditorLoginModal,
onProjectAccessLost,
});
const applyGeneratedProjectSnapshot = useCallback(
(project: EditorProjectSnapshot) => {
applyProjectSnapshot(project);
@@ -918,6 +1016,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) {
@@ -1043,11 +1155,50 @@ export function ImageCanvasEditorView() {
persistGeneratedAsset,
persistUpdatedLayerResource,
projectId,
currentUserId: currentEditorUserId,
assetFolderId: activeUploadFolderId,
upsertGeneratedAsset,
applyProjectSnapshot: applyGeneratedProjectSnapshot,
onWalletBalanceMayHaveChanged: refreshEditorWalletBalance,
});
const [isAgentConversationOpen, setIsAgentConversationOpen] = useState(false);
const toggleAgentConversation = useCallback(() => {
const nextOpen = !isAgentConversationOpen;
if (nextOpen) {
setActiveSidebarPanel(null);
if (generationSurface.isTaskSidebarOpen) {
generationSurface.toggleTaskSidebar();
}
}
setIsAgentConversationOpen(nextOpen);
}, [generationSurface, isAgentConversationOpen, setActiveSidebarPanel]);
const toggleTaskSidebar = useCallback(() => {
if (!generationSurface.isTaskSidebarOpen) {
setActiveSidebarPanel(null);
setIsAgentConversationOpen(false);
}
generationSurface.toggleTaskSidebar();
}, [generationSurface, setActiveSidebarPanel]);
const toggleCanvasSidebarPanel = useCallback(
(panel: SidebarPanel) => {
setIsAgentConversationOpen(false);
if (generationSurface.isTaskSidebarOpen) {
generationSurface.toggleTaskSidebar();
}
toggleSidebarPanel(panel);
},
[generationSurface, toggleSidebarPanel],
);
useEffect(() => {
if (generationSurface.isTaskSidebarOpen && isAgentConversationOpen) {
setIsAgentConversationOpen(false);
}
}, [generationSurface.isTaskSidebarOpen, isAgentConversationOpen]);
useEffect(() => {
if (activeSidebarPanel && isAgentConversationOpen) {
setIsAgentConversationOpen(false);
}
}, [activeSidebarPanel, isAgentConversationOpen]);
const {
setQuickEditPanel,
setCropExpandPanel,
@@ -1074,6 +1225,7 @@ export function ImageCanvasEditorView() {
setIsPickingUiDesignSpecFromCanvas,
openCharacterAnimationPanel,
openRedrawPanel,
openQuickEditPanel,
openCropExpandPanel,
removeSelectedLayerBackground,
extractUiDesignAssets,
@@ -1110,6 +1262,35 @@ export function ImageCanvasEditorView() {
setQuickEditPanelRef.current = setQuickEditPanel;
appendUiAssetExtractionReferencesRef.current =
appendUiAssetExtractionReferences;
useEffect(() => {
if (!isProjectReady || startupIntentConsumedRef.current) {
return;
}
const startupIntent = readCanvasStartupIntent();
if (!startupIntent.showToolbarGuide && !startupIntent.tool) {
startupIntentConsumedRef.current = true;
return;
}
startupIntentConsumedRef.current = true;
clearCanvasStartupIntent();
if (startupIntent.tool) {
setIsToolbarGuideVisible(false);
openCanvasStartupTool(startupIntent.tool, generationSurface);
return;
}
if (
startupIntent.showToolbarGuide &&
layersRef.current.length === 0 &&
canvasGenerationDialogsRef.current.length === 0
) {
setIsToolbarGuideVisible(true);
}
}, [generationSurface, isProjectReady]);
useEffect(() => {
if (isToolbarGuideVisible && isCanvasGenerationDialog(generateDialog)) {
setIsToolbarGuideVisible(false);
}
}, [generateDialog, isToolbarGuideVisible]);
const clearGenerationPanelsAfterBlur = useCallback(() => {
setQuickEditPanel(null);
setCropExpandPanel(null);
@@ -1124,11 +1305,14 @@ export function ImageCanvasEditorView() {
clearCanvasFocus,
handleCanvasContextMenu,
handleLayerContextMenu,
handleGenerationFrameContextMenu,
} = useImageCanvasStageController({
layers,
canvasGenerationDialogs,
selectedLayerId,
selectedLayerIds,
setSelectedLayerId,
setSelectedLayerIds,
imageContextMenu,
setImageContextMenu,
contextMenu,
@@ -1676,7 +1860,7 @@ export function ImageCanvasEditorView() {
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
submitAssetToShowcase,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset: downloadAsset,
@@ -1735,8 +1919,11 @@ export function ImageCanvasEditorView() {
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
editorAgentAssets: assets,
isToolbarGuideVisible,
taskListRefreshKey: generationSurface.taskListRefreshKey,
isTaskSidebarOpen: generationSurface.isTaskSidebarOpen,
isAgentConversationOpen,
generateDialog,
cropExpandPanel: generationSurface.cropExpandPanel,
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
@@ -1782,11 +1969,14 @@ 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: openLayerGenerationDialog,
onOpenQuickEditPanel: openQuickEditPanel,
onOpenRedrawPanel: openRedrawPanel,
onOpenCropExpandPanel: openCropExpandPanel,
onRemoveBackground: removeSelectedLayerBackground,
@@ -1830,7 +2020,7 @@ export function ImageCanvasEditorView() {
onToggleBackgroundSettings: toggleBackgroundSettings,
onApplyCanvasBackgroundColor: applyCanvasBackgroundColor,
onCanvasBackgroundHexChange: handleCanvasBackgroundHexChange,
onToggleSidebarPanel: toggleSidebarPanel,
onToggleSidebarPanel: toggleCanvasSidebarPanel,
onToggleMinimap: toggleMinimap,
onMinimapPointerDown: handleMinimapPointerDown,
onSwitchTool: switchTool,
@@ -117,7 +117,7 @@ function renderComposer(
}
describe('ImageCanvasGenerationComposerView', () => {
it('让快速编辑复用普通图片生成面板结构', () => {
it('让快速编辑只保留提示词和模型选择', () => {
renderComposer({
mode: 'quick-edit',
prompt: '',
@@ -142,6 +142,20 @@ describe('ImageCanvasGenerationComposerView', () => {
);
expect(within(panel).getByRole('textbox', { name: '快速编辑提示词' }))
.toBeTruthy();
expect(
within(panel).queryByRole('button', { name: '添加参考图' }),
).toBeNull();
expect(
within(panel).queryByRole('button', {
name: /快速编辑图片尺寸/u,
}),
).toBeNull();
expect(
within(panel).getByRole('button', {
name: '快速编辑图片模型 gpt-image-2',
}),
).toBeTruthy();
expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy();
expect(
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
).toBeTruthy();

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