Unify canvas generation placement and UI changes
Add unified placement and UI updates for canvas generation: docs require ImageCanvasGenerationPlacementModel to compute placeholder positions (avoid overlaps, center viewport on placement) and limit frontend video model entries. Refactor UI components and tests: expose div props on PlatformFloatingMenu, make PlatformInlineOptionButton forwardRef, replace inline placeholder actions with ImageCanvasGenerationImageOptionsView in basic generation composer, enhance character animation panel with parameter menu portal and option choices, add reference chip UI, and update many tests to match new interactions and labels. These changes align generation workflows, accessibility labels, and positioning behavior across editor panels.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react';
|
||||
import type { ButtonHTMLAttributes, CSSProperties, HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type PlatformFloatingMenuProps = {
|
||||
type PlatformFloatingMenuProps = HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
label?: string;
|
||||
@@ -26,9 +26,11 @@ export function PlatformFloatingMenu({
|
||||
label,
|
||||
placement = 'top-end',
|
||||
style,
|
||||
...divProps
|
||||
}: PlatformFloatingMenuProps) {
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className={[
|
||||
'platform-floating-menu',
|
||||
`platform-floating-menu--${placement}`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
|
||||
type PlatformInlineOptionButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
@@ -12,13 +12,13 @@ type PlatformInlineOptionButtonProps = Omit<
|
||||
* 平台内联选项按钮。
|
||||
* 统一承接工具面板里“当前选项 + 下拉箭头”这类轻量 pill 动作。
|
||||
*/
|
||||
export function PlatformInlineOptionButton({
|
||||
children,
|
||||
trailingIcon,
|
||||
className,
|
||||
type = 'button',
|
||||
...buttonProps
|
||||
}: PlatformInlineOptionButtonProps) {
|
||||
export const PlatformInlineOptionButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
PlatformInlineOptionButtonProps
|
||||
>(function PlatformInlineOptionButton(
|
||||
{ children, trailingIcon, className, type = 'button', ...buttonProps },
|
||||
ref,
|
||||
) {
|
||||
const actionClassName = [
|
||||
'platform-inline-option-button inline-flex items-center justify-center border-0 bg-transparent text-sm font-black text-slate-700 transition-colors hover:text-slate-950 disabled:cursor-not-allowed disabled:opacity-55',
|
||||
className,
|
||||
@@ -27,7 +27,7 @@ export function PlatformInlineOptionButton({
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<button {...buttonProps} type={type} className={actionClassName}>
|
||||
<button {...buttonProps} ref={ref} type={type} className={actionClassName}>
|
||||
<span className="platform-inline-option-button__label">{children}</span>
|
||||
{trailingIcon ? (
|
||||
<span className="platform-inline-option-button__trailing" aria-hidden="true">
|
||||
@@ -36,4 +36,4 @@ export function PlatformInlineOptionButton({
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { createRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
@@ -17,6 +17,10 @@ function createDialog(
|
||||
mode: 'generate',
|
||||
prompt: '初始提示',
|
||||
status: 'idle',
|
||||
generationReferences: [],
|
||||
imageModel: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -35,6 +39,9 @@ function BasicGenerationHarness({
|
||||
const [dialog, setDialog] = useState<GenerateDialogState | null>(
|
||||
initialDialog,
|
||||
);
|
||||
const [isReferenceMenuOpen, setIsReferenceMenuOpen] = useState(false);
|
||||
const [isPickingReference, setIsPickingReference] = useState(false);
|
||||
const generationReferenceButtonRef = createRef<HTMLButtonElement>();
|
||||
|
||||
return dialog ? (
|
||||
<div>
|
||||
@@ -42,13 +49,24 @@ function BasicGenerationHarness({
|
||||
dialog={dialog}
|
||||
style={{ left: 10, top: 20 }}
|
||||
setGenerateDialog={setDialog}
|
||||
generationReferenceButtonRef={generationReferenceButtonRef}
|
||||
isGenerationReferenceMenuOpen={isReferenceMenuOpen}
|
||||
setIsGenerationReferenceMenuOpen={setIsReferenceMenuOpen}
|
||||
setIsPickingGenerationReferenceFromCanvas={setIsPickingReference}
|
||||
renderEditorPortal={(node) => node}
|
||||
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onToggleReferenceMenu={() => setIsReferenceMenuOpen((open) => !open)}
|
||||
onSubmit={onSubmit}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<output aria-label="当前提示词">{dialog.prompt}</output>
|
||||
<output aria-label="当前比例">{dialog.aspectRatio}</output>
|
||||
<output aria-label="当前尺寸">{dialog.imageSize}</output>
|
||||
<output aria-label="当前模型">{dialog.imageModel}</output>
|
||||
<output aria-label="当前状态">{dialog.status}</output>
|
||||
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
||||
<output aria-label="选择参考图">{String(isPickingReference)}</output>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
@@ -71,31 +89,89 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||
target: { value: '新的提示' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加参考图' }));
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示');
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
expect(requestUpload).not.toHaveBeenCalled();
|
||||
expect(requestUpload).toHaveBeenCalledWith('generation-reference');
|
||||
expect(submitGeneration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prompt: '新的提示', status: 'idle' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs placeholder actions and closes through its interface', () => {
|
||||
it('keeps references on the first row and appends a new add-reference entry', () => {
|
||||
render(
|
||||
<BasicGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
generationReferences: [
|
||||
{
|
||||
id: 'ref-a',
|
||||
label: '参考图A',
|
||||
src: 'data:image/png;base64,cmVmQQ==',
|
||||
},
|
||||
{
|
||||
id: 'ref-b',
|
||||
label: '参考图B',
|
||||
src: 'data:image/png;base64,cmVmQg==',
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
const strip = panel.querySelector('.image-canvas-editor__reference-strip');
|
||||
|
||||
expect(strip).toBeTruthy();
|
||||
expect(within(strip as HTMLElement).getByText('参考图A')).toBeTruthy();
|
||||
expect(within(strip as HTMLElement).getByText('参考图B')).toBeTruthy();
|
||||
expect(
|
||||
within(strip as HTMLElement).getByRole('button', { name: '添加参考图' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows only current image options and opens upward option panels', () => {
|
||||
render(<BasicGenerationHarness />);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图片' });
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: '生成图片尺寸 16:9 · 1K',
|
||||
}),
|
||||
);
|
||||
const dimensionPanel = screen.getByRole('menu', {
|
||||
name: '生成图片尺寸选项',
|
||||
});
|
||||
expect(within(dimensionPanel).getByText('比例')).toBeTruthy();
|
||||
fireEvent.click(within(dimensionPanel).getByRole('button', { name: '2K' }));
|
||||
|
||||
expect(screen.getByLabelText('当前尺寸').textContent).toBe('2K');
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: '生成图片模型 nanobanana2',
|
||||
}),
|
||||
);
|
||||
const modelPanel = screen.getByRole('menu', { name: '生成图片模型选项' });
|
||||
fireEvent.click(
|
||||
within(modelPanel).getByRole('button', { name: /gpt-image-2/ }),
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
||||
const submitButton = within(panel).getByRole('button', { name: '生成' });
|
||||
expect(submitButton.textContent).toBe('生成12泥点');
|
||||
});
|
||||
|
||||
it('closes through its interface', () => {
|
||||
const closeComposer = vi.fn();
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
render(<BasicGenerationHarness onClose={closeComposer} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成比例 1:1 2k 1张' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成模型 GPT Image' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('生成参数功能建设中');
|
||||
expect(alertSpy).toHaveBeenCalledWith('模型选择功能建设中');
|
||||
expect(closeComposer).toHaveBeenCalledTimes(1);
|
||||
|
||||
alertSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, ImageIcon, X } from 'lucide-react';
|
||||
import { ImageIcon, X } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type Dispatch,
|
||||
@@ -7,17 +7,19 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import {
|
||||
EDITOR_GENERATION_MUD_POINT_CONFIG,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
@@ -36,14 +38,10 @@ type ImageCanvasBasicGenerationComposerViewProps = {
|
||||
) => CSSProperties;
|
||||
onRequestUpload: (target: 'generation-reference') => void;
|
||||
onToggleReferenceMenu?: () => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function triggerPlaceholderAction(label: string) {
|
||||
window.alert(`${label}功能建设中`);
|
||||
}
|
||||
|
||||
function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
return {
|
||||
...dialog,
|
||||
@@ -52,6 +50,28 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
};
|
||||
}
|
||||
|
||||
function ReferenceChip({
|
||||
reference,
|
||||
index,
|
||||
}: {
|
||||
reference: CharacterReferenceImage;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="image-canvas-editor__reference-chip"
|
||||
title={reference.label}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
<img src={reference.src} alt="" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-chip-label">
|
||||
{reference.label || `参考图${index + 1}`}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasBasicGenerationComposerView({
|
||||
dialog,
|
||||
style,
|
||||
@@ -64,9 +84,11 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
buildPortalMenuStyle = () => ({}),
|
||||
onRequestUpload,
|
||||
onToggleReferenceMenu,
|
||||
onRememberImageModel = () => {},
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ImageCanvasBasicGenerationComposerViewProps) {
|
||||
const references = dialog.generationReferences ?? [];
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
@@ -82,23 +104,36 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlatformIconButton
|
||||
ref={generationReferenceButtonRef}
|
||||
variant="surfaceFloating"
|
||||
className="image-canvas-editor__generation-ref"
|
||||
label="添加参考图"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => onToggleReferenceMenu?.()}
|
||||
icon={<ImageIcon className="h-4 w-4" />}
|
||||
>
|
||||
<span>参考图</span>
|
||||
</PlatformIconButton>
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
{references.map((reference, index) => (
|
||||
<ReferenceChip
|
||||
key={reference.id}
|
||||
reference={reference}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
ref={generationReferenceButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__reference-chip image-canvas-editor__reference-chip--upload"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label="添加参考图"
|
||||
onClick={() => onToggleReferenceMenu?.()}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
<ImageIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-chip-label">
|
||||
参考图
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="生成提示词"
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="今天我们要创作什么"
|
||||
placeholder="今天想生成什么画面?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
@@ -114,35 +149,18 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
}
|
||||
/>
|
||||
<div className="image-canvas-editor__generation-composer-footer">
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__generation-ratio"
|
||||
aria-label="生成比例 1:1 2k 1张"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => triggerPlaceholderAction('生成参数')}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
>
|
||||
中 · 1:1(2k) · 1张
|
||||
</PlatformInlineOptionButton>
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__generation-model"
|
||||
aria-label="生成模型 GPT Image"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => triggerPlaceholderAction('模型选择')}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
>
|
||||
GPT Im...
|
||||
</PlatformInlineOptionButton>
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label="生成"
|
||||
>
|
||||
{dialog.status === 'generating' ? '生成中' : '12'}
|
||||
</PlatformActionButton>
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.image}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
/>
|
||||
</div>
|
||||
{dialog.status === 'generating' ? (
|
||||
<PlatformStatusMessage
|
||||
|
||||
@@ -4,9 +4,27 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CharacterAnimationPanelState } from './ImageCanvasEditorTypes';
|
||||
import type { CanvasLayer, CharacterAnimationPanelState } from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
|
||||
|
||||
|
||||
function createSourceLayer(): CanvasLayer {
|
||||
return {
|
||||
id: 'layer-a',
|
||||
resourceId: 'resource-a',
|
||||
title: '角色源图',
|
||||
src: 'data:image/png;base64,Y2hhcmFjdGVy',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 128,
|
||||
height: 128,
|
||||
originalWidth: 128,
|
||||
originalHeight: 128,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
};
|
||||
}
|
||||
|
||||
function createPanel(
|
||||
patch: Partial<CharacterAnimationPanelState> = {},
|
||||
): CharacterAnimationPanelState {
|
||||
@@ -64,6 +82,7 @@ function CharacterAnimationPanelHarness({
|
||||
<div>
|
||||
<ImageCanvasCharacterAnimationPanelView
|
||||
panel={panel}
|
||||
sourceLayer={createSourceLayer()}
|
||||
style={{ left: 12, top: 24 }}
|
||||
price={18}
|
||||
setCharacterAnimationPanel={setPanel}
|
||||
@@ -84,7 +103,7 @@ function CharacterAnimationPanelHarness({
|
||||
}
|
||||
|
||||
describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
it('updates prompt, resolution and ratio while clearing failed state', () => {
|
||||
it('keeps source reference first and updates prompt, resolution and ratio from a menu while clearing failed state', () => {
|
||||
render(
|
||||
<CharacterAnimationPanelHarness
|
||||
initialPanel={createPanel({
|
||||
@@ -97,16 +116,18 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
fireEvent.change(screen.getByLabelText('动画描述'), {
|
||||
target: { value: `${'a'.repeat(4001)}` },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('分辨率'), {
|
||||
target: { value: '720p' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('画面比例'), {
|
||||
target: { value: '16:9' },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
|
||||
);
|
||||
const menu = screen.getByRole('menu', { name: '动画参数选项' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '清晰度 720p' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '比例 16:9' }));
|
||||
|
||||
expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前动画描述').textContent).toHaveLength(4000);
|
||||
expect(screen.getByLabelText('当前分辨率').textContent).toBe('720p');
|
||||
expect(screen.getByLabelText('当前比例').textContent).toBe('16:9');
|
||||
expect(menu).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
});
|
||||
@@ -124,9 +145,10 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '行走' }));
|
||||
fireEvent.change(screen.getByLabelText('时长'), {
|
||||
target: { value: '48' },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '时长 48帧·6秒' }));
|
||||
|
||||
expect(screen.getByLabelText('当前动画描述').textContent).toBe(
|
||||
'循环行走动作,步伐稳定。',
|
||||
@@ -145,7 +167,7 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成18泥点' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭角色动画生成面板' }),
|
||||
);
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { type CSSProperties, type Dispatch, type SetStateAction } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformSelectField, PlatformTextField } from '../common/PlatformTextField';
|
||||
import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import {
|
||||
CHARACTER_ANIMATION_ACTION_PROMPTS,
|
||||
CHARACTER_ANIMATION_DURATION_OPTIONS,
|
||||
CHARACTER_ANIMATION_RATIO_OPTIONS,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import type { CharacterAnimationPanelState } from './ImageCanvasEditorTypes';
|
||||
import type {
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
CanvasLayer,
|
||||
CharacterAnimationPanelState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
type ImageCanvasCharacterAnimationPanelViewProps = {
|
||||
panel: CharacterAnimationPanelState;
|
||||
sourceLayer: CanvasLayer;
|
||||
style: CSSProperties;
|
||||
price: number;
|
||||
setCharacterAnimationPanel: Dispatch<
|
||||
@@ -33,144 +51,198 @@ function resetFailedPanelStatus<T extends { status: string; errorMessage?: strin
|
||||
};
|
||||
}
|
||||
|
||||
function buildLocalMenuStyle(anchor: HTMLElement | null): CSSProperties {
|
||||
const rect = anchor?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
return {
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
zIndex: 70,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
left: Math.round(rect.left),
|
||||
top: Math.round(rect.top),
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
zIndex: 70,
|
||||
transform: 'translateY(calc(-100% - 0.45rem))',
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanelPortal(node: ReactNode) {
|
||||
if (typeof document === 'undefined') {
|
||||
return node;
|
||||
}
|
||||
// 中文注释:参数菜单挂到页面级,避免被角色动画面板滚动边界裁切。
|
||||
return document.body ? createPortal(node, document.body) : node;
|
||||
}
|
||||
|
||||
function getRatioLabel(value: EditorCharacterAnimationRatio) {
|
||||
return value === 'same' ? '同图尺寸' : value;
|
||||
}
|
||||
|
||||
function AnimationOptionChoice({
|
||||
children,
|
||||
selected,
|
||||
className,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
selected: boolean;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={['image-canvas-editor__option-popover-choice', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={selected}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasCharacterAnimationPanelView({
|
||||
panel,
|
||||
sourceLayer,
|
||||
style,
|
||||
price,
|
||||
setCharacterAnimationPanel,
|
||||
onUpdateDuration,
|
||||
onSubmit,
|
||||
}: ImageCanvasCharacterAnimationPanelViewProps) {
|
||||
const [isParameterMenuOpen, setIsParameterMenuOpen] = useState(false);
|
||||
const parameterButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const isGenerating = panel.status === 'generating';
|
||||
const ratioLabel = getRatioLabel(panel.ratio);
|
||||
|
||||
const updatePanel = (patch: Partial<CharacterAnimationPanelState>) => {
|
||||
setCharacterAnimationPanel((currentPanel) =>
|
||||
currentPanel
|
||||
? {
|
||||
...resetFailedPanelStatus(currentPanel),
|
||||
...patch,
|
||||
}
|
||||
: currentPanel,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="image-canvas-editor__character-animation-panel"
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label="角色动画生成面板"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (panel.status !== 'generating') {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="image-canvas-editor__character-animation-head">
|
||||
<strong>角色动画</strong>
|
||||
<>
|
||||
<form
|
||||
className="image-canvas-editor__character-animation-panel"
|
||||
style={style}
|
||||
role="dialog"
|
||||
aria-label="角色动画生成面板"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!isGenerating) {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
<span
|
||||
className="image-canvas-editor__reference-chip image-canvas-editor__reference-chip--character"
|
||||
title={sourceLayer.title}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
<img src={sourceLayer.src} alt="" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-chip-label">
|
||||
{sourceLayer.title}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<EditorIconButton
|
||||
className="image-canvas-editor__generation-close"
|
||||
label="关闭角色动画生成面板"
|
||||
title="关闭"
|
||||
icon={X}
|
||||
variant="surfaceFloating"
|
||||
disabled={isGenerating}
|
||||
onClick={() => setCharacterAnimationPanel(null)}
|
||||
/>
|
||||
</div>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="动画描述"
|
||||
value={panel.promptText}
|
||||
maxLength={4000}
|
||||
disabled={panel.status === 'generating'}
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__character-animation-textarea"
|
||||
onChange={(event) =>
|
||||
setCharacterAnimationPanel((currentPanel) =>
|
||||
currentPanel
|
||||
? {
|
||||
...resetFailedPanelStatus(currentPanel),
|
||||
promptText: event.target.value.slice(0, 4000),
|
||||
}
|
||||
: currentPanel,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="image-canvas-editor__character-animation-presets">
|
||||
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
className="image-canvas-editor__character-animation-preset"
|
||||
disabled={panel.status === 'generating'}
|
||||
onClick={() =>
|
||||
setCharacterAnimationPanel((currentPanel) =>
|
||||
currentPanel
|
||||
? {
|
||||
...currentPanel,
|
||||
promptText: preset.text,
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
}
|
||||
: currentPanel,
|
||||
)
|
||||
}
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="动画描述"
|
||||
value={panel.promptText}
|
||||
maxLength={4000}
|
||||
disabled={isGenerating}
|
||||
placeholder="你希望角色做什么动作?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless image-canvas-editor__character-animation-textarea"
|
||||
onChange={(event) =>
|
||||
updatePanel({ promptText: event.target.value.slice(0, 4000) })
|
||||
}
|
||||
/>
|
||||
<div className="image-canvas-editor__character-animation-presets">
|
||||
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
className="image-canvas-editor__character-animation-preset"
|
||||
disabled={isGenerating}
|
||||
onClick={() =>
|
||||
setCharacterAnimationPanel((currentPanel) =>
|
||||
currentPanel
|
||||
? {
|
||||
...currentPanel,
|
||||
promptText: preset.text,
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
}
|
||||
: currentPanel,
|
||||
)
|
||||
}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="image-canvas-editor__character-animation-footer">
|
||||
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
|
||||
<PlatformInlineOptionButton
|
||||
ref={parameterButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
|
||||
aria-label={`动画参数 ${ratioLabel} · ${panel.durationSeconds}秒 · ${panel.resolution}`}
|
||||
aria-expanded={isParameterMenuOpen}
|
||||
disabled={isGenerating}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => setIsParameterMenuOpen((open) => !open)}
|
||||
>
|
||||
{ratioLabel} · {panel.durationSeconds}秒 · {panel.resolution}
|
||||
</PlatformInlineOptionButton>
|
||||
</div>
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__character-animation-submit"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="image-canvas-editor__character-animation-grid">
|
||||
<PlatformSelectField
|
||||
aria-label="分辨率"
|
||||
value={panel.resolution}
|
||||
disabled={panel.status === 'generating'}
|
||||
size="xs"
|
||||
density="compact"
|
||||
onChange={(event) =>
|
||||
setCharacterAnimationPanel((currentPanel) =>
|
||||
currentPanel
|
||||
? {
|
||||
...resetFailedPanelStatus(currentPanel),
|
||||
resolution: event.target.value === '720p' ? '720p' : '480p',
|
||||
}
|
||||
: currentPanel,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="480p">480p</option>
|
||||
<option value="720p">720p</option>
|
||||
</PlatformSelectField>
|
||||
<PlatformSelectField
|
||||
aria-label="画面比例"
|
||||
value={panel.ratio}
|
||||
disabled={panel.status === 'generating'}
|
||||
size="xs"
|
||||
density="compact"
|
||||
onChange={(event) =>
|
||||
setCharacterAnimationPanel((currentPanel) =>
|
||||
currentPanel
|
||||
? {
|
||||
...resetFailedPanelStatus(currentPanel),
|
||||
ratio:
|
||||
CHARACTER_ANIMATION_RATIO_OPTIONS.find(
|
||||
(item) => item.value === event.target.value,
|
||||
)?.value ?? 'same',
|
||||
}
|
||||
: currentPanel,
|
||||
)
|
||||
}
|
||||
>
|
||||
{CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</PlatformSelectField>
|
||||
<PlatformSelectField
|
||||
aria-label="时长"
|
||||
value={String(panel.frameCount)}
|
||||
disabled={panel.status === 'generating'}
|
||||
size="xs"
|
||||
density="compact"
|
||||
onChange={(event) => onUpdateDuration(event.target.value)}
|
||||
>
|
||||
{CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => (
|
||||
<option key={option.frameCount} value={String(option.frameCount)}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</PlatformSelectField>
|
||||
</div>
|
||||
<div className="image-canvas-editor__character-animation-summary">
|
||||
{isGenerating ? '生成中' : `生成${price}泥点`}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
<span
|
||||
className="image-canvas-editor__character-animation-summary-text"
|
||||
title={panel.promptText.trim() || undefined}
|
||||
@@ -178,37 +250,103 @@ export function ImageCanvasCharacterAnimationPanelView({
|
||||
>
|
||||
{panel.promptText.trim() ? panel.promptText.trim() : '动画描述'}
|
||||
</span>
|
||||
<strong>{price}泥点</strong>
|
||||
</div>
|
||||
{panel.status === 'completed' && panel.result ? (
|
||||
<PlatformStatusMessage
|
||||
tone="success"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
role="status"
|
||||
>
|
||||
已生成 {panel.result.frameCount} 帧
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{panel.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
role="alert"
|
||||
>
|
||||
{panel.errorMessage}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
className="image-canvas-editor__character-animation-submit"
|
||||
disabled={panel.status === 'generating'}
|
||||
>
|
||||
{panel.status === 'generating' ? '生成中' : '生成'}
|
||||
</PlatformActionButton>
|
||||
</form>
|
||||
{panel.status === 'completed' && panel.result ? (
|
||||
<PlatformStatusMessage
|
||||
tone="success"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
role="status"
|
||||
>
|
||||
已生成 {panel.result.frameCount} 帧
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{panel.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
size="xs"
|
||||
role="alert"
|
||||
>
|
||||
{panel.errorMessage}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
</form>
|
||||
{isParameterMenuOpen
|
||||
? renderPanelPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
|
||||
label="动画参数选项"
|
||||
placement="top-start"
|
||||
style={buildLocalMenuStyle(parameterButtonRef.current)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-sections">
|
||||
<section className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
比例
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--card">
|
||||
{CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => (
|
||||
<AnimationOptionChoice
|
||||
key={option.value}
|
||||
selected={panel.ratio === option.value}
|
||||
disabled={isGenerating}
|
||||
className="image-canvas-editor__option-popover-choice--ratio"
|
||||
ariaLabel={`比例 ${getRatioLabel(option.value)}`}
|
||||
onClick={() => updatePanel({ ratio: option.value })}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__ratio-wireframe"
|
||||
data-ratio={option.value}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{getRatioLabel(option.value)}</span>
|
||||
</AnimationOptionChoice>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
时长
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => (
|
||||
<AnimationOptionChoice
|
||||
key={option.frameCount}
|
||||
selected={panel.frameCount === option.frameCount}
|
||||
disabled={isGenerating}
|
||||
ariaLabel={`时长 ${option.label}`}
|
||||
onClick={() => onUpdateDuration(String(option.frameCount))}
|
||||
>
|
||||
{option.label}
|
||||
</AnimationOptionChoice>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
清晰度
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{(['480p', '720p'] as EditorCharacterAnimationResolution[]).map(
|
||||
(resolution) => (
|
||||
<AnimationOptionChoice
|
||||
key={resolution}
|
||||
selected={panel.resolution === resolution}
|
||||
disabled={isGenerating}
|
||||
ariaLabel={`清晰度 ${resolution}`}
|
||||
onClick={() => updatePanel({ resolution })}
|
||||
>
|
||||
{resolution}
|
||||
</AnimationOptionChoice>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,23 @@ function CharacterGenerationHarness({
|
||||
}
|
||||
|
||||
describe('ImageCanvasCharacterGenerationComposerView', () => {
|
||||
it('keeps the prompt as a borderless single text input with a question placeholder', () => {
|
||||
render(<CharacterGenerationHarness />);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成角色形象' });
|
||||
const prompt = screen.getByRole('textbox', { name: '角色设定' });
|
||||
|
||||
expect(
|
||||
Array.from(panel.querySelectorAll('.image-canvas-editor__field-title'))
|
||||
.map((node) => node.textContent)
|
||||
.join(''),
|
||||
).not.toContain('角色设定');
|
||||
expect(prompt.getAttribute('placeholder')).toBe('你希望角色如何设计?');
|
||||
expect(prompt.className).toContain(
|
||||
'image-canvas-editor__generation-prompt--borderless',
|
||||
);
|
||||
});
|
||||
|
||||
it('updates character prompt, clears failed state and submits', () => {
|
||||
const submitCharacter = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
@@ -16,10 +14,12 @@ import {
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
SpecGenerationType,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { EDITOR_GENERATION_MUD_POINT_CONFIG } from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
|
||||
type ImageCanvasCharacterGenerationComposerViewProps = {
|
||||
@@ -53,6 +53,28 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
};
|
||||
}
|
||||
|
||||
function ReferenceChip({
|
||||
reference,
|
||||
index,
|
||||
}: {
|
||||
reference: CharacterReferenceImage;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="image-canvas-editor__reference-chip image-canvas-editor__reference-chip--character"
|
||||
title={reference.label}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
<img src={reference.src} alt="" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-chip-label">
|
||||
{reference.label || `参考图${index + 1}`}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasCharacterGenerationComposerView({
|
||||
dialog,
|
||||
style,
|
||||
@@ -86,39 +108,32 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="image-canvas-editor__character-reference-row">
|
||||
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--spec">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
<span className="image-canvas-editor__character-spec-wrap">
|
||||
<button
|
||||
ref={characterSpecButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__reference-chip image-canvas-editor__reference-chip--spec image-canvas-editor__reference-chip--upload"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label={dialog.characterSpecReference?.label ?? '角色形象规范'}
|
||||
onClick={() => setIsCharacterSpecMenuOpen((open) => !open)}
|
||||
>
|
||||
角色形象规范
|
||||
</PlatformFieldLabel>
|
||||
<span className="image-canvas-editor__character-spec-wrap">
|
||||
<button
|
||||
ref={characterSpecButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__character-spec-ref image-canvas-editor__reference-tile image-canvas-editor__reference-tile--spec"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => setIsCharacterSpecMenuOpen((open) => !open)}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-tile-visual">
|
||||
{dialog.characterSpecReference ? (
|
||||
<img
|
||||
src={dialog.characterSpecReference.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ClipboardList className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-tile-copy">
|
||||
{dialog.characterSpecReference?.label ?? '角色形象规范'}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
{dialog.characterSpecReference ? (
|
||||
<img
|
||||
src={dialog.characterSpecReference.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ClipboardList className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-chip-label">
|
||||
{dialog.characterSpecReference?.label ?? '角色形象规范'}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
{isCharacterSpecMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
@@ -160,90 +175,71 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--regular">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
常规参考图
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__character-reference-list">
|
||||
{(dialog.characterReferences ?? []).map((reference, index) => (
|
||||
<span
|
||||
key={reference.id}
|
||||
className="image-canvas-editor__character-ref-thumb"
|
||||
title={reference.label}
|
||||
>
|
||||
<img src={reference.src} alt={reference.label} />
|
||||
<span className="image-canvas-editor__character-ref-index">
|
||||
{index + 1}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
ref={characterReferenceButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__character-reference-add image-canvas-editor__reference-tile image-canvas-editor__reference-tile--upload"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={() => setIsCharacterReferenceMenuOpen((open) => !open)}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-tile-visual">
|
||||
<ImagePlus className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-tile-copy">
|
||||
上传常规参考图
|
||||
</span>
|
||||
</button>
|
||||
{isCharacterReferenceMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
||||
label="常规参考图来源"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
characterReferenceButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsPickingCharacterReferenceFromCanvas(true);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
从画布中选择
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
onRequestUpload('character-reference');
|
||||
}}
|
||||
>
|
||||
上传图片
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="image-canvas-editor__field-block">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
{(dialog.characterReferences ?? []).map((reference, index) => (
|
||||
<ReferenceChip
|
||||
key={reference.id}
|
||||
reference={reference}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
ref={characterReferenceButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__reference-chip image-canvas-editor__reference-chip--upload"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label="上传常规参考图"
|
||||
onClick={() => setIsCharacterReferenceMenuOpen((open) => !open)}
|
||||
>
|
||||
角色设定
|
||||
</PlatformFieldLabel>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
<ImagePlus className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-chip-label">
|
||||
参考图
|
||||
</span>
|
||||
</button>
|
||||
{isCharacterReferenceMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
||||
label="常规参考图来源"
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
characterReferenceButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsPickingCharacterReferenceFromCanvas(true);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
从画布中选择
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__context-menu-item"
|
||||
onClick={() => {
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
onRequestUpload('character-reference');
|
||||
}}
|
||||
>
|
||||
上传图片
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="角色设定"
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="你希望角色如何设计?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'character'
|
||||
@@ -273,17 +269,13 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.character}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
/>
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={dialog.status === 'generating'}
|
||||
>
|
||||
{dialog.status === 'generating' ? '生成中' : '生成'}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -80,6 +80,57 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
saveEditorProjectLayoutMock,
|
||||
});
|
||||
|
||||
const openGenerationDimensionsPanel = (
|
||||
panel: HTMLElement,
|
||||
labelPrefix = '生成图片',
|
||||
) => {
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: new RegExp(`^${labelPrefix}尺寸 `, 'u'),
|
||||
}),
|
||||
);
|
||||
return screen.getByRole('menu', { name: `${labelPrefix}尺寸选项` });
|
||||
};
|
||||
|
||||
const openGenerationModelPanel = (
|
||||
panel: HTMLElement,
|
||||
labelPrefix = '生成图片',
|
||||
) => {
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: new RegExp(`^${labelPrefix}模型 `, 'u'),
|
||||
}),
|
||||
);
|
||||
return screen.getByRole('menu', { name: `${labelPrefix}模型选项` });
|
||||
};
|
||||
|
||||
const selectGenerationModel = (panel: HTMLElement, modelLabel: string) => {
|
||||
const modelPanel = openGenerationModelPanel(panel);
|
||||
fireEvent.click(
|
||||
within(modelPanel).getByRole('button', {
|
||||
name: new RegExp(`^${modelLabel}(?: ✓)?$`, 'u'),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const selectGenerationDimensions = (
|
||||
panel: HTMLElement,
|
||||
aspectRatio: string,
|
||||
imageSize: string,
|
||||
) => {
|
||||
const dimensionsPanel = openGenerationDimensionsPanel(panel);
|
||||
fireEvent.click(
|
||||
within(dimensionsPanel).getByRole('button', {
|
||||
name: new RegExp(`^(?:比例 )?${aspectRatio}$`, 'u'),
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(dimensionsPanel).getByRole('button', {
|
||||
name: new RegExp(`^(?:尺寸 )?${imageSize}$`, 'u'),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
it('opens a canvas generation frame and composer before creating a generated layer', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||||
@@ -108,11 +159,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(
|
||||
within(generateDialog).getByRole('button', { name: '添加参考图' })
|
||||
.className,
|
||||
).toContain('bg-white/94');
|
||||
).toContain('image-canvas-editor__reference-chip');
|
||||
expect(
|
||||
within(generateDialog).getByRole('button', { name: '添加参考图' })
|
||||
.className,
|
||||
).toContain('image-canvas-editor__generation-ref');
|
||||
).toContain('image-canvas-editor__reference-chip');
|
||||
const generatePrompt = screen.getByLabelText('生成提示词');
|
||||
expect(generatePrompt.className).toContain('platform-text-field');
|
||||
expect(generatePrompt.className).toContain(
|
||||
@@ -120,12 +171,12 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
);
|
||||
expect(
|
||||
within(generateDialog).getByRole('button', {
|
||||
name: '生成比例 1:1 2k 1张',
|
||||
name: '生成图片尺寸 1:1 · 1K',
|
||||
}).className,
|
||||
).toContain('platform-inline-option-button');
|
||||
expect(
|
||||
within(generateDialog).getByRole('button', {
|
||||
name: '生成模型 GPT Image',
|
||||
name: '生成图片模型 nanobanana2',
|
||||
}).className,
|
||||
).toContain('platform-inline-option-button');
|
||||
expect(
|
||||
@@ -146,6 +197,9 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(screen.getByRole('status').textContent).toContain('生成中');
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
prompt: '一张明亮的拼图主视觉',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -654,7 +708,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(
|
||||
within(specDialog).getByRole('button', { name: '提交生成规范' })
|
||||
.textContent,
|
||||
).toContain('消耗5泥点');
|
||||
).toContain('生成5泥点');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('玩法设定'), {
|
||||
target: { value: '平台跳跃玩法' },
|
||||
@@ -745,7 +799,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(within(iconSpritesheetPanel).getByText('素材描述')).toBeTruthy();
|
||||
expect(within(iconSpritesheetPanel).getByText('素材描述 1')).toBeTruthy();
|
||||
expect(within(iconSpritesheetPanel).getByText('素材描述 6')).toBeTruthy();
|
||||
expect(within(iconSpritesheetPanel).getByText('模型')).toBeTruthy();
|
||||
expect(
|
||||
within(iconSpritesheetPanel).getByRole('button', {
|
||||
name: '生成图片模型 nanobanana2',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(
|
||||
within(iconSpritesheetPanel).getByRole('button', {
|
||||
@@ -768,26 +826,44 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const characterPanel = screen.getByRole('dialog', {
|
||||
name: '生成角色形象',
|
||||
});
|
||||
expect(within(characterPanel).getByText('画面比例')).toBeTruthy();
|
||||
expect(within(characterPanel).getByText('大小尺寸')).toBeTruthy();
|
||||
expect(within(characterPanel).getByText('模型')).toBeTruthy();
|
||||
expect(
|
||||
within(characterPanel).getByRole('button', { name: '1:1' }),
|
||||
within(characterPanel).getByRole('button', {
|
||||
name: '生成图片尺寸 1:1 · 1K',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(characterPanel).getByRole('button', { name: '1K' }),
|
||||
within(characterPanel).getByRole('button', {
|
||||
name: '生成图片模型 nanobanana2',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
const characterDimensionsPanel =
|
||||
openGenerationDimensionsPanel(characterPanel);
|
||||
expect(within(characterDimensionsPanel).getByText(/^(?:比例|Aspect ratio)$/u)).toBeTruthy();
|
||||
expect(within(characterDimensionsPanel).getByText(/^(?:尺寸|Size)$/u)).toBeTruthy();
|
||||
expect(
|
||||
within(characterDimensionsPanel).getByRole('button', { name: /^(?:比例 )?1:1$/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(characterPanel).getByRole('button', { name: 'nanobanana2' }),
|
||||
within(characterDimensionsPanel).getByRole('button', { name: /^(?:尺寸 )?1K$/u }),
|
||||
).toBeTruthy();
|
||||
const characterModelPanel = openGenerationModelPanel(characterPanel);
|
||||
expect(
|
||||
within(characterModelPanel).getByRole('button', {
|
||||
name: /nanobanana2/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
expect(within(iconPanel).getByText('画面比例')).toBeTruthy();
|
||||
expect(within(iconPanel).getByText('大小尺寸')).toBeTruthy();
|
||||
expect(within(iconPanel).getByText('模型')).toBeTruthy();
|
||||
expect(
|
||||
within(iconPanel).getByRole('button', { name: 'nanobanana2' }),
|
||||
within(iconPanel).getByRole('button', {
|
||||
name: '生成图片尺寸 1:1 · 1K',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(iconPanel).getByRole('button', {
|
||||
name: '生成图片模型 nanobanana2',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -867,13 +943,8 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const characterPanel = screen.getByRole('dialog', {
|
||||
name: '生成角色形象',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(characterPanel).getByRole('button', { name: 'gpt-image-2' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(characterPanel).getByRole('button', { name: '2:3' }),
|
||||
);
|
||||
fireEvent.click(within(characterPanel).getByRole('button', { name: '2K' }));
|
||||
selectGenerationModel(characterPanel, 'gpt-image-2');
|
||||
selectGenerationDimensions(characterPanel, '2:3', '2K');
|
||||
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
|
||||
target: { value: '蓝衣剑士' },
|
||||
});
|
||||
@@ -896,7 +967,9 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
expect(
|
||||
within(iconPanel).getByRole('button', { name: 'gpt-image-2' }),
|
||||
within(iconPanel).getByRole('button', {
|
||||
name: '生成图片模型 gpt-image-2',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(
|
||||
within(iconPanel).getByRole('button', { name: '图标素材规范' }),
|
||||
@@ -1091,11 +1164,14 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
within(characterPanel).getByRole('button', { name: '角色形象规范' }),
|
||||
);
|
||||
const referenceRow = characterPanel.querySelector(
|
||||
'.image-canvas-editor__character-reference-row',
|
||||
'.image-canvas-editor__reference-strip',
|
||||
);
|
||||
const sourceMenu = screen.getByRole('menu', { name: '角色形象规范来源' });
|
||||
|
||||
expect(referenceRow?.contains(sourceMenu)).toBe(false);
|
||||
if (!referenceRow) {
|
||||
throw new Error('角色生成面板应包含参考图区域');
|
||||
}
|
||||
expect(referenceRow.contains(sourceMenu)).toBe(false);
|
||||
expect(sourceMenu.className).toContain('platform-floating-menu--top-start');
|
||||
|
||||
fireEvent.click(
|
||||
@@ -1104,7 +1180,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const regularReferenceMenu = screen.getByRole('menu', {
|
||||
name: '常规参考图来源',
|
||||
});
|
||||
expect(referenceRow?.contains(regularReferenceMenu)).toBe(false);
|
||||
expect(referenceRow.contains(regularReferenceMenu)).toBe(false);
|
||||
expect(regularReferenceMenu.className).toContain(
|
||||
'platform-floating-menu--top-start',
|
||||
);
|
||||
@@ -1122,15 +1198,15 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
name: '上传常规参考图',
|
||||
});
|
||||
|
||||
expect(specTile.className).toContain('image-canvas-editor__reference-tile');
|
||||
expect(specTile.className).toContain('image-canvas-editor__reference-chip');
|
||||
expect(uploadTile.className).toContain(
|
||||
'image-canvas-editor__reference-tile',
|
||||
'image-canvas-editor__reference-chip',
|
||||
);
|
||||
expect(
|
||||
specTile.querySelector('.image-canvas-editor__reference-tile-visual'),
|
||||
specTile.querySelector('.image-canvas-editor__reference-chip-icon'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
uploadTile.querySelector('.image-canvas-editor__reference-tile-visual'),
|
||||
uploadTile.querySelector('.image-canvas-editor__reference-chip-icon'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1406,7 +1482,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(canvasReferenceLayer.className).not.toContain(
|
||||
'image-canvas-editor__layer--selected',
|
||||
);
|
||||
expect(within(characterPanel).getByText('1')).toBeTruthy();
|
||||
expect(within(characterPanel).getByText('大鱼素材')).toBeTruthy();
|
||||
|
||||
fireEvent.click(
|
||||
within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
|
||||
@@ -1417,7 +1493,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
new File(['reference'], '常规参考.png', { type: 'image/png' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(within(characterPanel).getByText('2')).toBeTruthy();
|
||||
expect(within(characterPanel).getByText('常规参考.png')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
|
||||
@@ -1807,16 +1883,14 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成动画' }));
|
||||
const panel = screen.getByRole('dialog', { name: '角色动画生成面板' });
|
||||
expect(within(panel).getByText('40泥点')).toBeTruthy();
|
||||
expect(
|
||||
(within(panel).getByLabelText('分辨率') as HTMLSelectElement).value,
|
||||
).toBe('480p');
|
||||
within(panel).getByRole('button', {
|
||||
name: '动画参数 同图尺寸 · 4秒 · 480p',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(within(panel).getByLabelText('画面比例') as HTMLSelectElement).value,
|
||||
).toBe('same');
|
||||
expect(
|
||||
(within(panel).getByLabelText('时长') as HTMLSelectElement).value,
|
||||
).toBe('32');
|
||||
within(panel).getByRole('button', { name: '生成40泥点' }),
|
||||
).toBeTruthy();
|
||||
for (const actionLabel of [
|
||||
'待机',
|
||||
'行走',
|
||||
@@ -1849,17 +1923,30 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(
|
||||
within(panel).getByLabelText(`生成文本:${precisePrompt}`),
|
||||
).toBeTruthy();
|
||||
fireEvent.change(within(panel).getByLabelText('分辨率'), {
|
||||
target: { value: '720p' },
|
||||
});
|
||||
fireEvent.change(within(panel).getByLabelText('画面比例'), {
|
||||
target: { value: '16:9' },
|
||||
});
|
||||
fireEvent.change(within(panel).getByLabelText('时长'), {
|
||||
target: { value: '48' },
|
||||
});
|
||||
expect(within(panel).getByText('120泥点')).toBeTruthy();
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', {
|
||||
name: '动画参数 同图尺寸 · 4秒 · 480p',
|
||||
}),
|
||||
);
|
||||
const animationMenu = screen.getByRole('menu', { name: '动画参数选项' });
|
||||
fireEvent.click(
|
||||
within(animationMenu).getByRole('button', { name: '清晰度 720p' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(animationMenu).getByRole('button', { name: '比例 16:9' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(animationMenu).getByRole('button', { name: '时长 48帧·6秒' }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '动画参数选项' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', {
|
||||
name: '动画参数 16:9 · 6秒 · 720p',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '生成120泥点' }));
|
||||
|
||||
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* @vitest-environment jsdom */
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
createRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
@@ -16,13 +17,14 @@ function mockStateSetter<T>() {
|
||||
return vi.fn() as unknown as Dispatch<SetStateAction<T>>;
|
||||
}
|
||||
|
||||
function renderComposer(
|
||||
|
||||
function createComposerProps(
|
||||
generateDialog: GenerateDialogState,
|
||||
overrides: Partial<
|
||||
ComponentProps<typeof ImageCanvasGenerationComposerView>
|
||||
> = {},
|
||||
) {
|
||||
const props: ComponentProps<typeof ImageCanvasGenerationComposerView> = {
|
||||
): ComponentProps<typeof ImageCanvasGenerationComposerView> {
|
||||
return {
|
||||
specToolWrapRef: createRef(),
|
||||
characterSpecButtonRef: createRef(),
|
||||
characterReferenceButtonRef: createRef(),
|
||||
@@ -88,6 +90,15 @@ function renderComposer(
|
||||
onRememberImageModel: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderComposer(
|
||||
generateDialog: GenerateDialogState,
|
||||
overrides: Partial<
|
||||
ComponentProps<typeof ImageCanvasGenerationComposerView>
|
||||
> = {},
|
||||
) {
|
||||
const props = createComposerProps(generateDialog, overrides);
|
||||
|
||||
return render(<ImageCanvasGenerationComposerView {...props} />);
|
||||
}
|
||||
@@ -113,6 +124,11 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成UI设计图' });
|
||||
expect(
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__generation-composer',
|
||||
);
|
||||
@@ -120,9 +136,8 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
'image-canvas-editor__generation-composer--image',
|
||||
);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: 'UI设计图标素材规范' })
|
||||
.parentElement?.className,
|
||||
).toContain('image-canvas-editor__generation-ref');
|
||||
within(panel).getByRole('button', { name: 'UI设计图标素材规范' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: 'UI设计要求' }).className,
|
||||
).toContain('image-canvas-editor__generation-prompt');
|
||||
@@ -157,16 +172,18 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成规范' });
|
||||
expect(
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__generation-composer',
|
||||
);
|
||||
expect(panel.className).toContain(
|
||||
'image-canvas-editor__generation-composer--image',
|
||||
);
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '参考图' }).parentElement
|
||||
?.className,
|
||||
).toContain('image-canvas-editor__generation-ref');
|
||||
expect(within(panel).getByRole('button', { name: '参考图' })).toBeTruthy();
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
||||
).toBeTruthy();
|
||||
@@ -194,9 +211,11 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成规范' });
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '参考图' }).parentElement
|
||||
?.className,
|
||||
).toContain('image-canvas-editor__generation-ref');
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(within(panel).getByRole('button', { name: '参考图' })).toBeTruthy();
|
||||
});
|
||||
it('生成图片参考图点击先弹来源菜单,不直接打开上传', () => {
|
||||
const onRequestUpload = vi.fn();
|
||||
@@ -272,11 +291,9 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('生成视频面板可切换时长、清晰度和模型并更新泥点', () => {
|
||||
const setGenerateDialog = vi.fn();
|
||||
|
||||
renderComposer(
|
||||
{
|
||||
it('生成视频面板用子面板修改参数和模型,不展示 Veo,并在按钮内显示泥点文案', () => {
|
||||
function VideoHarness() {
|
||||
const [dialog, setDialog] = useState<GenerateDialogState>({
|
||||
mode: 'video',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
@@ -288,32 +305,60 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
videoResolution: '480p',
|
||||
videoMode: 'std',
|
||||
videoSound: 'off',
|
||||
},
|
||||
{
|
||||
setGenerateDialog:
|
||||
setGenerateDialog as unknown as Dispatch<
|
||||
SetStateAction<GenerateDialogState | null>
|
||||
>,
|
||||
},
|
||||
);
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<ImageCanvasGenerationComposerView
|
||||
{...createComposerProps(dialog)}
|
||||
setGenerateDialog={setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>}
|
||||
/>
|
||||
<output aria-label="当前视频模型">{dialog.videoModel}</output>
|
||||
<output aria-label="当前视频时长">{dialog.videoDurationSeconds}</output>
|
||||
<output aria-label="当前视频清晰度">{dialog.videoResolution}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render(<VideoHarness />);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成视频' });
|
||||
expect(within(panel).getByRole('button', { name: '视频参数 16:9 · 4秒 · 480p' }))
|
||||
.toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '模型 Seedance 2.0' }))
|
||||
.toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '清晰度 480p' }))
|
||||
.toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '生成视频' }).textContent)
|
||||
.toBe('40');
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '视频参数 16:9 · 4秒 · 480p' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(panel).getByRole('button', { name: '模型 Seedance 2.0' }),
|
||||
).toBeTruthy();
|
||||
expect(within(panel).getByRole('button', { name: '生成视频' }).textContent).toBe(
|
||||
'生成40泥点',
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: '视频参数 16:9 · 4秒 · 480p' }),
|
||||
);
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '模型 Seedance 2.0' }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '清晰度 480p' }));
|
||||
const paramsPanel = screen.getByRole('menu', {
|
||||
name: '视频参数选项',
|
||||
});
|
||||
fireEvent.click(within(paramsPanel).getByRole('button', { name: '时长 5秒' }));
|
||||
fireEvent.click(within(paramsPanel).getByRole('button', { name: '清晰度 720p' }));
|
||||
|
||||
expect(setGenerateDialog).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '视频参数选项' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前视频时长').textContent).toBe('5');
|
||||
expect(screen.getByLabelText('当前视频清晰度').textContent).toBe('720p');
|
||||
expect(screen.getByRole('button', { name: '视频参数 16:9 · 5秒 · 720p' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '生成视频' }).textContent).toBe(
|
||||
'生成100泥点',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '模型 Seedance 2.0' }));
|
||||
const modelPanel = screen.getByRole('menu', { name: '视频模型选项' });
|
||||
expect(within(modelPanel).queryByRole('button', { name: /Veo/i })).toBeNull();
|
||||
fireEvent.click(within(modelPanel).getByRole('button', { name: 'Kling 3.0' }));
|
||||
|
||||
expect(screen.getByRole('menu', { name: '视频模型选项' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前视频模型').textContent).toBe('kling3.0');
|
||||
expect(screen.getByRole('button', { name: '模型 Kling 3.0' })).toBeTruthy();
|
||||
});
|
||||
it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => {
|
||||
const onRequestUpload = vi.fn();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -77,11 +77,15 @@ export function createGenerateDialogDraft({
|
||||
const placeholderWidth = 420;
|
||||
const placeholderHeight = 420;
|
||||
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
|
||||
const dimensionDefaults = resolveImageDimensionDefaults(DEFAULT_IMAGE_MODEL);
|
||||
return {
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
imageModel: DEFAULT_IMAGE_MODEL,
|
||||
aspectRatio: dimensionDefaults.aspectRatio,
|
||||
imageSize: dimensionDefaults.imageSize,
|
||||
placeholder: {
|
||||
x: worldCenter.x - placeholderWidth / 2,
|
||||
y: worldCenter.y - placeholderHeight / 2,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -29,6 +29,9 @@ function ImageOptionsHarness({
|
||||
setGenerateDialog={setDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
cost={12}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成角色形象"
|
||||
/>
|
||||
<output aria-label="当前模型">{dialog.imageModel}</output>
|
||||
<output aria-label="当前比例">{dialog.aspectRatio}</output>
|
||||
@@ -40,7 +43,7 @@ function ImageOptionsHarness({
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
it('updates dimensions and resets failed dialog state', () => {
|
||||
it('updates dimensions from a menu, keeps the menu open and marks the selection', () => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
initialDialog={{
|
||||
@@ -55,16 +58,35 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '16:9' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '2K' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1 · 1K' }),
|
||||
);
|
||||
const panel = screen.getByRole('menu', { name: '生成图片尺寸选项' });
|
||||
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '16:9' }));
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '2K' }));
|
||||
|
||||
expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy();
|
||||
expect(
|
||||
within(panel)
|
||||
.getByRole('button', { name: '16:9' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
within(panel)
|
||||
.getByRole('button', { name: '2K' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '生成图片尺寸 16:9 · 2K' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('当前比例').textContent).toBe('16:9');
|
||||
expect(screen.getByLabelText('当前尺寸').textContent).toBe('2K');
|
||||
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
||||
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('remembers model changes and keeps compatible dimensions', () => {
|
||||
it('renders model choices as one model per row and uses a check mark for selection', () => {
|
||||
const rememberImageModel = vi.fn();
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
@@ -80,13 +102,48 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
||||
);
|
||||
const panel = screen.getByRole('menu', { name: '生成图片模型选项' });
|
||||
|
||||
expect(within(panel).getByRole('button', { name: 'nanobanana2' })).toBeTruthy();
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'gpt-image-2' }));
|
||||
|
||||
expect(screen.getByRole('menu', { name: '生成图片模型选项' })).toBeTruthy();
|
||||
expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2);
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe(
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
);
|
||||
expect(screen.getByLabelText('当前比例').textContent).toBe('9:16');
|
||||
expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '生成图片模型 gpt-image-2' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders option menus and shows mud point text in the submit button', () => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
initialDialog={{
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片尺寸 1:1 · 1K' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('menu', { name: '生成图片尺寸选项' }),
|
||||
).toBeTruthy();
|
||||
const submit = screen.getByRole('button', { name: '生成角色形象' });
|
||||
expect(submit.textContent).toBe('生成12泥点');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { type Dispatch, type SetStateAction } from 'react';
|
||||
import { Check, ChevronDown, Cpu } from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import {
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
@@ -14,8 +23,19 @@ type ImageCanvasGenerationImageOptionsViewProps = {
|
||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||
includeDimensions: boolean;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
optionLabelPrefix?: string;
|
||||
cost?: number;
|
||||
submitLabel?: string;
|
||||
submitAriaLabel?: string;
|
||||
renderEditorPortal?: (node: ReactNode) => ReactNode;
|
||||
buildPortalMenuStyle?: (
|
||||
anchor: HTMLElement | null,
|
||||
placement: 'above' | 'below',
|
||||
) => CSSProperties;
|
||||
};
|
||||
|
||||
type OpenPanel = 'dimensions' | 'model' | null;
|
||||
|
||||
function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
return {
|
||||
...dialog,
|
||||
@@ -24,7 +44,7 @@ function resetFailedDialogStatus(dialog: GenerateDialogState) {
|
||||
};
|
||||
}
|
||||
|
||||
function getImageDimensionOptions(model: string | null | undefined) {
|
||||
export function getImageDimensionOptions(model: string | null | undefined) {
|
||||
return (
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS[
|
||||
(model ?? IMAGE_MODEL_NANOBANANA2) as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
|
||||
@@ -32,7 +52,7 @@ function getImageDimensionOptions(model: string | null | undefined) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeImageDialogSelection(dialog: GenerateDialogState) {
|
||||
export function normalizeImageDialogSelection(dialog: GenerateDialogState) {
|
||||
const model = dialog.imageModel ?? IMAGE_MODEL_NANOBANANA2;
|
||||
const options = getImageDimensionOptions(model);
|
||||
const aspectRatios = options.aspectRatios as readonly string[];
|
||||
@@ -52,13 +72,58 @@ function normalizeImageDialogSelection(dialog: GenerateDialogState) {
|
||||
};
|
||||
}
|
||||
|
||||
function getImageModelLabel(value: string) {
|
||||
return (
|
||||
EDITOR_IMAGE_MODEL_OPTIONS.find((option) => option.value === value)
|
||||
?.label ?? value
|
||||
);
|
||||
}
|
||||
|
||||
function OptionChoice({
|
||||
children,
|
||||
selected,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
selected: boolean;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={['image-canvas-editor__option-popover-choice', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-pressed={selected}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvasGenerationImageOptionsView({
|
||||
dialog,
|
||||
setGenerateDialog,
|
||||
includeDimensions,
|
||||
onRememberImageModel,
|
||||
optionLabelPrefix,
|
||||
cost,
|
||||
submitLabel = '生成',
|
||||
submitAriaLabel = '生成',
|
||||
renderEditorPortal = (node) => node,
|
||||
buildPortalMenuStyle = () => ({}),
|
||||
}: ImageCanvasGenerationImageOptionsViewProps) {
|
||||
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
|
||||
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const selection = normalizeImageDialogSelection(dialog);
|
||||
const selectedModelLabel = getImageModelLabel(selection.model);
|
||||
const isGenerating = dialog.status === 'generating';
|
||||
const labelPrefix = optionLabelPrefix ?? '生成图片';
|
||||
|
||||
const updateDialog = (patch: Partial<GenerateDialogState>) => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog && currentDialog.mode === dialog.mode
|
||||
@@ -70,96 +135,187 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
);
|
||||
};
|
||||
|
||||
const updateImageModel = (model: string) => {
|
||||
onRememberImageModel(model);
|
||||
setGenerateDialog((currentDialog) => {
|
||||
if (!currentDialog || currentDialog.mode !== dialog.mode) {
|
||||
return currentDialog;
|
||||
}
|
||||
const nextOptions = getImageDimensionOptions(model);
|
||||
const nextAspectRatios = nextOptions.aspectRatios as readonly string[];
|
||||
const nextImageSizes = nextOptions.imageSizes as readonly string[];
|
||||
return {
|
||||
...resetFailedDialogStatus(currentDialog),
|
||||
imageModel: model,
|
||||
aspectRatio:
|
||||
currentDialog.aspectRatio &&
|
||||
nextAspectRatios.includes(currentDialog.aspectRatio)
|
||||
? currentDialog.aspectRatio
|
||||
: nextOptions.aspectRatios[0],
|
||||
imageSize:
|
||||
currentDialog.imageSize &&
|
||||
nextImageSizes.includes(currentDialog.imageSize)
|
||||
? currentDialog.imageSize
|
||||
: (nextOptions.imageSizes.find((size) => size === '1K') ??
|
||||
nextOptions.imageSizes[0]),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const togglePanel = (panel: Exclude<OpenPanel, null>) => {
|
||||
setOpenPanel((currentPanel) => (currentPanel === panel ? null : panel));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{includeDimensions ? (
|
||||
<>
|
||||
<div className="image-canvas-editor__option-field">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
画面比例
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__inline-option-group">
|
||||
{selection.options.aspectRatios.map((aspectRatio) => (
|
||||
<PlatformInlineOptionButton
|
||||
key={aspectRatio}
|
||||
className="image-canvas-editor__generation-ratio"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-pressed={selection.aspectRatio === aspectRatio}
|
||||
onClick={() => updateDialog({ aspectRatio })}
|
||||
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
|
||||
<PlatformInlineOptionButton
|
||||
ref={dimensionsButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
|
||||
aria-label={`${labelPrefix}尺寸 ${selection.aspectRatio} · ${selection.imageSize}`}
|
||||
aria-expanded={openPanel === 'dimensions'}
|
||||
disabled={isGenerating}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => togglePanel('dimensions')}
|
||||
>
|
||||
{selection.aspectRatio} · {selection.imageSize}
|
||||
</PlatformInlineOptionButton>
|
||||
{openPanel === 'dimensions'
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__option-popover image-canvas-editor__portal-menu"
|
||||
label={`${labelPrefix}尺寸选项`}
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(
|
||||
dimensionsButtonRef.current,
|
||||
'above',
|
||||
)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{aspectRatio}
|
||||
</PlatformInlineOptionButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="image-canvas-editor__option-field">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
大小尺寸
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__inline-option-group">
|
||||
{selection.options.imageSizes.map((imageSize) => (
|
||||
<PlatformInlineOptionButton
|
||||
key={imageSize}
|
||||
className="image-canvas-editor__generation-ratio"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-pressed={selection.imageSize === imageSize}
|
||||
onClick={() => updateDialog({ imageSize })}
|
||||
>
|
||||
{imageSize}
|
||||
</PlatformInlineOptionButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="image-canvas-editor__option-field">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
模型
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__inline-option-group">
|
||||
{EDITOR_IMAGE_MODEL_OPTIONS.map((option) => {
|
||||
const nextOptions = getImageDimensionOptions(option.value);
|
||||
const nextAspectRatios = nextOptions.aspectRatios as readonly string[];
|
||||
const nextImageSizes = nextOptions.imageSizes as readonly string[];
|
||||
return (
|
||||
<PlatformInlineOptionButton
|
||||
key={option.value}
|
||||
className="image-canvas-editor__generation-model"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-pressed={selection.model === option.value}
|
||||
onClick={() => {
|
||||
onRememberImageModel(option.value);
|
||||
updateDialog({
|
||||
imageModel: option.value,
|
||||
aspectRatio:
|
||||
dialog.aspectRatio &&
|
||||
nextAspectRatios.includes(dialog.aspectRatio)
|
||||
? dialog.aspectRatio
|
||||
: nextOptions.aspectRatios[0],
|
||||
imageSize:
|
||||
dialog.imageSize &&
|
||||
nextImageSizes.includes(dialog.imageSize)
|
||||
? dialog.imageSize
|
||||
: (nextOptions.imageSizes.find((size) => size === '1K') ??
|
||||
nextOptions.imageSizes[0]),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</PlatformInlineOptionButton>
|
||||
);
|
||||
})}
|
||||
<div className="image-canvas-editor__option-popover-sections">
|
||||
<div className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
比例
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--card">
|
||||
{selection.options.aspectRatios.map((aspectRatio) => (
|
||||
<OptionChoice
|
||||
key={aspectRatio}
|
||||
selected={selection.aspectRatio === aspectRatio}
|
||||
className="image-canvas-editor__option-popover-choice--ratio"
|
||||
onClick={() => updateDialog({ aspectRatio })}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__ratio-wireframe"
|
||||
data-ratio={aspectRatio}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{aspectRatio}</span>
|
||||
</OptionChoice>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="image-canvas-editor__option-popover-section">
|
||||
<span className="image-canvas-editor__option-popover-title">
|
||||
尺寸
|
||||
</span>
|
||||
<div className="image-canvas-editor__option-popover-items">
|
||||
{selection.options.imageSizes.map((imageSize) => (
|
||||
<OptionChoice
|
||||
key={imageSize}
|
||||
selected={selection.imageSize === imageSize}
|
||||
onClick={() => updateDialog({ imageSize })}
|
||||
>
|
||||
{imageSize}
|
||||
</OptionChoice>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model">
|
||||
<PlatformInlineOptionButton
|
||||
ref={modelButtonRef}
|
||||
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
|
||||
aria-label={`${labelPrefix}模型 ${selectedModelLabel}`}
|
||||
aria-expanded={openPanel === 'model'}
|
||||
disabled={isGenerating}
|
||||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||||
onClick={() => togglePanel('model')}
|
||||
>
|
||||
<span className="image-canvas-editor__model-trigger-label">
|
||||
<span className="image-canvas-editor__model-icon" aria-hidden="true">
|
||||
<Cpu />
|
||||
</span>
|
||||
<span>{selectedModelLabel}</span>
|
||||
</span>
|
||||
</PlatformInlineOptionButton>
|
||||
{openPanel === 'model'
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--model image-canvas-editor__portal-menu"
|
||||
label={`${labelPrefix}模型选项`}
|
||||
placement="top-start"
|
||||
style={buildPortalMenuStyle(modelButtonRef.current, 'above')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-canvas-editor__option-popover-items image-canvas-editor__option-popover-items--model">
|
||||
{EDITOR_IMAGE_MODEL_OPTIONS.map((option) => {
|
||||
const selected = selection.model === option.value;
|
||||
return (
|
||||
<OptionChoice
|
||||
key={option.value}
|
||||
selected={selected}
|
||||
className="image-canvas-editor__option-popover-choice--model"
|
||||
onClick={() => updateImageModel(option.value)}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__model-icon"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Cpu />
|
||||
</span>
|
||||
<span>{option.label}</span>
|
||||
<Check
|
||||
className="image-canvas-editor__option-selected-check"
|
||||
data-visible={selected}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</OptionChoice>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
{typeof cost === 'number' ? (
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={isGenerating}
|
||||
aria-label={submitAriaLabel}
|
||||
>
|
||||
{isGenerating ? (
|
||||
'生成中'
|
||||
) : (
|
||||
<>
|
||||
<span>{submitLabel}</span>
|
||||
<span className="image-canvas-editor__mud-point-inline">
|
||||
{cost}泥点
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
chooseGenerationPlacement,
|
||||
centerViewportOnPlacement,
|
||||
} from './ImageCanvasGenerationPlacementModel';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
const canvasSize = { width: 900, height: 640 };
|
||||
const viewport: CanvasViewport = { x: 10, y: 20, scale: 2 };
|
||||
@@ -35,6 +39,26 @@ function layer(overrides: Partial<CanvasLayer>): CanvasLayer {
|
||||
};
|
||||
}
|
||||
|
||||
function generationDialog(
|
||||
overrides: Partial<CanvasGenerationDialogState>,
|
||||
): CanvasGenerationDialogState {
|
||||
return {
|
||||
id: 'dialog-1',
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
placeholder: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 2048,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ImageCanvasGenerationPlacementModel', () => {
|
||||
it('places an empty-canvas generation frame at the current viewport center', () => {
|
||||
const placement = chooseGenerationPlacement({
|
||||
@@ -94,10 +118,86 @@ describe('ImageCanvasGenerationPlacementModel', () => {
|
||||
expect(placement.y).toBe(-22);
|
||||
});
|
||||
|
||||
it('centers the viewport on the chosen placement without changing zoom', () => {
|
||||
const nextViewport = centerViewportOnPlacement({
|
||||
it('does not treat hidden layers as generation placement blockers', () => {
|
||||
const placement = chooseGenerationPlacement({
|
||||
canvasSize,
|
||||
viewport,
|
||||
frame: { ...frame, width: 100, height: 100 },
|
||||
layers: [
|
||||
layer({
|
||||
id: 'hidden-center',
|
||||
hidden: true,
|
||||
x: 170,
|
||||
y: 110,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}),
|
||||
],
|
||||
generationDialogs: [],
|
||||
});
|
||||
|
||||
expect(placement.x).toBe(170);
|
||||
expect(placement.y).toBe(100);
|
||||
});
|
||||
|
||||
it('chooses the nearest empty slot when multiple blockers surround the viewport center', () => {
|
||||
const placement = chooseGenerationPlacement({
|
||||
canvasSize: { width: 1000, height: 1000 },
|
||||
viewport: { x: 500, y: 500, scale: 1 },
|
||||
frame: { ...frame, width: 100, height: 100 },
|
||||
layers: [
|
||||
layer({
|
||||
id: 'left-blocker',
|
||||
x: -148,
|
||||
y: -68,
|
||||
width: 96,
|
||||
height: 136,
|
||||
}),
|
||||
layer({
|
||||
id: 'top-blocker',
|
||||
x: -68,
|
||||
y: -148,
|
||||
width: 136,
|
||||
height: 96,
|
||||
}),
|
||||
],
|
||||
generationDialogs: [],
|
||||
});
|
||||
|
||||
expect(placement.x).toBe(-20);
|
||||
expect(placement.y).toBe(-20);
|
||||
});
|
||||
|
||||
it('avoids existing generation placeholders when choosing a new placement', () => {
|
||||
const placement = chooseGenerationPlacement({
|
||||
canvasSize,
|
||||
viewport,
|
||||
frame: { ...frame, width: 100, height: 100 },
|
||||
layers: [],
|
||||
generationDialogs: [
|
||||
generationDialog({
|
||||
id: 'existing-placeholder',
|
||||
placeholder: {
|
||||
x: 170,
|
||||
y: 110,
|
||||
width: 100,
|
||||
height: 100,
|
||||
originalWidth: 2048,
|
||||
originalHeight: 2048,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(placement.x).toBe(170);
|
||||
expect(placement.y).toBe(-22);
|
||||
});
|
||||
|
||||
it('centers the viewport on the chosen placement without changing zoom', () => {
|
||||
const zoomedViewport = { ...viewport, scale: 1.75 };
|
||||
const nextViewport = centerViewportOnPlacement({
|
||||
canvasSize,
|
||||
viewport: zoomedViewport,
|
||||
placement: {
|
||||
x: 170,
|
||||
y: 262,
|
||||
@@ -108,6 +208,7 @@ describe('ImageCanvasGenerationPlacementModel', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(nextViewport).toEqual({ x: 10, y: -304, scale: 2 });
|
||||
expect(nextViewport.scale).toBe(zoomedViewport.scale);
|
||||
expect(nextViewport).toEqual({ x: 65, y: -226, scale: 1.75 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +158,26 @@ function buildPlacementCandidates({
|
||||
);
|
||||
});
|
||||
|
||||
// 组合不同阻挡物的水平 / 垂直边界,覆盖中心被多块图层夹住时的最近空位。
|
||||
const edgeXs = new Set<number>([baseX]);
|
||||
const edgeYs = new Set<number>([baseY]);
|
||||
blockingRects.forEach((rect) => {
|
||||
edgeXs.add(rect.x + rect.width);
|
||||
edgeXs.add(rect.x - frame.width);
|
||||
edgeYs.add(rect.y + rect.height);
|
||||
edgeYs.add(rect.y - frame.height);
|
||||
});
|
||||
edgeXs.forEach((x) => {
|
||||
edgeYs.forEach((y) => {
|
||||
pushUniqueCandidate(candidates, {
|
||||
x,
|
||||
y,
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
for (let ring = 1; ring <= MAX_PLACEMENT_RING; ring += 1) {
|
||||
const offsets = [
|
||||
{ x: ring, y: 0 },
|
||||
|
||||
@@ -227,11 +227,15 @@ export function buildImageGenerationSubmissionPlan({
|
||||
};
|
||||
}
|
||||
|
||||
const imageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL;
|
||||
return {
|
||||
kind: 'image',
|
||||
normalizedPrompt,
|
||||
input: {
|
||||
prompt: normalizedPrompt,
|
||||
model: imageModel,
|
||||
aspectRatio: dialog.aspectRatio ?? '1:1',
|
||||
imageSize: dialog.imageSize ?? '1K',
|
||||
...(dialog.generationReferences?.length
|
||||
? {
|
||||
referenceImageSrcs: dialog.generationReferences.map(
|
||||
@@ -246,6 +250,7 @@ export function buildImageGenerationSubmissionPlan({
|
||||
dialog.generationReferences,
|
||||
),
|
||||
},
|
||||
rememberImageModel: imageModel,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { createRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -101,10 +101,31 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '新建图标素材规范' }));
|
||||
expect(openSpecDialog).toHaveBeenCalledWith('icon');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '上传' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '图标素材规范' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
|
||||
expect(requestUpload).toHaveBeenCalledWith('icon-spec');
|
||||
});
|
||||
|
||||
it('keeps the spec reference as a single first-row card without extra source buttons', () => {
|
||||
render(<IconComposerHarness initialDialog={createIconDialog()} />);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||||
const firstRow = panel.querySelector('.image-canvas-editor__reference-strip');
|
||||
|
||||
expect(firstRow).toBeTruthy();
|
||||
expect(
|
||||
within(firstRow as HTMLElement).getByRole('button', {
|
||||
name: '图标素材规范',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
panel.querySelector('.image-canvas-editor__icon-spec-actions'),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '画布' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '新建' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '上传' })).toBeNull();
|
||||
});
|
||||
|
||||
it('updates descriptions, adds more descriptions and submits', () => {
|
||||
const updateIconDescription = vi.fn();
|
||||
const addIconDescription = vi.fn();
|
||||
@@ -142,7 +163,13 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
||||
);
|
||||
const modelPanel = screen.getByRole('menu', { name: '生成图片模型选项' });
|
||||
fireEvent.click(
|
||||
within(modelPanel).getByRole('button', { name: 'gpt-image-2' }),
|
||||
);
|
||||
|
||||
expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2');
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from 'react';
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
@@ -17,6 +16,7 @@ import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import {
|
||||
DEFAULT_ICON_DESCRIPTIONS,
|
||||
EDITOR_GENERATION_MUD_POINT_CONFIG,
|
||||
ICON_DESCRIPTION_LIMIT,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import type {
|
||||
@@ -95,46 +95,40 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="image-canvas-editor__field-block">
|
||||
<PlatformFieldLabel
|
||||
variant="field"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
图标素材规范
|
||||
</PlatformFieldLabel>
|
||||
<div className="image-canvas-editor__icon-spec-row">
|
||||
<span className="image-canvas-editor__character-spec-wrap">
|
||||
<button
|
||||
ref={iconSpecButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__icon-spec-card"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label={dialog.iconSpecReference?.label ?? '图标素材规范'}
|
||||
onClick={() => setIsIconSpecMenuOpen((open) => !open)}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__icon-spec-preview"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{dialog.iconSpecReference?.src ? (
|
||||
<img src={dialog.iconSpecReference.src} alt="" />
|
||||
) : (
|
||||
<ImageIcon className="h-5 w-5" />
|
||||
)}
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
<span className="image-canvas-editor__character-spec-wrap">
|
||||
<button
|
||||
ref={iconSpecButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__icon-spec-card image-canvas-editor__reference-chip image-canvas-editor__reference-chip--icon image-canvas-editor__reference-chip--upload"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label={dialog.iconSpecReference?.label ?? '图标素材规范'}
|
||||
onClick={() => setIsIconSpecMenuOpen((open) => !open)}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-chip-icon image-canvas-editor__icon-spec-preview">
|
||||
{dialog.iconSpecReference?.src ? (
|
||||
<img
|
||||
src={dialog.iconSpecReference.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="image-canvas-editor__icon-spec-copy">
|
||||
<span className="image-canvas-editor__icon-spec-eyebrow">
|
||||
图标规范
|
||||
</span>
|
||||
<span className="image-canvas-editor__icon-spec-copy">
|
||||
<span className="image-canvas-editor__icon-spec-eyebrow">
|
||||
图标素材规范
|
||||
</span>
|
||||
<span className="image-canvas-editor__icon-spec-title">
|
||||
{dialog.iconSpecReference?.label ?? '待选择'}
|
||||
</span>
|
||||
<span className="image-canvas-editor__icon-spec-title">
|
||||
{dialog.iconSpecReference?.label ?? '待选择'}
|
||||
</span>
|
||||
<span className="image-canvas-editor__icon-spec-state">
|
||||
{dialog.iconSpecReference ? '已绑定' : '待绑定'}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
<span className="image-canvas-editor__icon-spec-state">
|
||||
{dialog.iconSpecReference ? '已绑定' : '待绑定'}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
{isIconSpecMenuOpen
|
||||
? renderEditorPortal(
|
||||
<PlatformFloatingMenu
|
||||
@@ -164,33 +158,6 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
</PlatformFloatingMenu>,
|
||||
)
|
||||
: null}
|
||||
<div
|
||||
className="image-canvas-editor__icon-spec-actions"
|
||||
aria-label="图标素材规范操作"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={pickIconSpecFromCanvas}
|
||||
>
|
||||
画布
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={openIconSpecDialog}
|
||||
>
|
||||
新建
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={requestIconSpecUpload}
|
||||
>
|
||||
上传
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="image-canvas-editor__field-block">
|
||||
<PlatformFieldLabel
|
||||
@@ -249,23 +216,20 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
>
|
||||
添加素材描述
|
||||
</button>
|
||||
</div>
|
||||
<div className="image-canvas-editor__generation-composer-footer">
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.icon}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
/>
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label="生成"
|
||||
>
|
||||
{dialog.status === 'generating' ? '生成中' : '生成'}
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
@@ -51,7 +52,80 @@ function renderPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function UiDesignHarness({
|
||||
initialDialog,
|
||||
}: {
|
||||
initialDialog: GenerateDialogState;
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<GenerateDialogState | null>(
|
||||
initialDialog,
|
||||
);
|
||||
const referenceButtonRef = createRef<HTMLButtonElement>();
|
||||
|
||||
return dialog ? (
|
||||
<ImageCanvasSpecGenerationPanelView
|
||||
dialog={dialog}
|
||||
style={{ left: 10, top: 20 }}
|
||||
generationReferenceButtonRef={referenceButtonRef}
|
||||
setGenerateDialog={setDialog}
|
||||
renderEditorPortal={(node) => node}
|
||||
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
|
||||
onUpdateSpecFormValue={vi.fn()}
|
||||
onRequestUpload={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
it('keeps the reference row above fields for spec panels and shows mud point text', () => {
|
||||
renderPanel({ dialog: createSpecDialog() });
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成规范' });
|
||||
expect(
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
const submitButton = screen.getByRole('button', { name: '提交生成规范' });
|
||||
expect(submitButton.textContent).toBe('生成5泥点');
|
||||
});
|
||||
|
||||
it('renders UI design as a single borderless prompt below the first reference row', () => {
|
||||
render(
|
||||
<UiDesignHarness
|
||||
initialDialog={{
|
||||
mode: 'ui-design',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
uiDesignSpecReference: null,
|
||||
imageModel: 'nanobanana2',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '1K',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成UI设计图' });
|
||||
const prompt = screen.getByRole('textbox', { name: 'UI设计要求' });
|
||||
|
||||
expect(
|
||||
panel.firstElementChild?.className.includes(
|
||||
'image-canvas-editor__reference-strip',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(panel.textContent).not.toContain('UI设计要求');
|
||||
expect(prompt.getAttribute('placeholder')).toBe(
|
||||
'你希望这个 UI 长什么样?',
|
||||
);
|
||||
expect(prompt.className).toContain(
|
||||
'image-canvas-editor__generation-prompt--borderless',
|
||||
);
|
||||
const submitButton = screen.getByRole('button', { name: '生成UI设计图' });
|
||||
expect(submitButton.textContent).toBe('生成12泥点');
|
||||
});
|
||||
|
||||
it('renders character spec fields and forwards updates', () => {
|
||||
const updateSpecFormValue = vi.fn();
|
||||
renderPanel({
|
||||
@@ -129,6 +203,14 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
|
||||
expect(submitSpec).toHaveBeenCalledWith(dialog);
|
||||
});
|
||||
|
||||
it('renders compact spec submit cost with mud point text', () => {
|
||||
renderPanel({ dialog: createSpecDialog() });
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: '提交生成规范' });
|
||||
|
||||
expect(submitButton.textContent).toBe('生成5泥点');
|
||||
});
|
||||
|
||||
it('disables controls while generating and renders failure state', () => {
|
||||
const submitSpec = vi.fn();
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
import { ClipboardList, ImagePlus } from 'lucide-react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../common/PlatformTextField';
|
||||
import {
|
||||
CHARACTER_SPEC_VIEW_OPTIONS,
|
||||
EDITOR_GENERATION_MUD_POINT_CONFIG,
|
||||
SPEC_GENERATION_COST,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import type {
|
||||
@@ -28,6 +29,7 @@ import type {
|
||||
SpecGenerationType,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
|
||||
type ImageCanvasSpecGenerationPanelViewProps = {
|
||||
dialog: GenerateDialogState;
|
||||
@@ -45,6 +47,7 @@ type ImageCanvasSpecGenerationPanelViewProps = {
|
||||
onOpenSpecDialog?: (specType: SpecGenerationType) => void;
|
||||
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
@@ -61,6 +64,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
onOpenSpecDialog,
|
||||
onUpdateSpecFormValue,
|
||||
onRequestUpload,
|
||||
onRememberImageModel = () => {},
|
||||
onSubmit,
|
||||
}: ImageCanvasSpecGenerationPanelViewProps) {
|
||||
const isUiDesignDialog = dialog.mode === 'ui-design';
|
||||
@@ -94,24 +98,49 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="image-canvas-editor__reference-strip">
|
||||
{(isUiDesignDialog || dialog.specType) ? (
|
||||
<button
|
||||
ref={generationReferenceButtonRef}
|
||||
type="button"
|
||||
className={[
|
||||
'image-canvas-editor__reference-chip',
|
||||
isUiDesignDialog
|
||||
? 'image-canvas-editor__reference-chip--ui'
|
||||
: 'image-canvas-editor__reference-chip--spec',
|
||||
'image-canvas-editor__reference-chip--upload',
|
||||
].join(' ')}
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={openReferenceMenu}
|
||||
aria-label={referenceLabel}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-chip-icon">
|
||||
{reference ? (
|
||||
<img src={reference.src} alt="" aria-hidden="true" />
|
||||
) : isUiDesignDialog ? (
|
||||
<ImagePlus className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ClipboardList className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-chip-label">
|
||||
{reference?.label ?? referenceLabel}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="image-canvas-editor__spec-fields">
|
||||
{isUiDesignDialog ? (
|
||||
<label className="image-canvas-editor__field-block">
|
||||
<PlatformFieldLabel
|
||||
variant="form"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
UI设计要求
|
||||
</PlatformFieldLabel>
|
||||
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
aria-label="UI设计要求"
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="描述要生成的UI界面"
|
||||
placeholder="你希望这个 UI 长什么样?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__generation-prompt--borderless"
|
||||
onChange={(event) => {
|
||||
const nextPrompt = event.target.value;
|
||||
if (setGenerateDialog) {
|
||||
@@ -254,35 +283,6 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{isUiDesignDialog || dialog.specType ? (
|
||||
<div className="image-canvas-editor__field-block image-canvas-editor__generation-ref">
|
||||
<PlatformFieldLabel
|
||||
variant="form"
|
||||
className="image-canvas-editor__field-title"
|
||||
>
|
||||
{referenceLabel}
|
||||
</PlatformFieldLabel>
|
||||
<button
|
||||
ref={generationReferenceButtonRef}
|
||||
type="button"
|
||||
className="image-canvas-editor__character-spec-ref image-canvas-editor__reference-tile image-canvas-editor__reference-tile--spec"
|
||||
disabled={dialog.status === 'generating'}
|
||||
onClick={openReferenceMenu}
|
||||
aria-label={referenceLabel}
|
||||
>
|
||||
<span className="image-canvas-editor__reference-tile-visual">
|
||||
{reference ? (
|
||||
<img src={reference.src} alt="" aria-hidden="true" />
|
||||
) : (
|
||||
<ImagePlus className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span className="image-canvas-editor__reference-tile-copy">
|
||||
{reference?.label ?? '添加参考图'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{dialog.status === 'failed' ? (
|
||||
<PlatformStatusMessage
|
||||
@@ -296,18 +296,44 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
<div className="image-canvas-editor__generation-composer-footer image-canvas-editor__spec-footer">
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label={isUiDesignDialog ? '生成UI设计图' : '提交生成规范'}
|
||||
>
|
||||
{dialog.status === 'generating'
|
||||
? '生成中'
|
||||
: `消耗${SPEC_GENERATION_COST}泥点 · 生成`}
|
||||
</PlatformActionButton>
|
||||
{isUiDesignDialog ? (
|
||||
<ImageCanvasGenerationImageOptionsView
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setGenerateDialog ?? (() => undefined)}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.uiDesign}
|
||||
submitLabel="生成"
|
||||
submitAriaLabel="生成UI设计图"
|
||||
optionLabelPrefix="生成图片"
|
||||
renderEditorPortal={renderEditorPortal}
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span aria-hidden="true" />
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit"
|
||||
disabled={dialog.status === 'generating'}
|
||||
aria-label="提交生成规范"
|
||||
>
|
||||
{dialog.status === 'generating' ? (
|
||||
'生成中'
|
||||
) : (
|
||||
<>
|
||||
<span>生成</span>
|
||||
<span className="image-canvas-editor__mud-point-inline">
|
||||
{SPEC_GENERATION_COST}泥点
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
{isGenerationReferenceMenuOpen && generationReferenceButtonRef
|
||||
|
||||
@@ -199,10 +199,14 @@ describe('ImageCanvasWorldView', () => {
|
||||
placeholder: undefined,
|
||||
}),
|
||||
],
|
||||
generateDialog: dialog,
|
||||
});
|
||||
|
||||
const frame = screen.getByRole('button', { name: '图标素材生成占位图' });
|
||||
|
||||
expect(frame.className).toContain(
|
||||
'image-canvas-editor__generation-frame--focused',
|
||||
);
|
||||
expect(within(frame).getByText('Icon Generator')).toBeTruthy();
|
||||
expect(within(frame).getByText('图标')).toBeTruthy();
|
||||
expect(within(frame).getByText('1024 x 768')).toBeTruthy();
|
||||
@@ -218,4 +222,90 @@ describe('ImageCanvasWorldView', () => {
|
||||
expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog);
|
||||
expect(screen.queryByText('dialog-without-placeholder')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides idle placeholder chrome after blur and restores it when focused', () => {
|
||||
const dialog = createGenerationDialog({
|
||||
id: 'dialog-idle',
|
||||
mode: 'generate',
|
||||
status: 'idle',
|
||||
});
|
||||
|
||||
const { rerender, props } = renderWorldView({
|
||||
canvasGenerationDialogs: [dialog],
|
||||
generateDialog: null,
|
||||
});
|
||||
|
||||
const blurredFrame = screen.getByRole('button', { name: '图像生成占位图' });
|
||||
expect(blurredFrame.className).not.toContain(
|
||||
'image-canvas-editor__generation-frame--focused',
|
||||
);
|
||||
expect(within(blurredFrame).queryByText('Image Generator')).toBeNull();
|
||||
expect(within(blurredFrame).queryByText('1024 x 768')).toBeNull();
|
||||
|
||||
rerender(<ImageCanvasWorldView {...props} generateDialog={dialog} />);
|
||||
|
||||
const focusedFrame = screen.getByRole('button', { name: '图像生成占位图' });
|
||||
expect(focusedFrame.className).toContain(
|
||||
'image-canvas-editor__generation-frame--focused',
|
||||
);
|
||||
expect(within(focusedFrame).getByText('Image Generator')).toBeTruthy();
|
||||
expect(within(focusedFrame).getByText('1024 x 768')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps layer and generation chrome readable when the canvas is zoomed out', () => {
|
||||
const layer = createLayer({ assetKind: 'character' });
|
||||
const dialog = createGenerationDialog({ id: 'dialog-focused' });
|
||||
|
||||
renderWorldView({
|
||||
viewport: { x: 0, y: 0, scale: 0.25 },
|
||||
layers: [layer],
|
||||
hoveredLayerId: layer.id,
|
||||
canvasGenerationDialogs: [dialog],
|
||||
generateDialog: dialog,
|
||||
});
|
||||
|
||||
const layerButton = screen.getByRole('button', { name: '选择角色主图' });
|
||||
const frame = screen.getByRole('button', { name: '图像生成占位图' });
|
||||
const inverseScale = '4';
|
||||
|
||||
expect(
|
||||
(within(layerButton).getByText('角色') as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
(within(layerButton).getByText('640 x 480 px') as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
within(layerButton)
|
||||
.getByRole('button', { name: '查看角色主图图片信息' })
|
||||
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
(within(frame).getByText('Image Generator') as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
(within(frame).getByText('1024 x 768') as HTMLElement).style.getPropertyValue(
|
||||
'--image-canvas-editor-inverse-scale',
|
||||
),
|
||||
).toBe(inverseScale);
|
||||
});
|
||||
|
||||
it('uses a plain information icon for metadata corner actions', () => {
|
||||
const layer = createLayer();
|
||||
renderWorldView({ layers: [layer] });
|
||||
|
||||
const metadataButton = screen.getByRole('button', {
|
||||
name: '查看角色主图图片信息',
|
||||
});
|
||||
|
||||
expect(metadataButton.querySelector('svg')?.getAttribute('class')).toContain(
|
||||
'lucide-info',
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Braces, ImageIcon } from 'lucide-react';
|
||||
import { ImageIcon, Info } from 'lucide-react';
|
||||
import type {
|
||||
CSSProperties,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
@@ -24,6 +24,25 @@ import {
|
||||
getLayerKindLabel,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
function getInverseViewportScale(viewport: CanvasViewport) {
|
||||
if (!Number.isFinite(viewport.scale) || viewport.scale <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return 1 / viewport.scale;
|
||||
}
|
||||
|
||||
function buildInverseScaleStyle(
|
||||
inverseScale: number,
|
||||
): CSSProperties & Record<string, string> {
|
||||
return {
|
||||
'--image-canvas-editor-inverse-scale': String(inverseScale),
|
||||
'--image-canvas-editor-corner-offset': `calc(0.35rem * ${inverseScale})`,
|
||||
'--image-canvas-editor-kind-offset': `calc(0.38rem * ${inverseScale})`,
|
||||
'--image-canvas-editor-beside-kind-offset': `calc(3.95rem * ${inverseScale})`,
|
||||
'--image-canvas-editor-frame-label-offset': `calc(-1.35rem * ${inverseScale})`,
|
||||
};
|
||||
}
|
||||
|
||||
export type ImageCanvasWorldViewProps = {
|
||||
viewport: CanvasViewport;
|
||||
snapGuide: SnapGuide | null;
|
||||
@@ -77,6 +96,9 @@ export function ImageCanvasWorldView({
|
||||
onGenerationFramePointerDown,
|
||||
onActivateGenerationDialog,
|
||||
}: ImageCanvasWorldViewProps) {
|
||||
const inverseScale = getInverseViewportScale(viewport);
|
||||
const inverseScaleStyle = buildInverseScaleStyle(inverseScale);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="image-canvas-editor__world"
|
||||
@@ -153,6 +175,7 @@ export function ImageCanvasWorldView({
|
||||
{kindLabel ? (
|
||||
<span
|
||||
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${layer.assetKind}`}
|
||||
style={inverseScaleStyle}
|
||||
>
|
||||
{kindLabel}
|
||||
</span>
|
||||
@@ -166,7 +189,8 @@ export function ImageCanvasWorldView({
|
||||
: ''
|
||||
}`}
|
||||
label={`查看${layer.title}图片信息`}
|
||||
icon={<Braces className="h-3 w-3" />}
|
||||
icon={<Info className="h-3 w-3" />}
|
||||
style={inverseScaleStyle}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenLayerMetadata(layer);
|
||||
@@ -178,6 +202,7 @@ export function ImageCanvasWorldView({
|
||||
tone="lightOverlay"
|
||||
size="xs"
|
||||
className="image-canvas-editor__size-badge"
|
||||
style={inverseScaleStyle}
|
||||
>
|
||||
{Math.round(layer.originalWidth)} x{' '}
|
||||
{Math.round(layer.originalHeight)} px
|
||||
@@ -217,66 +242,99 @@ export function ImageCanvasWorldView({
|
||||
/>
|
||||
) : null}
|
||||
{canvasGenerationDialogs.map((dialog) =>
|
||||
dialog.placeholder ? (
|
||||
<div
|
||||
key={dialog.id}
|
||||
className={`image-canvas-editor__generation-frame ${
|
||||
dialog.mode === 'icon'
|
||||
? 'image-canvas-editor__generation-frame--icon'
|
||||
: ''
|
||||
} ${
|
||||
dialog.status === 'generating'
|
||||
? 'image-canvas-editor__generation-frame--generating'
|
||||
: ''
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{
|
||||
left: dialog.placeholder.x,
|
||||
top: dialog.placeholder.y,
|
||||
width: dialog.placeholder.width,
|
||||
height: dialog.placeholder.height,
|
||||
}}
|
||||
aria-label={getGenerationFrameAriaLabel(dialog)}
|
||||
onPointerDown={(event) => onGenerationFramePointerDown(event, dialog)}
|
||||
onDoubleClick={() => onActivateGenerationDialog(dialog)}
|
||||
>
|
||||
<span className="image-canvas-editor__generation-frame-label">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{getGenerationFrameLabel(dialog)}
|
||||
</span>
|
||||
{dialog.mode === 'character' ? (
|
||||
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--character">
|
||||
角色
|
||||
</span>
|
||||
) : null}
|
||||
{dialog.mode === 'spec' ? (
|
||||
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--spec">
|
||||
规范
|
||||
</span>
|
||||
) : null}
|
||||
{dialog.mode === 'icon' ? (
|
||||
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--icon">
|
||||
图标
|
||||
</span>
|
||||
) : null}
|
||||
<span className="image-canvas-editor__generation-frame-size">
|
||||
{dialog.placeholder.originalWidth} x{' '}
|
||||
{dialog.placeholder.originalHeight}
|
||||
</span>
|
||||
<span className="image-canvas-editor__generation-frame-icon">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
</span>
|
||||
{dialog.status === 'generating' ? (
|
||||
<span
|
||||
className="image-canvas-editor__generation-frame-progress"
|
||||
role="status"
|
||||
>
|
||||
生成中
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null,
|
||||
dialog.placeholder
|
||||
? (() => {
|
||||
const isFocused = generateDialog?.id === dialog.id;
|
||||
const showFocusedChrome =
|
||||
isFocused || dialog.status === 'generating';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dialog.id}
|
||||
className={`image-canvas-editor__generation-frame ${
|
||||
dialog.mode === 'icon'
|
||||
? 'image-canvas-editor__generation-frame--icon'
|
||||
: ''
|
||||
} ${
|
||||
dialog.status === 'generating'
|
||||
? 'image-canvas-editor__generation-frame--generating'
|
||||
: ''
|
||||
} ${
|
||||
isFocused
|
||||
? 'image-canvas-editor__generation-frame--focused'
|
||||
: ''
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{
|
||||
left: dialog.placeholder.x,
|
||||
top: dialog.placeholder.y,
|
||||
width: dialog.placeholder.width,
|
||||
height: dialog.placeholder.height,
|
||||
}}
|
||||
aria-label={getGenerationFrameAriaLabel(dialog)}
|
||||
onPointerDown={(event) =>
|
||||
onGenerationFramePointerDown(event, dialog)
|
||||
}
|
||||
onDoubleClick={() => onActivateGenerationDialog(dialog)}
|
||||
>
|
||||
{showFocusedChrome ? (
|
||||
<span
|
||||
className="image-canvas-editor__generation-frame-label"
|
||||
style={inverseScaleStyle}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{getGenerationFrameLabel(dialog)}
|
||||
</span>
|
||||
) : null}
|
||||
{dialog.mode === 'character' ? (
|
||||
<span
|
||||
className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--character"
|
||||
style={inverseScaleStyle}
|
||||
>
|
||||
角色
|
||||
</span>
|
||||
) : null}
|
||||
{dialog.mode === 'spec' ? (
|
||||
<span
|
||||
className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--spec"
|
||||
style={inverseScaleStyle}
|
||||
>
|
||||
规范
|
||||
</span>
|
||||
) : null}
|
||||
{dialog.mode === 'icon' ? (
|
||||
<span
|
||||
className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--icon"
|
||||
style={inverseScaleStyle}
|
||||
>
|
||||
图标
|
||||
</span>
|
||||
) : null}
|
||||
{showFocusedChrome ? (
|
||||
<span
|
||||
className="image-canvas-editor__generation-frame-size"
|
||||
style={inverseScaleStyle}
|
||||
>
|
||||
{dialog.placeholder.originalWidth} x{' '}
|
||||
{dialog.placeholder.originalHeight}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="image-canvas-editor__generation-frame-icon">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
</span>
|
||||
{dialog.status === 'generating' ? (
|
||||
<span
|
||||
className="image-canvas-editor__generation-frame-progress"
|
||||
role="status"
|
||||
>
|
||||
生成中
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null,
|
||||
)}
|
||||
{(generateDialog?.mode === 'generate' ||
|
||||
generateDialog?.mode === 'spec' ||
|
||||
|
||||
@@ -180,6 +180,9 @@ function GenerationWorkflowHarness({
|
||||
<button type="button" onClick={() => workflow.openSpecDialog('ui')}>
|
||||
打开UI规范
|
||||
</button>
|
||||
<button type="button" onClick={workflow.openVideoGenerationDialog}>
|
||||
打开视频生成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -356,6 +359,31 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect(screen.getByTestId('viewport').textContent).toBe('934:20:2');
|
||||
});
|
||||
|
||||
it('places a new video generation placeholder away from existing canvas images and centers the viewport on it', () => {
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
id: 'center-layer',
|
||||
x: 0,
|
||||
y: -80,
|
||||
width: 460,
|
||||
height: 460,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开视频生成' }));
|
||||
|
||||
expect(screen.getByTestId('tool').textContent).toBe('video');
|
||||
expect(screen.getByTestId('selected').textContent).toBe('-');
|
||||
expect(screen.getByTestId('placeholder').textContent).toBe(
|
||||
'-60:412:560:315',
|
||||
);
|
||||
expect(screen.getByTestId('viewport').textContent).toBe('10:-819:2');
|
||||
});
|
||||
|
||||
it('submits a normal generation, appends the generated layer, and keeps the composer anchored', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ prompt: '一张生成图' }),
|
||||
@@ -368,6 +396,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
prompt: '一张生成图',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
});
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:generating:closed:-:placeholder',
|
||||
@@ -406,6 +437,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
await waitFor(() => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith({
|
||||
prompt: '一张生成图',
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
referenceImageSrcs: ['data:image/png;base64,source'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,59 +173,92 @@ export function useImageCanvasGenerationWorkflow({
|
||||
? (generateDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS)
|
||||
: DEFAULT_ICON_DESCRIPTIONS;
|
||||
|
||||
const openGenerateDialog = useCallback(() => {
|
||||
const draft = createGenerateDialogDraft({ canvasSize, viewport });
|
||||
const draftPlaceholder = draft.placeholder;
|
||||
if (!draftPlaceholder) {
|
||||
return;
|
||||
}
|
||||
const placement = chooseGenerationPlacement({
|
||||
canvasSize,
|
||||
viewport,
|
||||
frame: draftPlaceholder,
|
||||
layers,
|
||||
generationDialogs: canvasGenerationDialogs,
|
||||
}) ?? draftPlaceholder;
|
||||
openCanvasGenerationDialog({
|
||||
...draft,
|
||||
placeholder: placement,
|
||||
});
|
||||
setViewport(
|
||||
centerViewportOnPlacement({
|
||||
const closeGenerationTransientState = useCallback(() => {
|
||||
setIsSpecMenuOpen(false);
|
||||
setIsGenerationReferenceMenuOpen(false);
|
||||
setIsCharacterSpecMenuOpen(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
setIsPickingGenerationReferenceFromCanvas(false);
|
||||
setIsPickingCharacterSpecFromCanvas(false);
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
setIsIconSpecMenuOpen(false);
|
||||
setIsPickingIconSpecFromCanvas(false);
|
||||
setIsUiDesignSpecMenuOpen(false);
|
||||
setIsPickingUiDesignSpecFromCanvas(false);
|
||||
setImageContextMenu(null);
|
||||
}, [setImageContextMenu]);
|
||||
|
||||
const openPlacedCanvasGenerationDialog = useCallback(
|
||||
(draft: Omit<CanvasGenerationDialogState, 'id'>) => {
|
||||
const draftPlaceholder = draft.placeholder;
|
||||
if (!draftPlaceholder) {
|
||||
openCanvasGenerationDialog(draft);
|
||||
return;
|
||||
}
|
||||
// 中文注释:所有画布生成入口统一先走 placement 模型,避免新占位压住已有图层或生成占位。
|
||||
const placement = chooseGenerationPlacement({
|
||||
canvasSize,
|
||||
viewport,
|
||||
placement,
|
||||
}),
|
||||
frame: draftPlaceholder,
|
||||
layers,
|
||||
generationDialogs: canvasGenerationDialogs,
|
||||
});
|
||||
openCanvasGenerationDialog({
|
||||
...draft,
|
||||
placeholder: placement,
|
||||
});
|
||||
setViewport(
|
||||
centerViewportOnPlacement({
|
||||
canvasSize,
|
||||
viewport,
|
||||
placement,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[
|
||||
canvasGenerationDialogs,
|
||||
canvasSize,
|
||||
layers,
|
||||
openCanvasGenerationDialog,
|
||||
setViewport,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
|
||||
const activateCanvasGenerationEntry = useCallback(
|
||||
(activeTool: CanvasTool) => {
|
||||
closeGenerationTransientState();
|
||||
setActiveTool(activeTool);
|
||||
selectSingleLayer(null);
|
||||
setQuickEditPanel(null);
|
||||
setCharacterAnimationPanel(null);
|
||||
},
|
||||
[closeGenerationTransientState, selectSingleLayer, setActiveTool],
|
||||
);
|
||||
|
||||
const openGenerateDialog = useCallback(() => {
|
||||
openPlacedCanvasGenerationDialog(
|
||||
createGenerateDialogDraft({ canvasSize, viewport }),
|
||||
);
|
||||
setActiveTool('generate');
|
||||
selectSingleLayer(null);
|
||||
setQuickEditPanel(null);
|
||||
activateCanvasGenerationEntry('generate');
|
||||
}, [
|
||||
activateCanvasGenerationEntry,
|
||||
canvasSize,
|
||||
canvasGenerationDialogs,
|
||||
layers,
|
||||
openCanvasGenerationDialog,
|
||||
selectSingleLayer,
|
||||
setActiveTool,
|
||||
setViewport,
|
||||
openPlacedCanvasGenerationDialog,
|
||||
viewport,
|
||||
]);
|
||||
|
||||
const openSpecDialog = useCallback(
|
||||
(specType: SpecGenerationType) => {
|
||||
openCanvasGenerationDialog(
|
||||
openPlacedCanvasGenerationDialog(
|
||||
createSpecDialogDraft({ canvasSize, viewport, specType }),
|
||||
);
|
||||
setIsSpecMenuOpen(false);
|
||||
setActiveTool('generate');
|
||||
selectSingleLayer(null);
|
||||
setQuickEditPanel(null);
|
||||
activateCanvasGenerationEntry('generate');
|
||||
},
|
||||
[
|
||||
activateCanvasGenerationEntry,
|
||||
canvasSize,
|
||||
openCanvasGenerationDialog,
|
||||
selectSingleLayer,
|
||||
setActiveTool,
|
||||
openPlacedCanvasGenerationDialog,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
@@ -245,116 +278,64 @@ export function useImageCanvasGenerationWorkflow({
|
||||
);
|
||||
|
||||
const openCharacterGenerationDialog = useCallback(() => {
|
||||
setIsSpecMenuOpen(false);
|
||||
setIsGenerationReferenceMenuOpen(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
setIsPickingGenerationReferenceFromCanvas(false);
|
||||
setIsPickingCharacterSpecFromCanvas(false);
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
setIsUiDesignSpecMenuOpen(false);
|
||||
setIsPickingUiDesignSpecFromCanvas(false);
|
||||
openCanvasGenerationDialog(
|
||||
openPlacedCanvasGenerationDialog(
|
||||
createCharacterGenerationDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
imageModel: lastImageModel,
|
||||
}),
|
||||
);
|
||||
setActiveTool('character');
|
||||
selectSingleLayer(null);
|
||||
setQuickEditPanel(null);
|
||||
activateCanvasGenerationEntry('character');
|
||||
}, [
|
||||
activateCanvasGenerationEntry,
|
||||
canvasSize,
|
||||
lastImageModel,
|
||||
openCanvasGenerationDialog,
|
||||
selectSingleLayer,
|
||||
setActiveTool,
|
||||
openPlacedCanvasGenerationDialog,
|
||||
viewport,
|
||||
]);
|
||||
|
||||
const openIconGenerationDialog = useCallback(() => {
|
||||
setIsSpecMenuOpen(false);
|
||||
setIsGenerationReferenceMenuOpen(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
setIsPickingGenerationReferenceFromCanvas(false);
|
||||
setIsPickingCharacterSpecFromCanvas(false);
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
setIsUiDesignSpecMenuOpen(false);
|
||||
setIsPickingUiDesignSpecFromCanvas(false);
|
||||
setIsPickingIconSpecFromCanvas(false);
|
||||
openCanvasGenerationDialog(
|
||||
openPlacedCanvasGenerationDialog(
|
||||
createIconGenerationDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
imageModel: lastImageModel,
|
||||
}),
|
||||
);
|
||||
setActiveTool('icon');
|
||||
selectSingleLayer(null);
|
||||
setQuickEditPanel(null);
|
||||
setCharacterAnimationPanel(null);
|
||||
activateCanvasGenerationEntry('icon');
|
||||
}, [
|
||||
activateCanvasGenerationEntry,
|
||||
canvasSize,
|
||||
lastImageModel,
|
||||
openCanvasGenerationDialog,
|
||||
selectSingleLayer,
|
||||
setActiveTool,
|
||||
openPlacedCanvasGenerationDialog,
|
||||
viewport,
|
||||
]);
|
||||
|
||||
const openVideoGenerationDialog = useCallback(() => {
|
||||
setIsSpecMenuOpen(false);
|
||||
setIsGenerationReferenceMenuOpen(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
setIsPickingGenerationReferenceFromCanvas(false);
|
||||
setIsPickingCharacterSpecFromCanvas(false);
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
setIsIconSpecMenuOpen(false);
|
||||
setIsPickingIconSpecFromCanvas(false);
|
||||
setIsUiDesignSpecMenuOpen(false);
|
||||
setIsPickingUiDesignSpecFromCanvas(false);
|
||||
openCanvasGenerationDialog(
|
||||
openPlacedCanvasGenerationDialog(
|
||||
createVideoGenerationDialogDraft({ canvasSize, viewport }),
|
||||
);
|
||||
setActiveTool('video');
|
||||
selectSingleLayer(null);
|
||||
setQuickEditPanel(null);
|
||||
setCharacterAnimationPanel(null);
|
||||
activateCanvasGenerationEntry('video');
|
||||
}, [
|
||||
activateCanvasGenerationEntry,
|
||||
canvasSize,
|
||||
openCanvasGenerationDialog,
|
||||
selectSingleLayer,
|
||||
setActiveTool,
|
||||
openPlacedCanvasGenerationDialog,
|
||||
viewport,
|
||||
]);
|
||||
|
||||
const openUiDesignGenerationDialog = useCallback(() => {
|
||||
setIsSpecMenuOpen(false);
|
||||
setIsGenerationReferenceMenuOpen(false);
|
||||
setIsCharacterReferenceMenuOpen(false);
|
||||
setIsPickingGenerationReferenceFromCanvas(false);
|
||||
setIsPickingCharacterSpecFromCanvas(false);
|
||||
setIsPickingCharacterReferenceFromCanvas(false);
|
||||
setIsIconSpecMenuOpen(false);
|
||||
setIsPickingIconSpecFromCanvas(false);
|
||||
setIsUiDesignSpecMenuOpen(false);
|
||||
setIsPickingUiDesignSpecFromCanvas(false);
|
||||
openCanvasGenerationDialog(
|
||||
openPlacedCanvasGenerationDialog(
|
||||
createUiDesignGenerationDialogDraft({
|
||||
canvasSize,
|
||||
viewport,
|
||||
imageModel: 'gpt-image-2',
|
||||
}),
|
||||
);
|
||||
setActiveTool('ui-design');
|
||||
selectSingleLayer(null);
|
||||
setQuickEditPanel(null);
|
||||
setCharacterAnimationPanel(null);
|
||||
activateCanvasGenerationEntry('ui-design');
|
||||
}, [
|
||||
activateCanvasGenerationEntry,
|
||||
canvasSize,
|
||||
openCanvasGenerationDialog,
|
||||
selectSingleLayer,
|
||||
setActiveTool,
|
||||
openPlacedCanvasGenerationDialog,
|
||||
viewport,
|
||||
]);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user