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

合入 master 的画布 Agent、精选素材审核、外部编辑器 API 与认证投影更新
保留当前分支的 AI 游戏创作壳、配置项和 LLM 运行接口改造
解决图片编辑器、LLM、去背景完成、外部生成排序和文档冲突
沿用 master 的 SpacetimeDB auth_store_projection 迁移与生成绑定
This commit is contained in:
AIGameCreator App
2026-07-06 11:47:19 +08:00
309 changed files with 24952 additions and 3100 deletions
@@ -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();
@@ -1198,11 +1198,7 @@ export function ImageCanvasGenerationComposerView({
</div>
) : null}
{quickEditPanel &&
(quickEditPanel.mode === 'redraw' ||
quickEditPanel.status !== 'generating') &&
quickEditSourceLayer &&
quickEditPanelStyle ? (
{quickEditPanel && quickEditSourceLayer && quickEditPanelStyle ? (
<ImageCanvasQuickEditPanelView
panel={quickEditPanel}
sourceLayer={quickEditSourceLayer}
@@ -37,7 +37,7 @@ import {
updateSpecFormDialogValue,
} from './ImageCanvasGenerationDialogModel';
import {
IMAGE_MODEL_NANOBANANA2,
IMAGE_MODEL_GPT_IMAGE_2,
} from './ImageCanvasGenerationModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
@@ -649,21 +649,21 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('normalizes legacy nanobanana model aliases in quick edit drafts', () => {
it('locks quick edit drafts to gpt-image-2 despite legacy source models', () => {
expect(
createQuickEditPanelDraft(
createLayer({
model: 'nano-banana',
}),
).model,
).toBe(IMAGE_MODEL_NANOBANANA2);
).toBe(IMAGE_MODEL_GPT_IMAGE_2);
expect(
createQuickEditPanelDraft(
createLayer({
model: 'nanobanana2',
}),
).model,
).toBe(IMAGE_MODEL_NANOBANANA2);
).toBe(IMAGE_MODEL_GPT_IMAGE_2);
});
it('creates character animation generation dialog drafts for character layers', () => {
@@ -1187,9 +1187,7 @@ export function createQuickEditPanelDraft(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const model = normalizeEditorImageModel(
options.imageModel ?? sourceLayer.model,
);
const model = IMAGE_MODEL_GPT_IMAGE_2;
return {
mode: 'quick-edit',
sourceLayerId: sourceLayer.id,
@@ -1211,7 +1209,6 @@ export function createQuickEditGenerationDialogDraft({
prompt,
status = 'idle',
references = [],
model,
aspectRatio,
imageSize,
frame,
@@ -1234,7 +1231,7 @@ export function createQuickEditGenerationDialogDraft({
composerOpen: false,
sourceLayerId: sourceLayer.id,
generationReferences: references.slice(0, 8),
imageModel: normalizeEditorImageModel(model ?? sourceLayer.model),
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio,
imageSize,
placeholder: {
@@ -1259,6 +1256,7 @@ export function createRedrawPanelDraft(
return {
...createQuickEditPanelDraft(sourceLayer, options),
mode: 'redraw',
model: normalizeEditorImageModel(options.imageModel ?? sourceLayer.model),
prompt: resolveUserGenerationPromptSnapshot(sourceLayer),
};
}
@@ -19,6 +19,7 @@ import {
getEditorImageModelDisplayName,
IMAGE_MODEL_NANOBANANA2,
normalizeEditorImageModel,
QUICK_EDIT_MODEL_OPTIONS,
resizeGenerationPlaceholderToImageSelection,
resolveEditorImageSizeLabel,
} from './ImageCanvasGenerationModel';
@@ -135,6 +136,7 @@ export function ImageCanvasGenerationImageOptionsView({
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
const isQuickEdit = dialog.mode === 'quick-edit';
const normalizedLockedModel = lockedModel
? normalizeEditorImageModel(lockedModel)
: null;
@@ -143,7 +145,17 @@ export function ImageCanvasGenerationImageOptionsView({
? { ...dialog, imageModel: normalizedLockedModel }
: dialog,
);
const selectedModelLabel = getEditorImageModelDisplayName(selection.model);
const modelOptions = isQuickEdit
? [...QUICK_EDIT_MODEL_OPTIONS]
: [...EDITOR_IMAGE_MODEL_OPTIONS];
const selectedModel = modelOptions.some(
(option) => option.value === selection.model,
)
? selection.model
: (modelOptions[0]?.value ?? selection.model);
const selectedModelLabel =
modelOptions.find((option) => option.value === selectedModel)?.label ??
getEditorImageModelDisplayName(selectedModel);
const selectedSizeLabel = resolveEditorImageSizeLabel({
aspectRatio: selection.aspectRatio,
imageSize: selection.imageSize,
@@ -340,8 +352,8 @@ export function ImageCanvasGenerationImageOptionsView({
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
{EDITOR_IMAGE_MODEL_OPTIONS.map((option) => {
const selected = selection.model === option.value;
{modelOptions.map((option) => {
const selected = selectedModel === option.value;
return (
<OptionChoice
key={option.value}
@@ -461,6 +461,27 @@ describe('ImageCanvasGenerationLayerModel', () => {
priceMudPoints: 10,
audioKind: 'sound-effect',
durationSeconds: 75,
resource: {
resourceId: 'resource-audio-bff',
projectId: 'editor-project-1',
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
assetObjectId: 'assetobj-audio-1',
width: 420,
height: 120,
sourceType: 'generated',
assetKind: 'sound-effect',
},
asset: {
assetId: 'asset-audio-bff',
folderId: 'user-1:asset-folder:project',
label: '游戏音效 14',
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
width: 420,
height: 120,
sourceType: 'generated',
assetKind: 'sound-effect',
},
},
generatedIndex: 14,
title: '游戏音效 14',
@@ -482,7 +503,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
expect(layer).toMatchObject({
id: 'layer-audio-14',
resourceId: 'local-resource-audio-14',
resourceId: 'resource-audio-bff',
title: '游戏音效 14',
src: '/generated-character-drafts/editor-audios/sfx.mp3',
mediaType: 'audio',
@@ -500,7 +521,11 @@ describe('ImageCanvasGenerationLayerModel', () => {
taskId: 'sound-task-1',
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
assetObjectId: 'assetobj-audio-1',
sourceAssetId: 'asset-audio-bff',
durationSeconds: 75,
generatedAssetSnapshot: expect.objectContaining({
assetId: 'asset-audio-bff',
}),
});
});
@@ -459,6 +459,8 @@ export function createAudioResultLayer({
generationInputs,
frame,
}: AudioResultLayerOptions): CanvasLayer {
const resource = generated.resource;
const asset = generated.asset;
const originalWidth = generated.width || 420;
const originalHeight = generated.height || 120;
const { width, height } = resolveLayerResolutionSize(
@@ -478,9 +480,10 @@ export function createAudioResultLayer({
return {
id: `layer-audio-${generatedIndex}`,
resourceId: `local-resource-audio-${generatedIndex}`,
resourceId:
resource?.resourceId ?? `local-resource-audio-${generatedIndex}`,
title,
src: generated.audioSrc,
src: resource?.imageSrc ?? generated.audioSrc,
mediaType: 'audio',
assetKind: generated.audioKind,
x: frameX ?? worldCenter.x - width / 2,
@@ -496,10 +499,12 @@ export function createAudioResultLayer({
model: generated.model,
provider: generated.provider,
taskId: generated.taskId,
objectKey: generated.objectKey,
assetObjectId: generated.assetObjectId,
objectKey: resource?.objectKey ?? generated.objectKey,
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
sourceAssetId: asset?.assetId,
durationSeconds: generated.durationSeconds ?? undefined,
generationInputs,
generationInputs: resource?.generationInputs ?? generationInputs,
generatedAssetSnapshot: asset ?? undefined,
};
}

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