合并编辑器素材库分支

合并 codex/editor-asset-library 到 AI 游戏创作 App 分支。

保留 AI 游戏创作客户端配置决策,并合入素材库分支新增文档。

排除素材库分支误带的 .env.local 本机密钥改动。
This commit is contained in:
AIGameCreator App
2026-06-30 15:23:41 +08:00
197 changed files with 18543 additions and 4560 deletions
+55
View File
@@ -294,6 +294,61 @@ describe('App navigation history', () => {
);
});
test('缺少 projectid 的项目画布直达会替换回创作页', () => {
window.history.replaceState(null, '', '/editor/canvas');
const replaceStateSpy = vi.spyOn(window.history, 'replaceState');
renderApp();
expect(screen.getByTestId('selection-stage').textContent).toBe(
'creation-home',
);
expect(window.location.pathname).toBe('/creation');
expect(window.location.search).toBe('');
expect(replaceStateSpy).toHaveBeenCalledWith(
{ [APP_HISTORY_STATE_KEY]: true },
'',
'/creation',
);
replaceStateSpy.mockRestore();
});
test('带 projectid 的项目画布直达保持编辑器阶段', () => {
mockMatchMedia(false);
window.history.replaceState(
null,
'',
'/editor/canvas?projectid=project-from-url',
);
renderApp();
expect(screen.getByTestId('selection-stage').textContent).toBe(
'image-editor',
);
expect(window.location.pathname).toBe('/editor/canvas');
expect(window.location.search).toBe('?projectid=project-from-url');
});
test('历史回到缺少 projectid 的项目画布时会替换回创作页', async () => {
mockMatchMedia(false);
window.history.replaceState(null, '', '/creation');
renderApp();
window.history.pushState(null, '', '/editor/canvas');
await act(async () => {
window.dispatchEvent(new PopStateEvent('popstate'));
});
expect(screen.getByTestId('selection-stage').textContent).toBe(
'creation-home',
);
expect(window.location.pathname).toBe('/creation');
expect(window.location.search).toBe('');
});
test('项目画布导航只写入一次带 projectid 的历史记录', async () => {
mockMatchMedia(true);
window.history.replaceState(null, '', '/creation');
+32 -5
View File
@@ -25,6 +25,7 @@ import {
readPublicWorkCodeFromLocationSearch,
resolveInitialSelectionStageFromPath,
resolvePathForSelectionStage,
shouldRedirectEditorCanvasWithoutProject,
} from './routing/appPageRoutes';
import type { RpgRuntimeAppIntent } from './RpgRuntimeApp';
import {
@@ -62,6 +63,23 @@ function isRpgRuntimeRoute(pathname: string) {
);
}
function resolveInitialAppSelectionStage() {
if (
shouldRedirectEditorCanvasWithoutProject(
window.location.pathname,
window.location.search,
)
) {
replaceAppHistoryPath('/creation');
return 'creation-home';
}
return resolveInitialSelectionStageFromPath(
window.location.pathname,
getInitialPlatformDesktopLayout(),
);
}
export default function App() {
const authUi = useAuthUi();
const runtimeIntentTokenRef = useRef(0);
@@ -75,11 +93,8 @@ export default function App() {
const [isRuntimeActive, setIsRuntimeActive] = useState(() =>
isRpgRuntimeRoute(window.location.pathname),
);
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
resolveInitialSelectionStageFromPath(
window.location.pathname,
getInitialPlatformDesktopLayout(),
),
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(
resolveInitialAppSelectionStage,
);
const [runtimeReturnStage, setRuntimeReturnStage] =
useState<SelectionStage>('platform');
@@ -111,6 +126,18 @@ export default function App() {
window.history.state,
);
if (
shouldRedirectEditorCanvasWithoutProject(
window.location.pathname,
window.location.search,
)
) {
replaceAppHistoryPath('/creation');
setIsRuntimeActive(false);
setRawSelectionStage('creation-home');
return;
}
if (isRpgRuntimeRoute(window.location.pathname)) {
setIsRuntimeActive(true);
return;
+6 -15
View File
@@ -18,7 +18,7 @@ const baseUser: AuthUser = {
wechatBound: true,
};
test('绑定手机号表单复用平台输入和字段标题', async () => {
test('绑定手机号表单展示当前身份并提交手机号验证码', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn().mockResolvedValue(undefined);
@@ -42,23 +42,14 @@ test('绑定手机号表单复用平台输入和字段标题', async () => {
const phoneInput = screen.getByLabelText('手机号') as HTMLInputElement;
const codeInput = screen.getByLabelText('验证码') as HTMLInputElement;
expect(phoneInput.className).toContain('platform-text-field');
expect(codeInput.className).toContain('platform-text-field');
expect(screen.getByText('手机号').className).toContain(
'text-[var(--platform-text-strong)]',
);
expect(screen.getByText('当前登录身份:微信旅人').className).toContain(
'platform-subpanel',
);
expect(
document
.querySelector('.selection-hero-brand__image')
?.getAttribute('src'),
).toBe('/branding/taonier-product-ip.png');
expect(screen.getByText('手机号')).toBeTruthy();
expect(screen.getByText('当前登录身份:微信旅人')).toBeTruthy();
await user.type(phoneInput, '13800000000');
await user.type(codeInput, '123456');
await user.click(screen.getByRole('button', { name: '绑定手机号并进入游戏' }));
await user.click(
screen.getByRole('button', { name: '绑定手机号并进入游戏' }),
);
expect(onSubmit).toHaveBeenCalledWith('13800000000', '123456');
});
@@ -118,6 +118,9 @@ describe('CreationLandingView', () => {
expect(screen.getByText('游戏视觉规范')).toBeTruthy();
expect(screen.getByRole('heading', { name: '陶泥儿精选' })).toBeTruthy();
expect(screen.getByRole('tab', { name: '素材包' })).toBeTruthy();
expect(screen.queryByRole('tab', { name: '特效' })).toBeNull();
expect(screen.queryByRole('tab', { name: '场景' })).toBeNull();
expect(screen.queryByRole('tab', { name: '游戏' })).toBeNull();
expect(screen.queryByText('精选入口')).toBeNull();
expect(screen.queryByText(/Meowa|Discord/u)).toBeNull();
expect(await screen.findByText('暂无素材')).toBeTruthy();
@@ -235,7 +238,9 @@ describe('CreationLandingView', () => {
provider: 'provider',
taskId: 'task-1',
assetKind: 'character',
authorName: '创作者A',
ownerUserId: 'user-internal-author',
authorDisplayName: '创作者A',
authorPublicUserCode: 'SY-00000042',
priceMudPoints: 20,
},
]);
@@ -249,6 +254,7 @@ describe('CreationLandingView', () => {
expect(card).toBeTruthy();
expect(within(card as HTMLElement).getByText('character hero prompt')).toBeTruthy();
expect(within(card as HTMLElement).getByText('创作者A')).toBeTruthy();
expect(within(card as HTMLElement).queryByText('user-internal-author')).toBeNull();
expect(within(card as HTMLElement).getByText('20泥点')).toBeTruthy();
});
@@ -270,7 +276,8 @@ describe('CreationLandingView', () => {
provider: 'provider',
taskId: 'task-1',
assetKind: 'character',
authorName: '创作者A',
authorDisplayName: '创作者A',
authorPublicUserCode: 'SY-00000042',
priceMudPoints: 20,
},
{
@@ -76,21 +76,41 @@ describe('creationShowcaseModel', () => {
expect(items[0]?.label).toBe('公开素材');
});
it('uses the public project resource owner user id as showcase author fallback', () => {
it('uses public author fields without falling back to internal owner user id', () => {
const items = buildCreationShowcaseItems({
activeTab: 'characters',
projectResources: [
createResource({
resourceId: 'owner-resource',
label: '作者兜底素材',
resourceId: 'display-author-resource',
label: '展示名作者素材',
assetKind: 'character',
ownerUserId: 'user-author-1',
authorDisplayName: '作者昵称',
authorPublicUserCode: 'SY-00000001',
}),
createResource({
resourceId: 'code-author-resource',
label: '陶泥号作者素材',
assetKind: 'character',
ownerUserId: 'user-author-2',
authorPublicUserCode: 'SY-00000002',
}),
createResource({
resourceId: 'owner-only-resource',
label: '内部id素材',
assetKind: 'character',
ownerUserId: 'user-author-3',
authorName: 'user-author-3',
ownerName: 'user-author-3',
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.author).toBe('user-author-1');
expect(items.map((item) => item.author)).toEqual([
'作者昵称',
'SY-00000002',
'-',
]);
});
it('deduplicates canvas copies that point back to the same generated resource', () => {
@@ -137,7 +157,7 @@ describe('creationShowcaseModel', () => {
assetKind: 'spec',
prompt: '规范提示词',
priceMudPoints: 5,
authorName: '创作者A',
authorDisplayName: '创作者A',
}),
createResource({
resourceId: 'character-1',
@@ -21,10 +21,7 @@ export type ShowcaseTabId =
| 'characters'
| 'ui'
| 'music'
| 'effects'
| 'scenes'
| 'marketing'
| 'games';
| 'marketing';
export type ShowcaseAssetMediaType = 'image' | 'video' | 'audio';
@@ -62,24 +59,14 @@ const SHOWCASE_COST_KEYS = [
'mudPointCost',
] as const;
const SHOWCASE_AUTHOR_KEYS = [
'authorName',
'creatorName',
'ownerLabel',
'ownerName',
'authorDisplayName',
'ownerUserId',
] as const;
const SHOWCASE_AUTHOR_KEYS = ['authorDisplayName', 'authorPublicUserCode'] as const;
export const SHOWCASE_TABS: Array<{ id: ShowcaseTabId; label: string }> = [
{ id: 'packs', label: '素材包' },
{ id: 'characters', label: '角色' },
{ id: 'ui', label: 'UI' },
{ id: 'music', label: '音乐' },
{ id: 'effects', label: '特效' },
{ id: 'scenes', label: '场景' },
{ id: 'marketing', label: '美宣' },
{ id: 'games', label: '游戏' },
];
function assetRecord(asset: EditorAssetSnapshot) {
@@ -362,18 +349,6 @@ function isMarketingAsset(asset: EditorAssetSnapshot) {
);
}
function isSceneAsset(asset: EditorAssetSnapshot) {
return /场景|scene|background/u.test(getAssetHaystack(asset));
}
function isEffectAsset(asset: EditorAssetSnapshot) {
return /特效|effect|vfx/u.test(getAssetHaystack(asset));
}
function isGameAsset(asset: EditorAssetSnapshot) {
return /游戏工程|game asset|game package|导出工程/u.test(getAssetHaystack(asset));
}
function addAssetToGroup(
groups: Map<string, ShowcaseAssetGroup>,
groupId: string,
@@ -575,13 +550,7 @@ function getGroupsForTab(assets: EditorAssetSnapshot[], tab: ShowcaseTabId) {
if (tab === 'marketing') {
return groupSingleAssets(assets, isMarketingAsset);
}
if (tab === 'effects') {
return groupSingleAssets(assets, isEffectAsset);
}
if (tab === 'scenes') {
return groupSingleAssets(assets, isSceneAsset);
}
return groupSingleAssets(assets, isGameAsset);
return [];
}
function resolveAssetPrompt(asset: EditorAssetSnapshot) {
@@ -46,17 +46,9 @@ test('shows cost range and opens an independent adjustment dialog', () => {
fireEvent.click(within(confirmDialog).getByRole('button', { name: /调整/u }));
const adjustDialog = screen.getByRole('dialog', { name: '调整拼图模板' });
expect(adjustDialog.parentElement).not.toBe(confirmDialog);
expect(within(adjustDialog).getByText('关卡数').className).toContain(
'inline-flex',
);
expect(within(adjustDialog).getByText('关卡数')).toBeTruthy();
fireEvent.click(within(adjustDialog).getByRole('button', { name: '多关卡' }));
const levelCountInput = within(adjustDialog).getByLabelText('计划关卡数');
expect(levelCountInput.className).toContain('bg-white/90');
expect(levelCountInput.className).toContain(
'focus:ring-[var(--platform-warm-border)]',
);
expect(levelCountInput.className).toContain('font-bold');
fireEvent.change(within(adjustDialog).getByLabelText('计划关卡数'), {
target: { value: '4' },
});
@@ -86,14 +78,7 @@ test('template preview uses platform media frame with image and fallback states'
);
const previewImage = screen.getByRole('img', { name: '创意拼图' });
const previewFrame = previewImage.closest('div.relative');
expect(previewFrame?.className).toContain('aspect-[16/9]');
expect(previewFrame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(previewFrame?.className).toContain('bg-white/68');
expect(previewFrame?.className).toContain('rounded-[1.25rem]');
expect(previewImage.getAttribute('src')).toBe('/template-preview.webp');
const selectionWithoutPreview = {
...createSelection(),
@@ -108,16 +93,6 @@ test('template preview uses platform media frame with image and fallback states'
/>,
);
const fallbackFrame = screen
.getByRole('dialog', {
name: '确认拼图模板',
})
.querySelector('div.relative.aspect-\\[16\\/9\\]');
const fallbackIcon = fallbackFrame?.querySelector('svg');
expect(fallbackFrame?.className).toContain('aspect-[16/9]');
expect(fallbackFrame?.className).toContain(
'border-[var(--platform-subpanel-border)]',
);
expect(fallbackIcon?.closest('span')?.className).toContain('bg-white/84');
expect(screen.queryByRole('img', { name: '创意拼图' })).toBeNull();
expect(screen.getByRole('dialog', { name: '确认拼图模板' })).toBeTruthy();
});
@@ -66,6 +66,7 @@ type ImageCanvasAssetFolderSectionViewProps = {
) => void;
toggleAssetSelected: (assetId: string) => void;
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
};
export function ImageCanvasAssetFolderSectionView({
@@ -97,6 +98,7 @@ export function ImageCanvasAssetFolderSectionView({
setAssetPublicShowcaseEnabled,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset,
}: ImageCanvasAssetFolderSectionViewProps) {
return (
<section
@@ -256,6 +258,7 @@ export function ImageCanvasAssetFolderSectionView({
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
onDownloadAsset={onDownloadAsset}
/>
))}
</div>
@@ -96,6 +96,7 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
) => void;
toggleAssetSelected: (assetId: string) => void;
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
toggleAllAssetsSelected: () => void;
deleteSelectedAssets: () => void;
closeAssetSelectionMode: () => void;
@@ -143,6 +144,7 @@ export function ImageCanvasAssetLibraryPanelView({
setAssetPublicShowcaseEnabled,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset,
toggleAllAssetsSelected,
deleteSelectedAssets,
closeAssetSelectionMode,
@@ -234,6 +236,7 @@ export function ImageCanvasAssetLibraryPanelView({
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
onDownloadAsset={onDownloadAsset}
/>
))}
{isAssetSelectionMode ? (
@@ -2,11 +2,17 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { EditorAsset } from './ImageCanvasEditorTypes';
import { ImageCanvasAssetRowView } from './ImageCanvasAssetRowView';
const useResolvedAssetReadUrlMock = vi.hoisted(() => vi.fn());
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: useResolvedAssetReadUrlMock,
}));
vi.mock('../common/PlatformMediaFrame', () => ({
PlatformMediaFrame: ({
src,
@@ -32,6 +38,18 @@ vi.mock('../common/PlatformMediaFrame', () => ({
),
}));
beforeEach(() => {
useResolvedAssetReadUrlMock.mockImplementation(
(source: string | null | undefined, options?: { objectKey?: string | null }) => ({
resolvedUrl: options?.objectKey
? `https://signed.example.com/${options.objectKey}`
: (source ?? ''),
isResolving: false,
shouldResolve: Boolean(options?.objectKey),
}),
);
});
function createAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
return {
id: 'asset-1',
@@ -58,6 +76,7 @@ function renderAssetRow({
commitAssetRename = vi.fn(),
deleteUploadedAsset = vi.fn(),
setAssetPublicShowcaseEnabled = vi.fn(),
onDownloadAsset = vi.fn(),
}: {
asset?: EditorAsset;
isAssetSelectionMode?: boolean;
@@ -72,6 +91,7 @@ function renderAssetRow({
asset: EditorAsset,
publicShowcaseEnabled: boolean,
) => void;
onDownloadAsset?: (asset: EditorAsset) => void;
} = {}) {
const props = {
asset,
@@ -93,6 +113,7 @@ function renderAssetRow({
setAssetPublicShowcaseEnabled,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset,
};
render(<ImageCanvasAssetRowView {...props} />);
@@ -110,6 +131,46 @@ describe('ImageCanvasAssetRowView', () => {
expect(addAssetLayer).toHaveBeenCalledWith(asset);
});
it('downloads the asset from the row actions', () => {
const asset = createAsset();
const onDownloadAsset = vi.fn();
renderAssetRow({ asset, onDownloadAsset });
fireEvent.click(
screen.getByRole('button', { name: '下载素材账号素材A' }),
);
expect(onDownloadAsset).toHaveBeenCalledWith(asset);
});
it('closes the right click menu when row actions run on the same asset', () => {
const asset = createAsset({
sourceResourceId: 'resource-asset-1',
publicShowcaseEnabled: true,
});
const addAssetLayer = vi.fn();
const onDownloadAsset = vi.fn();
renderAssetRow({ asset, addAssetLayer, onDownloadAsset });
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
});
expect(screen.getByRole('menu')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '下载素材账号素材A' }));
expect(onDownloadAsset).toHaveBeenCalledWith(asset);
expect(screen.queryByRole('menu')).toBeNull();
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
});
fireEvent.click(screen.getByRole('button', { name: '添加账号素材A' }));
expect(addAssetLayer).toHaveBeenCalledWith(asset);
expect(screen.queryByRole('menu')).toBeNull();
});
it('selects the asset instead of adding it in selection mode', () => {
const addAssetLayer = vi.fn();
const toggleAssetSelected = vi.fn();
@@ -187,16 +248,19 @@ describe('ImageCanvasAssetRowView', () => {
asset: createAsset({
label: '生成视频.mp4',
src: '/generated-character-drafts/editor/asset-library/video/demo.mp4',
objectKey: 'generated-character-drafts/editor/asset-library/video/demo.mp4',
mediaType: 'video',
}),
});
expect(screen.getByText('视频')).toBeTruthy();
expect(screen.queryByRole('img', { name: '素材:生成视频.mp4' })).toBeNull();
expect(
document.querySelector('.image-canvas-editor__media-preview--video')
?.className,
).toContain('image-canvas-editor__media-preview--variant-');
const video = screen.getByLabelText(
'视频素材预览:生成视频.mp4',
) as HTMLVideoElement;
expect(video.getAttribute('src')).toBe(
'https://signed.example.com/generated-character-drafts/editor/asset-library/video/demo.mp4',
);
});
it('passes objectKey to image thumbnails in the asset library', () => {
@@ -1,8 +1,10 @@
import { Check, Music2, Pencil, Video, X } from 'lucide-react';
import { Check, Download, Music2, Pencil, Video, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { PlatformTextField } from '../common/PlatformTextField';
import type { UploadFilesOptions } from './ImageCanvasAssetLibraryPanelView';
import {
EditorIconButton,
SidebarMediaItem,
@@ -21,7 +23,6 @@ import {
getCanvasMediaPreviewClassName,
getCanvasMediaPreviewMarker,
} from './ImageCanvasMediaModel';
import type { UploadFilesOptions } from './ImageCanvasAssetLibraryPanelView';
export type ImageCanvasAssetRowViewProps = {
asset: EditorAsset;
@@ -48,6 +49,7 @@ export type ImageCanvasAssetRowViewProps = {
) => void;
toggleAssetSelected: (assetId: string) => void;
addAssetLayer: (asset: EditorAsset) => void;
onDownloadAsset: (asset: EditorAsset) => void;
};
export function ImageCanvasAssetRowView({
@@ -70,6 +72,7 @@ export function ImageCanvasAssetRowView({
setAssetPublicShowcaseEnabled,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset,
}: ImageCanvasAssetRowViewProps) {
const rowRef = useRef<HTMLDivElement | null>(null);
const [menuPosition, setMenuPosition] = useState<{
@@ -116,6 +119,25 @@ export function ImageCanvasAssetRowView({
const mediaPreviewMarker = getCanvasMediaPreviewMarker(asset);
const mediaPreviewClassName =
getCanvasMediaPreviewClassName(mediaPreviewMarker);
const { resolvedUrl: resolvedVideoPreviewUrl } = useResolvedAssetReadUrl(
asset.mediaType === 'video' ? asset.src : '',
{
enabled: asset.mediaType === 'video' && !asset.thumbnailSrc?.trim(),
objectKey: asset.mediaType === 'video' ? asset.objectKey : undefined,
refreshKey: asset.id,
},
);
const videoPreview =
asset.mediaType === 'video' && !asset.thumbnailSrc?.trim() ? (
<video
className="image-canvas-editor__asset-video-preview"
src={resolvedVideoPreviewUrl || undefined}
muted
playsInline
preload="metadata"
aria-label={`视频素材预览:${asset.label}`}
/>
) : undefined;
const mediaDetail =
asset.mediaType === 'audio'
? '音频'
@@ -184,11 +206,23 @@ export function ImageCanvasAssetRowView({
</div>
) : (
<div className="image-canvas-editor__asset-actions">
<EditorIconButton
label={`下载素材${asset.label}`}
title="下载"
icon={Download}
onClick={() => {
closeMenu();
onDownloadAsset(asset);
}}
/>
<EditorIconButton
label={`重命名素材${asset.label}`}
title="重命名"
icon={Pencil}
onClick={() => startRenamingAsset(asset)}
onClick={() => {
closeMenu();
startRenamingAsset(asset);
}}
/>
</div>
);
@@ -223,6 +257,7 @@ export function ImageCanvasAssetRowView({
if (isUploadingAsset || isFailedUpload) {
return;
}
closeMenu();
if (suppressAssetClickRef.current) {
return;
}
@@ -251,6 +286,7 @@ export function ImageCanvasAssetRowView({
: (asset.thumbnailSrc ?? '')
}
objectKey={asset.mediaType === 'image' ? asset.objectKey : undefined}
previewNode={videoPreview}
titleNode={
isUploadingAsset || isFailedUpload ? <span>{asset.label}</span> : titleNode
}
@@ -16,14 +16,27 @@ import { PlatformTextField } from '../common/PlatformTextField';
import type {
CharacterReferenceImage,
GenerateDialogState,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import {
calculateEditorImageGenerationPrice,
} from './ImageCanvasGenerationModel';
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
type ReferenceLabelFormatter = (
reference: CharacterReferenceImage,
index: number,
) => string;
type ReferenceTone =
| 'default'
| 'spec'
| 'icon'
| 'ui'
| 'quick-edit'
| 'video'
| 'audio';
type ImageCanvasBasicGenerationComposerViewProps = {
dialog: GenerateDialogState;
style: CSSProperties;
@@ -37,11 +50,41 @@ type ImageCanvasBasicGenerationComposerViewProps = {
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
onRequestUpload: (target: 'generation-reference') => void;
onRequestUpload: (target: UploadTarget) => void;
onPickReferenceFromCanvas?: () => void;
onToggleReferenceMenu?: () => void;
onRememberImageModel?: (model: string) => void;
onSubmit: (dialog: GenerateDialogState) => void;
dialogLabel?: string;
includeDimensions?: boolean;
includeModel?: boolean;
includeReferences?: boolean;
promptLabel?: string;
promptPlaceholder?: string;
optionLabelPrefix?: string;
dimensionRatioAriaLabelPrefix?: string;
dimensionSizeAriaLabelPrefix?: string;
referenceUploadTarget?: UploadTarget;
referenceButtonIcon?: ReactNode;
referenceButtonLabel?: string;
referenceButtonAriaLabel?: string;
referenceMenuLabel?: string;
referenceSlotTone?: ReferenceTone;
referenceSlotDisabled?: boolean;
referenceLabelFormatter?: ReferenceLabelFormatter;
referenceAriaLabelFormatter?: ReferenceLabelFormatter;
referenceRemoveLabelFormatter?: ReferenceLabelFormatter;
showReferenceAddButton?: boolean;
formClassName?: string;
footerClassName?: string;
promptClassName?: string;
referenceSlotClassName?: string;
submitButtonClassName?: string;
submitLabel?: string;
submitAriaLabel?: string;
submittingStatusLabel?: string;
};
function resetFailedDialogStatus(dialog: GenerateDialogState) {
return {
...dialog,
@@ -50,31 +93,6 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
};
}
function ReferenceChip({
reference,
index,
onRemove,
}: {
reference: CharacterReferenceImage;
index: number;
onRemove: () => void;
}) {
const label = reference.label || `参考图${index + 1}`;
return (
<ImageCanvasReferenceSlot
tone="default"
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
imageSrc={reference.src}
objectKey={reference.objectKey}
label={label}
ariaLabel={label}
title={reference.label}
onRemove={onRemove}
removeLabel={`删除${label}`}
/>
);
}
export function ImageCanvasBasicGenerationComposerView({
dialog,
style,
@@ -86,12 +104,60 @@ export function ImageCanvasBasicGenerationComposerView({
renderEditorPortal = (node) => node,
buildPortalMenuStyle = () => ({}),
onRequestUpload,
onPickReferenceFromCanvas,
onToggleReferenceMenu,
onRememberImageModel = () => {},
onSubmit,
dialogLabel,
includeDimensions = true,
includeModel = true,
includeReferences = true,
promptLabel,
promptPlaceholder,
optionLabelPrefix,
dimensionRatioAriaLabelPrefix,
dimensionSizeAriaLabelPrefix,
referenceUploadTarget = 'generation-reference',
referenceButtonIcon = <ImageIcon className="h-4 w-4" aria-hidden="true" />,
referenceButtonLabel = '参考图',
referenceButtonAriaLabel = '添加参考图',
referenceMenuLabel = '参考图来源',
referenceSlotTone = 'default',
referenceSlotDisabled = false,
referenceLabelFormatter,
referenceAriaLabelFormatter,
referenceRemoveLabelFormatter,
showReferenceAddButton = true,
formClassName,
footerClassName,
promptClassName = 'image-canvas-editor__generation-prompt',
referenceSlotClassName,
submitButtonClassName = 'image-canvas-editor__generation-submit',
submitLabel = '生成',
submitAriaLabel = '生成',
submittingStatusLabel = '生成中',
}: ImageCanvasBasicGenerationComposerViewProps) {
const references = dialog.generationReferences ?? [];
const isQuickEdit = dialog.mode === 'quick-edit';
const resolvedDialogLabel =
dialogLabel ?? optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
const resolvedPromptLabel =
promptLabel ?? (isQuickEdit ? '快速编辑提示词' : '生成提示词');
const resolvedPromptPlaceholder =
promptPlaceholder ??
(isQuickEdit ? '想怎么编辑这张图?' : '今天想生成什么画面?');
const resolvedOptionLabelPrefix =
optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片');
const resolvedReferenceButtonLabel = referenceButtonLabel;
const resolvedReferenceButtonAriaLabel =
referenceButtonAriaLabel ?? `添加${resolvedReferenceButtonLabel}`;
const hasReferenceMenu = includeReferences && onRequestUpload;
const finalFormClassName =
formClassName ??
'image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image';
const finalFooterClassName =
footerClassName ?? 'image-canvas-editor__generation-composer-footer';
useImageCanvasFloatingOptionDismiss({
isOpen: isGenerationReferenceMenuOpen,
boundaryRefs: [generationReferenceButtonRef],
@@ -101,10 +167,10 @@ export function ImageCanvasBasicGenerationComposerView({
return (
<>
<form
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image"
className={finalFormClassName}
style={style}
role="dialog"
aria-label={isQuickEdit ? '改造图片' : '生成图片'}
aria-label={resolvedDialogLabel}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
@@ -113,102 +179,133 @@ export function ImageCanvasBasicGenerationComposerView({
}
}}
>
<div className="image-canvas-editor__reference-strip">
{references.map((reference, index) => (
<ReferenceChip
key={reference.id}
reference={reference}
index={index}
onRemove={() =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
generationReferences: (
currentDialog.generationReferences ?? []
).filter((item) => item.id !== reference.id),
}
: currentDialog,
)
}
/>
))}
<ImageCanvasReferenceSlot
buttonRef={generationReferenceButtonRef}
tone="default"
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
label="参考图"
ariaLabel="添加参考图"
disabled={dialog.status === 'generating'}
isAdd
onClick={() => onToggleReferenceMenu?.()}
/>
</div>
<PlatformTextField
variant="textarea"
aria-label={isQuickEdit ? '改造提示词' : '生成提示词'}
value={dialog.prompt}
disabled={dialog.status === 'generating'}
placeholder={isQuickEdit ? '想怎么改造这张图?' : '今天想生成什么画面?'}
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
prompt: event.target.value,
{includeReferences ? (
<div className="image-canvas-editor__reference-strip">
{references.map((reference, index) => {
const label =
(referenceLabelFormatter?.(reference, index) ??
reference.label) ||
`参考图${index + 1}`;
const ariaLabel =
referenceAriaLabelFormatter?.(reference, index) ?? label;
const removeLabel =
referenceRemoveLabelFormatter?.(reference, index) ??
`删除${ariaLabel}`;
return (
<ImageCanvasReferenceSlot
key={reference.id}
tone={referenceSlotTone}
icon={<ImageIcon className="h-4 w-4" aria-hidden="true" />}
imageSrc={reference.src}
objectKey={reference.objectKey}
label={label}
ariaLabel={ariaLabel}
title={reference.label}
onRemove={() =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
generationReferences: (
currentDialog.generationReferences ?? []
).filter((item) => item.id !== reference.id),
}
: currentDialog,
)
}
removeLabel={removeLabel}
className={referenceSlotClassName}
/>
);
})}
{showReferenceAddButton ? (
<ImageCanvasReferenceSlot
buttonRef={generationReferenceButtonRef}
tone={referenceSlotTone}
icon={referenceButtonIcon}
label={resolvedReferenceButtonLabel}
ariaLabel={resolvedReferenceButtonAriaLabel}
disabled={
dialog.status === 'generating' || referenceSlotDisabled
}
: currentDialog,
)
}
/>
<div className="image-canvas-editor__generation-composer-footer">
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
setGenerateDialog={setGenerateDialog}
includeDimensions
onRememberImageModel={onRememberImageModel}
optionLabelPrefix={isQuickEdit ? '改造图片' : '生成图片'}
cost={calculateEditorImageGenerationPrice({
model: dialog.imageModel,
imageSize: dialog.imageSize,
})}
submitLabel="生成"
submitAriaLabel="生成"
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
isAdd
onClick={() => onToggleReferenceMenu?.()}
/>
) : null}
</div>
) : null}
<PlatformTextField
variant="textarea"
aria-label={resolvedPromptLabel}
value={dialog.prompt}
disabled={dialog.status === 'generating'}
placeholder={resolvedPromptPlaceholder}
size="sm"
density="compact"
className={promptClassName}
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...resetFailedDialogStatus(currentDialog),
prompt: event.target.value,
}
: currentDialog,
)
}
/>
</div>
{dialog.status === 'generating' ? (
<PlatformStatusMessage
tone="info"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="status"
>
生成中
</PlatformStatusMessage>
) : null}
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{dialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className={finalFooterClassName}>
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
setGenerateDialog={setGenerateDialog}
includeDimensions={includeDimensions}
includeModel={includeModel}
onRememberImageModel={onRememberImageModel}
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix}
optionLabelPrefix={resolvedOptionLabelPrefix}
cost={calculateEditorImageGenerationPrice({
model: dialog.imageModel,
imageSize: dialog.imageSize,
})}
submitLabel={submitLabel}
submitAriaLabel={submitAriaLabel}
submitButtonClassName={submitButtonClassName}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
</div>
{dialog.status === 'generating' ? (
<PlatformStatusMessage
tone="info"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="status"
>
{submittingStatusLabel}
</PlatformStatusMessage>
) : null}
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{dialog.errorMessage}
</PlatformStatusMessage>
) : null}
</form>
{isGenerationReferenceMenuOpen && generationReferenceButtonRef
{hasReferenceMenu &&
isGenerationReferenceMenuOpen &&
generationReferenceButtonRef
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
label="参考图来源"
label={referenceMenuLabel}
placement="top-start"
style={buildPortalMenuStyle(
generationReferenceButtonRef.current,
@@ -218,7 +315,11 @@ export function ImageCanvasBasicGenerationComposerView({
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen?.(false);
setIsPickingGenerationReferenceFromCanvas?.(true);
if (onPickReferenceFromCanvas) {
onPickReferenceFromCanvas();
} else {
setIsPickingGenerationReferenceFromCanvas?.(true);
}
}}
>
从画布中选择
@@ -227,7 +328,7 @@ export function ImageCanvasBasicGenerationComposerView({
onClick={() => {
setIsGenerationReferenceMenuOpen?.(false);
setIsPickingGenerationReferenceFromCanvas?.(false);
onRequestUpload('generation-reference');
onRequestUpload(referenceUploadTarget);
}}
>
上传图片
@@ -1,18 +1,21 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { createRef } from 'react';
import { createRef, type RefObject } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
describe('ImageCanvasBottomToolbarView', () => {
it('renders the canvas tools and forwards tool changes', () => {
function renderToolbar(
overrides: Partial<Parameters<typeof ImageCanvasBottomToolbarView>[0]> = {},
) {
const switchTool = vi.fn();
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
render(
const view = render(
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
@@ -21,71 +24,88 @@ describe('ImageCanvasBottomToolbarView', () => {
onSwitchTool={switchTool}
onOpenToolOptions={vi.fn()}
onCloseToolOptions={vi.fn()}
{...overrides}
/>,
);
const toolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
return {
...view,
toolbar: screen.getByRole('toolbar', { name: 'AI画布工具栏' }),
switchTool,
refs: {
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
},
};
}
expect(
within(toolbar)
.getByRole('button', { name: '生成图片' })
.getAttribute('aria-pressed'),
).toBe('false');
expect(
within(toolbar)
.getByRole('button', { name: '选择工具' })
.getAttribute('aria-pressed'),
).toBe('false');
fireEvent.click(within(toolbar).getByRole('button', { name: '抓手工具' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '生成视频' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '生成规范' }));
fireEvent.click(
within(toolbar).getByRole('button', { name: '生成UI设计图' }),
);
fireEvent.click(within(toolbar).getByRole('button', { name: '生成音乐' }));
fireEvent.click(
within(toolbar).getByRole('button', { name: '生成图标素材' }),
);
fireEvent.click(within(toolbar).getByRole('button', { name: '宣发素材' }));
expect(switchTool).toHaveBeenNthCalledWith(1, 'hand');
expect(switchTool).toHaveBeenNthCalledWith(2, 'video');
expect(switchTool).toHaveBeenNthCalledWith(3, 'spec');
expect(switchTool).toHaveBeenNthCalledWith(4, 'ui-design');
expect(switchTool).toHaveBeenNthCalledWith(5, 'music');
expect(switchTool).toHaveBeenNthCalledWith(6, 'icon');
expect(switchTool).toHaveBeenNthCalledWith(7, 'publication');
});
it('only keeps the select and hand tools visibly pressed', () => {
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
const renderToolbar = (effectiveTool: Parameters<
function renderToolbarView(
effectiveTool: Parameters<
typeof ImageCanvasBottomToolbarView
>[0]['effectiveTool']) => (
>[0]['effectiveTool'],
refs: {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
},
) {
return (
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
specToolWrapRef={refs.specToolWrapRef}
musicToolWrapRef={refs.musicToolWrapRef}
publicationToolWrapRef={refs.publicationToolWrapRef}
effectiveTool={effectiveTool}
onSwitchTool={vi.fn()}
onOpenToolOptions={vi.fn()}
onCloseToolOptions={vi.fn()}
/>
);
}
const { rerender } = render(renderToolbar('select'));
const toolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
it('renders the expected canvas tools and forwards user selections', async () => {
const user = userEvent.setup();
const { toolbar, switchTool } = renderToolbar();
const toolExpectations = [
['选择工具', 'select'],
['抓手工具', 'hand'],
['上传工具', 'upload'],
['生成图片', 'generate'],
['生成视频', 'video'],
['生成音乐', 'music'],
['生成规范', 'spec'],
['生成角色形象', 'character'],
['生成图标素材', 'icon'],
['生成UI设计图', 'ui-design'],
['宣发素材', 'publication'],
] as const;
for (const [label, tool] of toolExpectations) {
await user.click(within(toolbar).getByRole('button', { name: label }));
expect(switchTool).toHaveBeenLastCalledWith(tool);
}
expect(switchTool).toHaveBeenCalledTimes(toolExpectations.length);
});
it('only exposes persistent pressed state for navigation tools', () => {
const { toolbar, rerender, refs } = renderToolbar({
effectiveTool: 'select',
});
expect(
within(toolbar)
.getByRole('button', { name: '选择工具' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
within(toolbar)
.getByRole('button', { name: '生成图片' })
.getAttribute('aria-pressed'),
).toBe('false');
rerender(renderToolbar('hand'));
rerender(renderToolbarView('hand', refs));
expect(
within(toolbar)
@@ -93,119 +113,44 @@ describe('ImageCanvasBottomToolbarView', () => {
.getAttribute('aria-pressed'),
).toBe('true');
for (const tool of [
['upload', '上传工具'],
['generate', '生成图片'],
['video', '生成视频'],
['music', '生成音乐'],
['spec', '生成规范'],
['character', '生成角色形象'],
['icon', '生成图标素材'],
['ui-design', '生成UI设计图'],
['publication', '宣发素材'],
] as const) {
rerender(renderToolbar(tool[0]));
expect(
within(toolbar)
.getByRole('button', { name: tool[1] })
.getAttribute('aria-pressed'),
).toBe('false');
}
});
it('把宣发素材入口放在生成UI设计图右侧', () => {
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
render(
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool="select"
onSwitchTool={vi.fn()}
onOpenToolOptions={vi.fn()}
onCloseToolOptions={vi.fn()}
/>,
);
const toolNames = within(
screen.getByRole('toolbar', { name: 'AI画布工具栏' }),
)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label'));
expect(toolNames).toEqual([
'选择工具',
'抓手工具',
'上传工具',
'生成图片',
'生成视频',
'生成音乐',
'生成规范',
'生成角色形象',
'生成图标素材',
'生成UI设计图',
'宣发素材',
]);
});
it('uses an upload-specific icon for the upload tool', () => {
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
render(
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool="select"
onSwitchTool={vi.fn()}
onOpenToolOptions={vi.fn()}
onCloseToolOptions={vi.fn()}
/>,
);
rerender(renderToolbarView('music', refs));
expect(
within(
screen.getByRole('toolbar', { name: 'AI画布工具栏' }),
)
.getByRole('button', { name: '上传工具' })
.querySelector('.lucide-upload'),
).toBeTruthy();
within(toolbar)
.getByRole('button', { name: '生成音乐' })
.getAttribute('aria-pressed'),
).toBe('false');
});
it('opens and closes bottom option tools on hover', () => {
it('opens and closes bottom option tools on pointer and keyboard focus', async () => {
const user = userEvent.setup();
const openToolOptions = vi.fn();
const closeToolOptions = vi.fn();
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
render(
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool="select"
onSwitchTool={vi.fn()}
onOpenToolOptions={openToolOptions}
onCloseToolOptions={closeToolOptions}
/>,
const { toolbar, refs } = renderToolbar({
onOpenToolOptions: openToolOptions,
onCloseToolOptions: closeToolOptions,
});
await user.hover(within(toolbar).getByRole('button', { name: '生成规范' }));
await user.unhover(
within(toolbar).getByRole('button', { name: '生成规范' }),
);
await user.hover(within(toolbar).getByRole('button', { name: '生成音乐' }));
await user.unhover(
within(toolbar).getByRole('button', { name: '生成音乐' }),
);
fireEvent.pointerEnter(specToolWrapRef.current!);
fireEvent.pointerLeave(specToolWrapRef.current!);
fireEvent.pointerEnter(musicToolWrapRef.current!);
fireEvent.pointerLeave(musicToolWrapRef.current!);
fireEvent.pointerEnter(publicationToolWrapRef.current!);
fireEvent.pointerLeave(publicationToolWrapRef.current!);
const publicationTool = within(toolbar).getByRole('button', {
name: '宣发素材',
});
publicationTool.focus();
publicationTool.blur();
expect(openToolOptions).toHaveBeenNthCalledWith(1, 'spec');
expect(closeToolOptions).toHaveBeenNthCalledWith(1, 'spec');
expect(openToolOptions).toHaveBeenNthCalledWith(2, 'music');
expect(closeToolOptions).toHaveBeenNthCalledWith(2, 'music');
expect(openToolOptions).toHaveBeenNthCalledWith(3, 'publication');
expect(closeToolOptions).toHaveBeenNthCalledWith(3, 'publication');
expect(openToolOptions).toHaveBeenCalledWith('spec');
expect(closeToolOptions).toHaveBeenCalledWith('spec');
expect(openToolOptions).toHaveBeenCalledWith('music');
expect(closeToolOptions).toHaveBeenCalledWith('music');
expect(openToolOptions).toHaveBeenCalledWith('publication');
expect(closeToolOptions).toHaveBeenCalledWith('publication');
});
});
@@ -3,8 +3,8 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
@@ -33,6 +33,7 @@ function renderContextMenus(
canvasClipboard: null,
imageContextMenu: null,
imageContextMenuLayer: null,
contextMenuLayer: null,
contextShouldShowLayer: false,
contextShouldUnlockLayer: false,
onPasteCanvasClipboard: vi.fn(),
@@ -81,8 +82,27 @@ describe('ImageCanvasContextMenusView', () => {
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(2);
});
it('closes canvas context menus when another operation starts outside', () => {
const props = renderContextMenus({
contextMenu: {
kind: 'blank',
x: 10,
y: 12,
canvasPoint: { x: 18, y: 24 },
},
});
fireEvent.pointerDown(document.body);
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
});
it('renders layer context commands and forwards layer operations', () => {
const layer = createLayer({ assetKind: 'character' });
const layer = createLayer({
assetKind: 'character',
mediaType: 'image-sequence',
});
const props = renderContextMenus({
contextMenu: {
kind: 'layer',
@@ -93,6 +113,7 @@ describe('ImageCanvasContextMenusView', () => {
},
canvasClipboard: { layers: [layer], mode: 'copy' },
imageContextMenuLayer: layer,
contextMenuLayer: layer,
contextShouldShowLayer: true,
contextShouldUnlockLayer: true,
});
@@ -104,6 +125,18 @@ describe('ImageCanvasContextMenusView', () => {
fireEvent.click(screen.getByRole('menuitem', { name: '显示' }));
fireEvent.click(screen.getByRole('menuitem', { name: '解锁' }));
fireEvent.click(screen.getByRole('menuitem', { name: '水平翻转' }));
expect(
screen
.getByRole('menuitem', { name: '导出为' })
.getAttribute('aria-haspopup'),
).toBe('menu');
fireEvent.click(
screen.getByRole('menuitem', { name: '序列帧导出(zip)' }),
);
fireEvent.click(
screen.getByRole('menuitem', { name: 'Spine 导出(zip)' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '快速编辑' }));
fireEvent.click(screen.getByRole('menuitem', { name: '生成动画' }));
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
@@ -114,9 +147,18 @@ describe('ImageCanvasContextMenusView', () => {
expect(props.onToggleContextLayerVisibility).toHaveBeenCalledTimes(1);
expect(props.onToggleContextLayerLock).toHaveBeenCalledTimes(1);
expect(props.onFlipContextLayers).toHaveBeenCalledWith('x');
expect(props.onExportContextLayer).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ mode: 'sequence-with-preview' }),
);
expect(props.onExportContextLayer).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ mode: 'spine-json' }),
);
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer);
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(1);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
expect(props.onCloseContextMenu).toHaveBeenCalledTimes(2);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
});
@@ -146,7 +188,7 @@ describe('ImageCanvasContextMenusView', () => {
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(layer);
expect(props.onOpenLayerMetadata).toHaveBeenCalledWith(layer);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(1);
expect(props.onCloseImageContextMenu).toHaveBeenCalledTimes(2);
expect(props.onDeleteLayerById).toHaveBeenCalledWith(layer.id);
});
});
@@ -1,4 +1,9 @@
import { PlatformFloatingMenu, PlatformFloatingMenuItem } from '../common/PlatformFloatingMenu';
import { useCallback, useEffect, useRef } from 'react';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import type {
CanvasClipboard,
CanvasContextMenuState,
@@ -13,6 +18,7 @@ type ImageCanvasContextMenusViewProps = {
canvasClipboard: CanvasClipboard | null;
imageContextMenu: ImageContextMenuState | null;
imageContextMenuLayer: CanvasLayer | null;
contextMenuLayer: CanvasLayer | null;
contextShouldShowLayer: boolean;
contextShouldUnlockLayer: boolean;
onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void;
@@ -24,7 +30,9 @@ type ImageCanvasContextMenusViewProps = {
onToggleContextLayerVisibility: () => void;
onToggleContextLayerLock: () => void;
onFlipContextLayers: (axis: 'x' | 'y') => void;
onExportContextLayer: () => void;
onExportContextLayer: (options?: {
mode?: 'spine-json' | 'sequence-with-preview';
}) => void;
onDeleteContextLayers: () => void;
onDeleteLayerById: (layerId: string | null) => void;
onCloseContextMenu: () => void;
@@ -42,6 +50,7 @@ export function ImageCanvasContextMenusView({
canvasClipboard,
imageContextMenu,
imageContextMenuLayer,
contextMenuLayer,
contextShouldShowLayer,
contextShouldUnlockLayer,
onPasteCanvasClipboard,
@@ -64,10 +73,45 @@ export function ImageCanvasContextMenusView({
onOpenLayerMetadata,
onOpenCharacterAnimationPanel,
}: ImageCanvasContextMenusViewProps) {
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
const menuRef = useRef<HTMLDivElement | null>(null);
const closeMenus = useCallback(() => {
onCloseContextMenu();
onCloseImageContextMenu();
}, [onCloseContextMenu, onCloseImageContextMenu]);
useEffect(() => {
if (!contextMenu && !imageContextMenu) {
return undefined;
}
const handlePointerDown = (event: PointerEvent) => {
if (menuRef.current?.contains(event.target as Node)) {
return;
}
closeMenus();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeMenus();
}
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('scroll', closeMenus, true);
window.addEventListener('resize', closeMenus);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('scroll', closeMenus, true);
window.removeEventListener('resize', closeMenus);
};
}, [closeMenus, contextMenu, imageContextMenu]);
return (
<>
{contextMenu ? (
<div
ref={menuRef}
className="image-canvas-editor__context-menu"
role="menu"
aria-label={
@@ -85,7 +129,6 @@ export function ImageCanvasContextMenusView({
<button
type="button"
role="menuitem"
disabled={!canvasClipboard?.layers.length}
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
>
粘贴
@@ -140,7 +183,6 @@ export function ImageCanvasContextMenusView({
<button
type="button"
role="menuitem"
disabled={!canvasClipboard?.layers.length}
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
>
粘贴
@@ -182,7 +224,11 @@ export function ImageCanvasContextMenusView({
移动至底层
</button>
<hr />
<button type="button" role="menuitem" onClick={onGroupContextLayers}>
<button
type="button"
role="menuitem"
onClick={onGroupContextLayers}
>
创建组
</button>
<button
@@ -221,16 +267,63 @@ export function ImageCanvasContextMenusView({
>
垂直翻转
</button>
<button type="button" role="menuitem" onClick={onExportContextLayer}>
导出为
</button>
{isImageSequenceLayer ? (
<div className="image-canvas-editor__context-submenu">
<button
type="button"
role="menuitem"
aria-haspopup="menu"
className="image-canvas-editor__context-submenu-trigger"
>
<span>导出为</span>
<span aria-hidden="true">›</span>
</button>
<PlatformFloatingMenu
className="image-canvas-editor__context-submenu-panel"
label="动作导出选项"
placement="bottom-start"
style={{ margin: 0 }}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() =>
onExportContextLayer({
mode: 'sequence-with-preview',
})
}
>
序列帧导出(zip)
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() =>
onExportContextLayer({ mode: 'spine-json' })
}
>
Spine 导出(zip)
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>
</div>
) : (
<button
type="button"
role="menuitem"
onClick={() => onExportContextLayer()}
>
导出为
</button>
)}
<hr />
{imageContextMenuLayer ? (
<>
<button
type="button"
role="menuitem"
onClick={() => onOpenQuickEditPanel(imageContextMenuLayer)}
onClick={() => {
onOpenQuickEditPanel(imageContextMenuLayer);
onCloseContextMenu();
onCloseImageContextMenu();
}}
>
快速编辑
</button>
@@ -276,6 +369,7 @@ export function ImageCanvasContextMenusView({
{imageContextMenu && imageContextMenuLayer && !contextMenu ? (
<div
ref={menuRef}
className="image-canvas-editor__context-menu"
style={{
left: imageContextMenu.x,
@@ -286,7 +380,10 @@ export function ImageCanvasContextMenusView({
<PlatformFloatingMenu label="图片功能面板" placement="bottom-start">
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => onOpenQuickEditPanel(imageContextMenuLayer)}
onClick={() => {
onOpenQuickEditPanel(imageContextMenuLayer);
onCloseImageContextMenu();
}}
>
快速编辑
</PlatformFloatingMenuItem>
@@ -15,6 +15,8 @@ function createDialog(
prompt: '旧修改提示',
status: 'idle',
sourceLayerId: 'layer-a',
imageModel: 'gpt-image-2',
imageSize: '1K',
...patch,
};
}
@@ -1,13 +1,10 @@
import { type Dispatch, type SetStateAction } from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal';
import {
calculateEditorImageGenerationPrice,
IMAGE_MODEL_GPT_IMAGE_2,
} from './ImageCanvasGenerationModel';
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
type ImageCanvasEditGenerationModalViewProps = {
@@ -22,10 +19,12 @@ export function ImageCanvasEditGenerationModalView({
onSubmit,
}: ImageCanvasEditGenerationModalViewProps) {
const isOpen = dialog?.mode === 'edit' && dialog.status !== 'generating';
const dialogImageModel = dialog?.imageModel ?? 'gpt-image-2';
const dialogImageSize = dialog?.imageSize ?? '1K';
const editPrice = calculateEditorImageGenerationPrice({
kind: 'quick-edit',
model: IMAGE_MODEL_GPT_IMAGE_2,
imageSize: '1K',
model: dialogImageModel,
imageSize: dialogImageSize,
});
return (
@@ -40,70 +39,32 @@ export function ImageCanvasEditGenerationModalView({
bodyClassName="image-canvas-editor__generate-dialog-body"
>
{dialog?.mode === 'edit' ? (
<form
className="image-canvas-editor__generate-form"
onSubmit={(event) => {
event.preventDefault();
if (dialog.status !== 'generating') {
onSubmit(dialog);
}
<ImageCanvasBasicGenerationComposerView
dialog={{
...dialog,
mode: 'edit',
imageModel: dialogImageModel,
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialogImageSize,
generationReferences: [],
}}
>
<div className="image-canvas-editor__generate-body">
<PlatformTextField
variant="textarea"
aria-label="生成提示词"
value={dialog.prompt}
disabled={dialog.status === 'generating'}
size="sm"
density="roomy"
className="image-canvas-editor__generate-prompt"
placeholder="描述你想如何修改这张图片"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
prompt: event.target.value,
}
: currentDialog,
)
}
/>
{dialog.status === 'generating' ? (
<PlatformStatusMessage
tone="info"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="status"
>
修改中
</PlatformStatusMessage>
) : null}
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{dialog.errorMessage}
</PlatformStatusMessage>
) : null}
<PlatformActionButton
type="submit"
size="sm"
className="image-canvas-editor__generate-submit"
disabled={dialog.status === 'generating'}
>
{dialog.status === 'generating'
? '修改中'
: `修改${editPrice}泥点`}
</PlatformActionButton>
</div>
</form>
style={{}}
setGenerateDialog={setGenerateDialog}
onRequestUpload={() => {}}
onSubmit={onSubmit}
dialogLabel="修改图片"
includeDimensions={false}
includeModel={false}
includeReferences={false}
promptPlaceholder="描述你想如何修改这张图片"
submitLabel="修改"
submitAriaLabel={`修改${editPrice}泥点`}
submittingStatusLabel="修改中"
formClassName="image-canvas-editor__generate-form"
footerClassName="image-canvas-editor__generate-body"
promptClassName="image-canvas-editor__generate-prompt"
submitButtonClassName="image-canvas-editor__generate-submit"
/>
) : null}
</UnifiedModal>
);
@@ -296,6 +296,47 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(screen.getByAltText('画布图片:主视觉素材')).toBeTruthy();
});
it('downloads an asset directly from the asset library', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async (url: string) => {
if (url === '/creation-type-references/puzzle.webp') {
return new Response(new Blob(['asset'], { type: 'image/webp' }));
}
return new Response(null, { status: 404 });
}) as typeof fetch;
let downloadName = '';
let downloadHref = '';
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:asset-library-download'),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
downloadHref = this.href;
},
);
try {
render(<ImageCanvasEditorView />);
fireEvent.click(
await screen.findByRole('button', { name: '下载素材拼图素材' }),
);
await waitFor(() => {
expect(downloadName).toBe('拼图素材.webp');
});
expect(downloadHref).toBe('blob:asset-library-download');
} finally {
globalThis.fetch = originalFetch;
}
});
it('collapses folders, creates upload folders, and deletes uploaded materials', async () => {
const user = userEvent.setup();
const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image');
@@ -1095,6 +1136,141 @@ describe('ImageCanvasEditorView asset library integration', () => {
).toBe(true);
});
it('pastes a clipboard image onto the canvas through the upload workflow', async () => {
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
const imageFile = new File(['image'], '剪贴板素材.png', {
type: 'image/png',
});
const pasteEvent = new Event('paste', {
bubbles: true,
cancelable: true,
});
Object.defineProperty(pasteEvent, 'clipboardData', {
value: {
files: [imageFile],
items: [],
},
});
window.dispatchEvent(pasteEvent);
await waitFor(() => {
expect(screen.getByAltText('画布图片:剪贴板素材.png')).toBeTruthy();
});
expect(pasteEvent.defaultPrevented).toBe(true);
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
imageFile,
'image',
);
expect(createEditorAssetMock).toHaveBeenCalledWith(
expect.objectContaining({
label: '剪贴板素材.png',
imageSrc:
'/generated-character-drafts/editor/asset-library/image/剪贴板素材.png',
objectKey:
'generated-character-drafts/editor/asset-library/image/剪贴板素材.png',
assetObjectId: 'assetobj-editor-image',
}),
);
expect(
screen
.getByRole('button', { name: '选择剪贴板素材.png' })
.className.includes('image-canvas-editor__layer--selected'),
).toBe(true);
});
it('pastes a system clipboard image from the canvas context menu', async () => {
const originalClipboard = navigator.clipboard;
const clipboardBlob = new Blob(['image'], { type: 'image/png' });
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
read: vi.fn(async () => [
{
types: ['image/png'],
getType: vi.fn(async () => clipboardBlob),
},
]),
},
});
try {
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
clientX: 360,
clientY: 240,
});
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
await waitFor(() => {
expect(screen.getByAltText('画布图片:剪贴板图片.png')).toBeTruthy();
});
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
expect.objectContaining({
name: '剪贴板图片.png',
type: 'image/png',
}),
'image',
);
} finally {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
});
}
});
it('keeps canvas clipboard paste ahead of system clipboard images', async () => {
render(<ImageCanvasEditorView />);
await waitFor(() => {
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
});
fireEvent.contextMenu(
screen.getByAltText('画布图片:拼图素材').closest('button')!,
{
clientX: 510,
clientY: 330,
},
);
fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));
const systemImageFile = new File(['image'], '系统剪贴板素材.png', {
type: 'image/png',
});
const pasteEvent = new Event('paste', {
bubbles: true,
cancelable: true,
});
Object.defineProperty(pasteEvent, 'clipboardData', {
value: {
files: [systemImageFile],
items: [],
},
});
act(() => {
window.dispatchEvent(pasteEvent);
});
expect(pasteEvent.defaultPrevented).toBe(true);
await waitFor(() => {
expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2);
});
expect(screen.queryByAltText('画布图片:系统剪贴板素材.png')).toBeNull();
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalledWith(
systemImageFile,
'image',
);
});
it('drops files into the asset panel only once without creating canvas layers', async () => {
render(<ImageCanvasEditorView />);
File diff suppressed because it is too large Load Diff
@@ -102,6 +102,8 @@ describe('ImageCanvasEditorModel', () => {
folderId: 'project',
label: '开场动画.mp4',
imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
thumbnailSrc:
'/generated-character-drafts/editor/asset-library/video/开场动画-cover.png',
width: 560,
height: 315,
sourceType: 'uploaded',
@@ -112,7 +114,11 @@ describe('ImageCanvasEditorModel', () => {
});
expect(library.assets[0]).toMatchObject({ mediaType: 'audio' });
expect(library.assets[1]).toMatchObject({ mediaType: 'video' });
expect(library.assets[1]).toMatchObject({
mediaType: 'video',
thumbnailSrc:
'/generated-character-drafts/editor/asset-library/video/开场动画-cover.png',
});
expect(
createLayerFromAsset(
library.assets[0]!,
@@ -139,6 +145,7 @@ describe('ImageCanvasEditorModel', () => {
persisted: true,
objectKey: 'oss/asset-1.png',
assetObjectId: 'object-1',
thumbnailSrc: '/generated-character-drafts/editor/asset-1-cover.png',
sourceResourceId: 'resource-source-1',
};
@@ -158,6 +165,7 @@ describe('ImageCanvasEditorModel', () => {
originalHeight: 480,
objectKey: 'oss/asset-1.png',
assetObjectId: 'object-1',
thumbnailSrc: '/generated-character-drafts/editor/asset-1-cover.png',
sourceAssetId: 'asset-1',
sourceResourceId: 'resource-source-1',
});
@@ -561,6 +569,8 @@ describe('ImageCanvasEditorModel', () => {
composerOpen: false,
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
generationStartedAt: 1_771_400_000_000,
generationFinishedAt: 1_771_400_004_000,
placeholder: {
x: 100,
y: 120,
@@ -616,6 +626,8 @@ describe('ImageCanvasEditorModel', () => {
status: 'generating',
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
generationStartedAt: 1_771_400_000_000,
generationFinishedAt: 1_771_400_004_000,
placeholder: {
x: 100,
y: 120,
@@ -172,6 +172,7 @@ export function createLayerFromAsset(
originalHeight: asset.height,
zIndex: index + 10,
sourceType: asset.sourceType,
thumbnailSrc: asset.thumbnailSrc,
prompt: asset.prompt,
actualPrompt: asset.actualPrompt,
model: asset.model,
@@ -591,6 +592,8 @@ export function hydrateCanvasGenerationDialog(
aspectRatio: stringOrUndefined(snapshot.aspectRatio),
imageSize: stringOrUndefined(snapshot.imageSize),
errorMessage: stringOrUndefined(snapshot.errorMessage),
generationStartedAt: numberOrUndefined(snapshot.generationStartedAt),
generationFinishedAt: numberOrUndefined(snapshot.generationFinishedAt),
placeholder: hydrateGenerationPlaceholder(snapshot.placeholder),
};
}
@@ -644,11 +647,12 @@ export function hydrateLayer(
? imageSequenceFrames
: undefined,
previewVideoPath: stringOrNull(snapshot.previewVideoPath),
prompt: stringOrNull(snapshot.prompt),
actualPrompt: stringOrNull(snapshot.actualPrompt),
model: stringOrNull(snapshot.model),
provider: stringOrNull(snapshot.provider),
taskId: stringOrNull(snapshot.taskId),
prompt: stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt),
actualPrompt:
stringOrNull(snapshot.actualPrompt) ?? stringOrNull(resource?.actualPrompt),
model: stringOrNull(snapshot.model) ?? stringOrNull(resource?.model),
provider: stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider),
taskId: stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId),
objectKey: stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey),
assetObjectId:
stringOrNull(snapshot.assetObjectId) ?? stringOrNull(resource?.assetObjectId),
@@ -691,6 +695,7 @@ export function mapAssetLibrarySnapshot(
const mediaType = inferEditorAssetMediaType(
asset.imageSrc,
asset.objectKey ?? undefined,
asset.assetKind,
);
return {
id: asset.assetId,
@@ -710,7 +715,8 @@ export function mapAssetLibrarySnapshot(
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
sourceResourceId: asset.sourceResourceId ?? undefined,
thumbnailSrc: asset.thumbnailSrc ?? undefined,
sourceResourceId: asset.sourceResourceId ?? null,
publicShowcaseEnabled: asset.publicShowcaseEnabled ?? null,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
@@ -723,7 +729,14 @@ export function mapAssetLibrarySnapshot(
export function inferEditorAssetMediaType(
imageSrc: string,
objectKey?: string | null,
assetKind?: string | null,
): CanvasMediaType {
if (assetKind === 'video') {
return 'video';
}
if (assetKind === 'sound-effect' || assetKind === 'background-music') {
return 'audio';
}
const source = `${objectKey ?? ''} ${imageSrc}`.toLowerCase();
const sourceWithoutQuery = source.split(/[?#]/u)[0] ?? source;
if (sourceWithoutQuery.includes('.mp4')) {
@@ -812,6 +825,12 @@ export function numberFromSnapshot(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function numberOrUndefined(value: unknown) {
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
function audioDurationOrNull(value: unknown) {
if (value && typeof value === 'object' && 'durationSeconds' in value) {
return audioDurationOrNull(
@@ -70,6 +70,7 @@ export type SidebarMediaItemProps = {
primaryClassName?: string;
actions?: ReactNode;
titleNode?: ReactNode;
previewNode?: ReactNode;
previewOverlay?: ReactNode;
footerNode?: ReactNode;
draggable?: boolean;
@@ -97,6 +98,7 @@ export function SidebarMediaItem({
primaryClassName,
actions,
titleNode,
previewNode,
previewOverlay,
footerNode,
draggable,
@@ -129,16 +131,18 @@ export function SidebarMediaItem({
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<PlatformMediaFrame
src={imageSrc}
objectKey={objectKey}
alt={imageAlt}
fallbackLabel={title}
aspect="square"
surface="none"
className={thumbnailClassName}
previewOverlay={previewOverlay}
/>
{previewNode ?? (
<PlatformMediaFrame
src={imageSrc}
objectKey={objectKey}
alt={imageAlt}
fallbackLabel={title}
aspect="square"
surface="none"
className={thumbnailClassName}
previewOverlay={previewOverlay}
/>
)}
</button>
<div className={metaClassName}>
{titleNode ?? <span>{title}</span>}
@@ -80,6 +80,7 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
setAssetPublicShowcaseEnabled: vi.fn(),
toggleAssetSelected: vi.fn(),
addAssetLayer: vi.fn(),
onDownloadAsset: vi.fn(),
toggleAllAssetsSelected: vi.fn(),
deleteSelectedAssets: vi.fn(),
closeAssetSelectionMode: vi.fn(),
@@ -101,6 +102,7 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
layers: [],
walletBalanceLabel: '0泥点',
isWalletBalanceLoading: false,
currentUser: null,
assetExportStatus: null,
isExportingAssets: false,
setProjectRenameValue: vi.fn(),
@@ -111,11 +113,13 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
exportCanvasAssets: vi.fn(),
onOpenShortcuts: vi.fn(),
onOpenWallet: vi.fn(),
onOpenAccount: vi.fn(),
};
}
function createStageProps(): ImageCanvasStageViewProps {
return {
projectId: 'project-1',
canvasViewportRef: createRef<HTMLDivElement>(),
specToolWrapRef: createRef<HTMLSpanElement>(),
musicToolWrapRef: createRef<HTMLSpanElement>(),
@@ -132,6 +136,8 @@ function createStageProps(): ImageCanvasStageViewProps {
hoveredLayerId: null,
canvasMarquee: null,
canvasGenerationDialogs: [],
taskListRefreshKey: 0,
isTaskSidebarOpen: false,
generateDialog: null,
cropExpandPanel: null,
cropExpandSourceLayer: null,
@@ -146,6 +152,7 @@ function createStageProps(): ImageCanvasStageViewProps {
canvasClipboard: null,
imageContextMenu: null,
imageContextMenuLayer: null,
contextMenuLayer: null,
contextShouldShowLayer: false,
contextShouldUnlockLayer: false,
canUndo: false,
@@ -172,6 +179,8 @@ function createStageProps(): ImageCanvasStageViewProps {
onUpdateLayerAssetKind: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onFocusExternalTask: vi.fn(),
onToggleTaskSidebar: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
onOpenQuickEditPanel: vi.fn(),
onOpenRedrawPanel: vi.fn(),
@@ -179,9 +188,12 @@ function createStageProps(): ImageCanvasStageViewProps {
onRemoveBackground: vi.fn(),
onExtractUiDesignAssets: vi.fn(),
onUiAssetExtractionToolChange: vi.fn(),
onUiAssetExtractionModelChange: vi.fn(),
onUiAssetExtractionPointerStart: vi.fn(),
onUiAssetExtractionPointerMove: vi.fn(),
onUiAssetExtractionPointerEnd: vi.fn(),
onRequestUiAssetExtractionReferenceUpload: vi.fn(),
onRemoveUiAssetExtractionReference: vi.fn(),
onSubmitUiAssetExtraction: vi.fn(),
onQuickEditSelectionToolChange: vi.fn(),
onQuickEditSelectionPointerStart: vi.fn(),
@@ -283,6 +295,37 @@ describe('ImageCanvasEditorShellView', () => {
expect((preview as HTMLElement).style.top).toBe('48px');
});
it('keeps the native context menu available inside metadata text', () => {
render(
<ImageCanvasEditorShellView
editorRootRef={createRef<HTMLElement>()}
uploadInputRef={createRef<HTMLInputElement>()}
onUploadInputChange={vi.fn()}
assetDragPreview={null}
sidebarProps={createSidebarProps()}
topbarProps={createTopbarProps()}
stageProps={createStageProps()}
metadataProps={createMetadataProps(createLayer())}
/>,
);
const editor = screen.getByRole('region', { name: '图片画布编辑器' });
const metadataText = screen.getByText('上传图片');
const metadataContextMenuEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
metadataText.dispatchEvent(metadataContextMenuEvent);
expect(metadataContextMenuEvent.defaultPrevented).toBe(false);
const editorContextMenuEvent = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
});
editor.dispatchEvent(editorContextMenuEvent);
expect(editorContextMenuEvent.defaultPrevented).toBe(true);
});
it('renders quick edit selection tools through the stage props', () => {
const layer = createLayer();
const handleQuickEditToolChange = vi.fn();
@@ -293,7 +336,9 @@ describe('ImageCanvasEditorShellView', () => {
quickEditSelectionState: {
sourceLayerId: layer.id,
tool: 'rect' as const,
model: 'gemini-3.1-flash-image-preview',
marks: [],
references: [],
draftMark: null,
status: 'idle' as const,
},
@@ -316,9 +361,7 @@ describe('ImageCanvasEditorShellView', () => {
const toolbar = screen.getByRole('toolbar', {
name: '快速编辑框选工具',
});
fireEvent.click(
within(toolbar).getByRole('button', { name: '椭圆框选' }),
);
fireEvent.click(within(toolbar).getByRole('button', { name: '椭圆框选' }));
expect(handleQuickEditToolChange).toHaveBeenCalledWith('ellipse');
});
@@ -48,7 +48,15 @@ export function ImageCanvasEditorShellView({
ref={editorRootRef}
className="image-canvas-editor"
aria-label="图片画布编辑器"
onContextMenu={(event) => event.preventDefault()}
onContextMenu={(event) => {
if (
event.target instanceof Element &&
event.target.closest('.image-canvas-editor__metadata-dialog')
) {
return;
}
event.preventDefault();
}}
>
<input
ref={uploadInputRef}
@@ -228,6 +228,8 @@ export type GenerateDialogState = {
aspectRatio?: string;
imageSize?: string;
errorMessage?: string;
generationStartedAt?: number;
generationFinishedAt?: number;
placeholder?: {
x: number;
y: number;
@@ -248,6 +250,8 @@ export type CanvasGenerationDialogState = GenerateDialogState & {
mode: CanvasGenerationDialogMode;
};
export type CanvasTaskStatus = 'pending' | 'running' | 'done' | 'failed';
export type ImageContextMenuState = {
layerId: string;
x: number;
@@ -342,6 +346,7 @@ export type CharacterAnimationPanelState = {
export type UploadTarget =
| 'asset'
| 'generation-reference'
| 'ui-asset-extraction-reference'
| 'quick-edit-reference'
| 'video-reference-image'
| 'video-reference-video'

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