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:
2026-06-18 11:40:19 +08:00
parent 17768119ea
commit 8956ed6fe6
29 changed files with 2292 additions and 1010 deletions
@@ -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