合并编辑器素材库分支
合并 codex/editor-asset-library 到 AI 游戏创作 App 分支。 保留 AI 游戏创作客户端配置决策,并合入素材库分支新增文档。 排除素材库分支误带的 .env.local 本机密钥改动。
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user