合并编辑器素材库分支
合并 codex/editor-asset-library 到 AI 游戏创作 App 分支。 保留 AI 游戏创作客户端配置决策,并合入素材库分支新增文档。 排除素材库分支误带的 .env.local 本机密钥改动。
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -205,6 +205,42 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the account modal from the canvas topbar avatar entry', async () => {
|
||||
const openAccountModal = vi.fn();
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: 'U001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: 'data:image/png;base64,AVATAR',
|
||||
phoneNumberMasked: '138****0000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
openAccountModal,
|
||||
})}
|
||||
>
|
||||
<ImageCanvasEditorView />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
const accountButton = await screen.findByRole('button', {
|
||||
name: '账号 测试用户',
|
||||
});
|
||||
|
||||
expect(accountButton.querySelector('img')?.getAttribute('src')).toBe(
|
||||
'data:image/png;base64,AVATAR',
|
||||
);
|
||||
|
||||
fireEvent.click(accountButton);
|
||||
|
||||
expect(openAccountModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the same account wallet entry from the canvas topbar mud point button', async () => {
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
@@ -842,7 +878,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(contextMenuEvent.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the blank canvas context menu with paste disabled, zoom, and fit all', () => {
|
||||
it('shows the blank canvas context menu with paste, zoom, and fit all', () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const viewport = screen.getByLabelText('画布工作区');
|
||||
@@ -858,7 +894,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
name: '粘贴',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(within(menu).getByRole('menuitem', { name: '放大' })).toBeTruthy();
|
||||
expect(
|
||||
within(menu).getByRole('menuitem', { name: '显示画布所有元素' }),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user