合并 master 更新到 AI 游戏创作分支
合入 master 的画布 Agent、精选素材审核、外部编辑器 API 与认证投影更新 保留当前分支的 AI 游戏创作壳、配置项和 LLM 运行接口改造 解决图片编辑器、LLM、去背景完成、外部生成排序和文档冲突 沿用 master 的 SpacetimeDB auth_store_projection 迁移与生成绑定
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user