完善画布生成面板交互

补齐普通生图参考图来源菜单和画布选择流程

接入UI设计图与视频生成面板的提交链路

让生成引用上传目标支持多种生成面板

统一图片信息弹窗断言并补充相关测试

修复图标按钮浮层锚点ref与视频生成类型契约
This commit is contained in:
2026-06-17 21:49:32 +08:00
parent 2d90a30b8b
commit 6d964937db
21 changed files with 914 additions and 85 deletions

View File

@@ -1,9 +1,11 @@
import { forwardRef } from 'react';
import type {
ButtonHTMLAttributes,
HTMLAttributes,
KeyboardEvent,
LabelHTMLAttributes,
ReactNode,
Ref,
} from 'react';
type PlatformIconButtonBaseProps = {
@@ -52,7 +54,11 @@ type PlatformIconButtonProps =
* 平台通用图标动作按钮。
* 统一承接纯图标动作、图标上传 label 和带短标签的浮动图标动作。
*/
export function PlatformIconButton({
export const PlatformIconButton = forwardRef<
HTMLButtonElement | HTMLLabelElement | HTMLSpanElement,
PlatformIconButtonProps
>(function PlatformIconButton(
{
label,
icon,
children,
@@ -61,7 +67,9 @@ export function PlatformIconButton({
className,
asChild,
...actionProps
}: PlatformIconButtonProps) {
},
ref,
) {
const variantClassName = {
platformIcon: 'platform-icon-button',
surfaceFloating:
@@ -78,6 +86,7 @@ export function PlatformIconButton({
return (
<label
{...(actionProps as LabelHTMLAttributes<HTMLLabelElement>)}
ref={ref as Ref<HTMLLabelElement>}
title={title}
className={actionClassName}
>
@@ -112,6 +121,7 @@ export function PlatformIconButton({
return (
<span
{...spanProps}
ref={ref as Ref<HTMLSpanElement>}
aria-disabled={disabled}
aria-label={label}
className={actionClassName}
@@ -133,6 +143,7 @@ export function PlatformIconButton({
return (
<button
{...buttonProps}
ref={ref as Ref<HTMLButtonElement>}
type={type}
aria-label={label}
title={title}
@@ -142,4 +153,4 @@ export function PlatformIconButton({
{children}
</button>
);
}
});

View File

@@ -77,7 +77,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示');
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
expect(requestUpload).toHaveBeenCalledWith('asset');
expect(requestUpload).not.toHaveBeenCalled();
expect(submitGeneration).toHaveBeenCalledWith(
expect.objectContaining({ prompt: '新的提示', status: 'idle' }),
);

View File

@@ -1,7 +1,17 @@
import { ChevronDown, ImageIcon, X } from 'lucide-react';
import { type CSSProperties, type Dispatch, type SetStateAction } from 'react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
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';
@@ -9,14 +19,23 @@ import { PlatformTextField } from '../common/PlatformTextField';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type {
GenerateDialogState,
UploadTarget,
} from './ImageCanvasEditorTypes';
type ImageCanvasBasicGenerationComposerViewProps = {
dialog: GenerateDialogState;
style: CSSProperties;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
onRequestUpload: (target: UploadTarget) => void;
generationReferenceButtonRef?: RefObject<HTMLButtonElement | null>;
isGenerationReferenceMenuOpen?: boolean;
setIsGenerationReferenceMenuOpen?: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas?: Dispatch<SetStateAction<boolean>>;
renderEditorPortal?: (node: ReactNode) => ReactNode;
buildPortalMenuStyle?: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
onRequestUpload: (target: 'generation-reference') => void;
onToggleReferenceMenu?: () => void;
onSubmit: (dialog: GenerateDialogState) => void;
onClose: () => void;
};
@@ -37,13 +56,21 @@ export function ImageCanvasBasicGenerationComposerView({
dialog,
style,
setGenerateDialog,
generationReferenceButtonRef,
isGenerationReferenceMenuOpen = false,
setIsGenerationReferenceMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
renderEditorPortal = (node) => node,
buildPortalMenuStyle = () => ({}),
onRequestUpload,
onToggleReferenceMenu,
onSubmit,
onClose,
}: ImageCanvasBasicGenerationComposerViewProps) {
return (
<>
<form
className="image-canvas-editor__generation-composer"
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image"
style={style}
role="dialog"
aria-label="生成图片"
@@ -56,11 +83,12 @@ export function ImageCanvasBasicGenerationComposerView({
}}
>
<PlatformIconButton
ref={generationReferenceButtonRef}
variant="surfaceFloating"
className="image-canvas-editor__generation-ref"
label="添加参考图"
disabled={dialog.status === 'generating'}
onClick={() => onRequestUpload('asset')}
onClick={() => onToggleReferenceMenu?.()}
icon={<ImageIcon className="h-4 w-4" />}
>
<span></span>
@@ -147,5 +175,37 @@ export function ImageCanvasBasicGenerationComposerView({
onClick={onClose}
/>
</form>
{isGenerationReferenceMenuOpen && generationReferenceButtonRef
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
label="参考图来源"
placement="top-start"
style={buildPortalMenuStyle(
generationReferenceButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen?.(false);
setIsPickingGenerationReferenceFromCanvas?.(true);
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen?.(false);
setIsPickingGenerationReferenceFromCanvas?.(false);
onRequestUpload('generation-reference');
}}
>
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</>
);
}

View File

@@ -173,9 +173,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(metadataButtons[0]).toBeTruthy();
fireEvent.click(metadataButtons[0]!);
const infoPanel = screen.getByRole('dialog', {
name: /生成图片 .*图片信息/,
});
const infoPanel = screen.getByRole('dialog', { name: '图片信息' });
expect(within(infoPanel).queryByText('Prompt')).toBeNull();
expect(
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
@@ -1452,7 +1450,7 @@ describe('ImageCanvasEditorView generation integration', () => {
})[0]!,
);
const characterInfoPanel = screen.getByRole('dialog', {
name: / .*/u,
name: '图片信息',
});
expect(within(characterInfoPanel).queryByText('Prompt')).toBeNull();
expect(within(characterInfoPanel).getByText('生成输入')).toBeTruthy();
@@ -1679,9 +1677,7 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.click(
screen.getAllByRole('button', { name: '查看返回按钮图片信息' })[0]!,
);
const iconInfoPanel = screen.getByRole('dialog', {
name: '返回按钮图片信息',
});
const iconInfoPanel = screen.getByRole('dialog', { name: '图片信息' });
expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull();
expect(within(iconInfoPanel).getByText('生成输入')).toBeTruthy();
expect(within(iconInfoPanel).getByText('素材描述 1')).toBeTruthy();
@@ -2216,9 +2212,7 @@ describe('ImageCanvasEditorView generation integration', () => {
);
fireEvent.click(metadataCornerButton);
const metadataDialog = screen.getByRole('dialog', {
name: /生成图片 .*图片信息/,
});
const metadataDialog = screen.getByRole('dialog', { name: '图片信息' });
expect(metadataDialog).toBeTruthy();
expect(within(metadataDialog).getByText('图片类型')).toBeTruthy();
expect(within(metadataDialog).getByText('生成图片')).toBeTruthy();
@@ -2266,7 +2260,7 @@ describe('ImageCanvasEditorView generation integration', () => {
})[0]!,
);
const editedMetadataDialog = screen.getByRole('dialog', {
name: / .* /u,
name: '图片信息',
});
expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull();
expect(within(editedMetadataDialog).getByText('修改要求')).toBeTruthy();

View File

@@ -225,7 +225,7 @@ describe('ImageCanvasEditorShellView', () => {
expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy();
expect(screen.getByLabelText('画布工作区')).toBeTruthy();
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
expect(screen.getByRole('dialog', { name: '测试图片图片信息' })).toBeTruthy();
expect(screen.getByRole('dialog', { name: '图片信息' })).toBeTruthy();
const uploadInput = screen.getByLabelText('上传图片文件') as HTMLInputElement;
fireEvent.change(uploadInput);

View File

@@ -3,6 +3,7 @@ import type {
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
EditorVideoModel,
} from '../../services/image-editor/editorProjectClient';
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
@@ -159,9 +160,9 @@ export type GenerateDialogState = {
iconDescriptions?: string[];
uiDesignSpecReference?: CharacterReferenceImage | null;
imageModel?: string;
videoModel?: string;
videoModel?: EditorVideoModel;
videoAspectRatio?: string;
videoResolution?: string;
videoResolution?: '480p' | '720p';
videoDurationSeconds?: 4 | 5;
videoMode?: 'std';
videoSound?: 'off';

View File

@@ -489,7 +489,7 @@ describe('ImageCanvasEditorView', () => {
);
fireEvent.click(infoButton);
const infoPanel = screen.getByRole('dialog', { name: '拼图素材图片信息' });
const infoPanel = screen.getByRole('dialog', { name: '图片信息' });
expect(within(infoPanel).getByText('图片类型')).toBeTruthy();
expect(within(infoPanel).getByText('上传图片')).toBeTruthy();
expect(within(infoPanel).getByText('生成输入')).toBeTruthy();
@@ -547,9 +547,7 @@ describe('ImageCanvasEditorView', () => {
fireEvent.click(
screen.getAllByRole('button', { name: '查看旧布局图片图片信息' })[0]!,
);
const infoPanel = screen.getByRole('dialog', {
name: '旧布局图片图片信息',
});
const infoPanel = screen.getByRole('dialog', { name: '图片信息' });
expect(within(infoPanel).queryByText('Size')).toBeNull();
expect(within(infoPanel).getByText('Resolution')).toBeTruthy();
expect(within(infoPanel).getByText('1536 x 1024 px')).toBeTruthy();

View File

@@ -49,6 +49,7 @@ export function ImageCanvasEditorView() {
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const selectedLayerIdRef = useRef<string | null>(null);
const selectedLayerIdsRef = useRef<string[]>([]);
@@ -350,14 +351,17 @@ export function ImageCanvasEditorView() {
layers,
canvasSize,
viewport,
setViewport,
layerCounterRef,
specToolWrapRef,
characterSpecButtonRef,
characterReferenceButtonRef,
generationReferenceButtonRef,
iconSpecButtonRef,
generateDialog,
setGenerateDialog,
activeCanvasGenerationDialog,
canvasGenerationDialogs,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
@@ -376,8 +380,11 @@ export function ImageCanvasEditorView() {
quickEditPanel,
setQuickEditPanel,
setIsSpecMenuOpen,
setIsGenerationReferenceMenuOpen,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
isPickingGenerationReferenceFromCanvas,
setIsPickingGenerationReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
@@ -385,12 +392,17 @@ export function ImageCanvasEditorView() {
setIsIconSpecMenuOpen,
isPickingIconSpecFromCanvas,
setIsPickingIconSpecFromCanvas,
setIsUiDesignSpecMenuOpen,
isPickingUiDesignSpecFromCanvas,
setIsPickingUiDesignSpecFromCanvas,
openCharacterAnimationPanel,
openEditDialog,
openQuickEditPanel,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
hideGeneratedLayerPanelAfterBlur,
clearDeletedLayerGenerationState,
generationComposerStyle,
@@ -482,12 +494,16 @@ export function ImageCanvasEditorView() {
generateDialog,
setGenerateDialog,
isPickingCharacterSpecFromCanvas,
isPickingGenerationReferenceFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingUiDesignSpecFromCanvas,
clearCanvasFocus,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
moveViewportFromMinimapPointer,
@@ -514,12 +530,16 @@ export function ImageCanvasEditorView() {
setQuickEditPanel,
closeEditorChromePanels,
setIsSpecMenuOpen,
setIsGenerationReferenceMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsPickingCharacterSpecFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsIconSpecMenuOpen,
setIsPickingIconSpecFromCanvas,
setIsUiDesignSpecMenuOpen,
setIsPickingUiDesignSpecFromCanvas,
setIsSpacePanning,
setShiftPressed,
});

View File

@@ -1,3 +1,4 @@
import { ImageIcon } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
@@ -11,6 +12,9 @@ import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
@@ -18,7 +22,10 @@ import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationM
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
import { SPEC_TYPE_LABEL } from './ImageCanvasGenerationModel';
import {
SPEC_TYPE_LABEL,
calculateEditorVideoPrice,
} from './ImageCanvasGenerationModel';
import type {
CharacterAnimationPanelState,
CanvasLayer,
@@ -33,14 +40,19 @@ type ImageCanvasGenerationComposerViewProps = {
specToolWrapRef: RefObject<HTMLSpanElement | null>;
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
generationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
isSpecMenuOpen: boolean;
isGenerationReferenceMenuOpen: boolean;
isCharacterSpecMenuOpen: boolean;
isCharacterReferenceMenuOpen: boolean;
isIconSpecMenuOpen: boolean;
isUiDesignSpecMenuOpen: boolean;
isPickingGenerationReferenceFromCanvas: boolean;
isPickingCharacterSpecFromCanvas: boolean;
isPickingCharacterReferenceFromCanvas: boolean;
isPickingIconSpecFromCanvas: boolean;
isPickingUiDesignSpecFromCanvas: boolean;
generateDialog: GenerateDialogState | null;
generationComposerStyle: CSSProperties | null;
iconComposerStyle: CSSProperties | null;
@@ -58,12 +70,16 @@ type ImageCanvasGenerationComposerViewProps = {
setCharacterAnimationPanel: Dispatch<
SetStateAction<CharacterAnimationPanelState | null>
>;
setIsGenerationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsCharacterSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsCharacterReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsIconSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsUiDesignSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingUiDesignSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
onOpenSpecDialog: (specType: SpecGenerationType) => void;
onRequestUpload: (target: UploadTarget) => void;
onSubmitImageGeneration: (dialog: GenerateDialogState) => void;
@@ -116,18 +132,153 @@ function renderEditorPortal(node: ReactNode) {
return createPortal(node, document.body);
}
function ImageCanvasVideoGenerationComposerView({
dialog,
style,
generationReferenceButtonRef,
isGenerationReferenceMenuOpen,
setGenerateDialog,
setIsGenerationReferenceMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
onRequestUpload,
onSubmit,
}: {
dialog: GenerateDialogState;
style: CSSProperties;
generationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
isGenerationReferenceMenuOpen: boolean;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
setIsGenerationReferenceMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
onRequestUpload: (target: UploadTarget) => void;
onSubmit: (dialog: GenerateDialogState) => void;
}) {
const resolution = dialog.videoResolution ?? '480p';
const durationSeconds = dialog.videoDurationSeconds ?? 4;
const price = calculateEditorVideoPrice(resolution, durationSeconds);
return (
<>
<form
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image"
style={style}
role="dialog"
aria-label="生成视频"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (dialog.status !== 'generating') {
onSubmit(dialog);
}
}}
>
<PlatformIconButton
ref={generationReferenceButtonRef}
variant="surfaceFloating"
className="image-canvas-editor__generation-ref"
label="添加视频参考图"
disabled={dialog.status === 'generating'}
onClick={() => setIsGenerationReferenceMenuOpen((open) => !open)}
icon={<ImageIcon className="h-4 w-4" />}
>
<span></span>
</PlatformIconButton>
<PlatformTextField
variant="textarea"
aria-label="视频描述"
value={dialog.prompt}
disabled={dialog.status === 'generating'}
placeholder="描述视频画面"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
prompt: event.target.value,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
}
: currentDialog,
)
}
/>
<div className="image-canvas-editor__generation-composer-footer">
<span className="image-canvas-editor__generation-ratio">
16:9 · {resolution} · {durationSeconds}
</span>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={dialog.status === 'generating'}
aria-label="生成视频"
>
{dialog.status === 'generating' ? '生成中' : `${price}`}
</PlatformActionButton>
</div>
</form>
{isGenerationReferenceMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
label="参考图来源"
placement="top-start"
style={buildPortalMenuStyle(
generationReferenceButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(true);
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
onRequestUpload('generation-reference');
}}
>
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</>
);
}
export function ImageCanvasGenerationComposerView({
specToolWrapRef,
characterSpecButtonRef,
characterReferenceButtonRef,
generationReferenceButtonRef,
iconSpecButtonRef,
isSpecMenuOpen,
isGenerationReferenceMenuOpen,
isCharacterSpecMenuOpen,
isCharacterReferenceMenuOpen,
isIconSpecMenuOpen,
isUiDesignSpecMenuOpen,
isPickingGenerationReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingUiDesignSpecFromCanvas,
generateDialog,
generationComposerStyle,
iconComposerStyle,
@@ -143,12 +294,16 @@ export function ImageCanvasGenerationComposerView({
setGenerateDialog,
setQuickEditPanel,
setCharacterAnimationPanel,
setIsGenerationReferenceMenuOpen,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsIconSpecMenuOpen,
setIsUiDesignSpecMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
setIsPickingCharacterSpecFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsPickingIconSpecFromCanvas,
setIsPickingUiDesignSpecFromCanvas,
onOpenSpecDialog,
onRequestUpload,
onSubmitImageGeneration,
@@ -192,7 +347,18 @@ export function ImageCanvasGenerationComposerView({
dialog={generateDialog}
style={generationComposerStyle}
setGenerateDialog={setGenerateDialog}
generationReferenceButtonRef={generationReferenceButtonRef}
isGenerationReferenceMenuOpen={isGenerationReferenceMenuOpen}
setIsGenerationReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
setIsPickingGenerationReferenceFromCanvas={
setIsPickingGenerationReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onToggleReferenceMenu={() =>
setIsGenerationReferenceMenuOpen((open) => !open)
}
onSubmit={onSubmitImageGeneration}
onClose={onCloseGenerateComposer}
/>
@@ -204,12 +370,58 @@ export function ImageCanvasGenerationComposerView({
<ImageCanvasSpecGenerationPanelView
dialog={generateDialog}
style={generationComposerStyle}
isGenerationReferenceMenuOpen={isGenerationReferenceMenuOpen}
generationReferenceButtonRef={generationReferenceButtonRef}
setIsGenerationReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
setIsPickingGenerationReferenceFromCanvas={
setIsPickingGenerationReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onUpdateSpecFormValue={onUpdateSpecFormValue}
onRequestUpload={onRequestUpload}
onSubmit={onSubmitImageGeneration}
/>
) : null}
{generateDialog?.mode === 'ui-design' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasSpecGenerationPanelView
dialog={generateDialog}
style={generationComposerStyle}
isGenerationReferenceMenuOpen={isUiDesignSpecMenuOpen}
generationReferenceButtonRef={generationReferenceButtonRef}
setIsGenerationReferenceMenuOpen={setIsUiDesignSpecMenuOpen}
setIsPickingGenerationReferenceFromCanvas={
setIsPickingUiDesignSpecFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onUpdateSpecFormValue={onUpdateSpecFormValue}
onRequestUpload={onRequestUpload}
onSubmit={onSubmitImageGeneration}
/>
) : null}
{generateDialog?.mode === 'video' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasVideoGenerationComposerView
dialog={generateDialog}
style={generationComposerStyle}
generationReferenceButtonRef={generationReferenceButtonRef}
isGenerationReferenceMenuOpen={isGenerationReferenceMenuOpen}
setGenerateDialog={setGenerateDialog}
setIsGenerationReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
setIsPickingGenerationReferenceFromCanvas={
setIsPickingGenerationReferenceFromCanvas
}
onRequestUpload={onRequestUpload}
onSubmit={onSubmitImageGeneration}
/>
) : null}
{generateDialog?.mode === 'character' && generationComposerStyle ? (
<ImageCanvasCharacterGenerationComposerView
dialog={generateDialog}
@@ -268,11 +480,21 @@ export function ImageCanvasGenerationComposerView({
Esc 退
</div>
) : null}
{isPickingGenerationReferenceFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
</div>
) : null}
{isPickingIconSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
</div>
) : null}
{isPickingUiDesignSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
</div>
) : null}
{quickEditPanel &&
quickEditPanel.status !== 'generating' &&

View File

@@ -23,6 +23,8 @@ import {
ICON_FRAME_ORIGINAL_SIZE,
SPEC_FRAME_DISPLAY_SIZE,
SPEC_FRAME_ORIGINAL_SIZE,
UI_DESIGN_FRAME_DISPLAY_SIZE,
UI_DESIGN_FRAME_ORIGINAL_SIZE,
} from './ImageCanvasGenerationModel';
type CanvasSize = { width: number; height: number };
@@ -180,6 +182,37 @@ export function createIconGenerationDialogDraft({
};
}
export function createUiDesignGenerationDialogDraft({
canvasSize,
viewport,
imageModel,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
imageModel: string;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const dimensionDefaults = resolveImageDimensionDefaults(imageModel);
return {
mode: 'ui-design',
prompt: '',
status: 'idle',
composerOpen: true,
uiDesignSpecReference: null,
imageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
x: worldCenter.x - UI_DESIGN_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - UI_DESIGN_FRAME_DISPLAY_SIZE.height / 2,
width: UI_DESIGN_FRAME_DISPLAY_SIZE.width,
height: UI_DESIGN_FRAME_DISPLAY_SIZE.height,
originalWidth: UI_DESIGN_FRAME_ORIGINAL_SIZE.width,
originalHeight: UI_DESIGN_FRAME_ORIGINAL_SIZE.height,
},
};
}
export function createEditDialogDraft(
sourceLayer: CanvasLayer,
): GenerateDialogState {
@@ -255,6 +288,24 @@ export function appendCharacterReference(
: dialog;
}
export function appendGenerationReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'generate' ||
dialog?.mode === 'video' ||
dialog?.mode === 'spec'
? {
...resetFailedGenerationDialog(dialog),
generationReferences: [
...(dialog.generationReferences ?? []),
createCanvasLayerReference(layer),
],
composerOpen: true,
}
: dialog;
}
export function assignIconSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
@@ -271,6 +322,19 @@ export function assignIconSpecReference(
: dialog;
}
export function assignUiDesignSpecReference(
dialog: GenerateDialogState | null,
layer: CanvasLayer,
): GenerateDialogState | null {
return dialog?.mode === 'ui-design'
? {
...resetFailedGenerationDialog(dialog),
uiDesignSpecReference: createCanvasLayerReference(layer),
composerOpen: true,
}
: dialog;
}
export function updateSpecFormDialogValue(
dialog: GenerateDialogState | null,
key: keyof SpecFormValues,

View File

@@ -2,6 +2,7 @@ import type {
EditorCharacterAnimationGenerationInput,
EditorIconSpritesheetGenerationInput,
EditorImageGenerationInput,
EditorVideoGenerationInput,
} from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationInputs,
@@ -22,7 +23,10 @@ import {
buildImageGenerationInputs,
buildSpecGenerationInputs,
buildSpecPrompt,
buildUiDesignGenerationInputs,
buildVideoGenerationInputs,
calculateCharacterAnimationPrice,
calculateEditorVideoPrice,
resolveCharacterAnimationSourceImageSrc,
} from './ImageCanvasGenerationModel';
@@ -49,6 +53,15 @@ export type ImageGenerationSubmissionPlan =
generationInputs: CanvasGenerationInputs;
};
rememberImageModel?: string;
}
| {
kind: 'video';
normalizedPrompt: string;
input: EditorVideoGenerationInput;
result: {
title: string;
generationInputs: CanvasGenerationInputs;
};
};
export type IconSpritesheetGenerationSubmissionPlan =
@@ -156,14 +169,82 @@ export function buildImageGenerationSubmissionPlan({
};
}
if (dialog.mode === 'ui-design') {
const imageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL;
const referenceImageSrcs = [dialog.uiDesignSpecReference?.src].filter(
(src): src is string => Boolean(src),
);
return {
kind: 'image',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
kind: 'ui-design',
model: imageModel,
aspectRatio: dialog.aspectRatio ?? '16:9',
imageSize: dialog.imageSize ?? '1K',
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
},
result: {
generationInputs: buildImageGenerationInputs(normalizedPrompt),
assetKind: 'ui-design',
title: `UI设计图 ${nextGeneratedIndex}`,
generationInputs: buildUiDesignGenerationInputs(
normalizedPrompt,
dialog.uiDesignSpecReference,
),
},
rememberImageModel: imageModel,
};
}
if (dialog.mode === 'video') {
const resolution = dialog.videoResolution ?? '480p';
const durationSeconds = dialog.videoDurationSeconds ?? 4;
const model = dialog.videoModel ?? 'seedance2.0';
return {
kind: 'video',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
model,
aspectRatio: '16:9',
durationSeconds,
resolution,
mode: 'std',
sound: 'off',
priceMudPoints: calculateEditorVideoPrice(
resolution,
durationSeconds,
),
},
result: {
title: `生成视频 ${nextGeneratedIndex}`,
generationInputs: buildVideoGenerationInputs(
normalizedPrompt,
dialog.generationReferences,
),
},
};
}
return {
kind: 'image',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
...(dialog.generationReferences?.length
? {
referenceImageSrcs: dialog.generationReferences.map(
(reference) => reference.src,
),
}
: {}),
},
result: {
generationInputs: buildImageGenerationInputs(
normalizedPrompt,
dialog.generationReferences,
),
},
};
}

View File

@@ -4,7 +4,10 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { createRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
import type {
GenerateDialogState,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
function createIconDialog(
@@ -35,7 +38,7 @@ function IconComposerHarness({
initialDialog: GenerateDialogState;
initialMenuOpen?: boolean;
onOpenSpecDialog?: (specType: 'character' | 'ui' | 'icon' | 'custom') => void;
onRequestUpload?: (target: 'asset' | 'spec-reference' | 'character-spec' | 'character-reference' | 'icon-spec') => void;
onRequestUpload?: (target: UploadTarget) => void;
onUpdateIconDescription?: (index: number, value: string) => void;
onAddIconDescription?: () => void;
onRememberImageModel?: (model: string) => void;

View File

@@ -109,7 +109,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
cleanup();
renderPanel({ dialog: createSpecDialog({ specType: 'icon' }) });
expect(screen.queryByText('添加参考图')).toBeNull();
expect(screen.getByRole('button', { name: '参考图' })).toBeTruthy();
});
it('requests reference upload and submits while idle', () => {
@@ -122,7 +122,7 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
onSubmit: submitSpec,
});
fireEvent.click(screen.getByRole('button', { name: '添加参考图' }));
fireEvent.click(screen.getByRole('button', { name: '参考图' }));
fireEvent.click(screen.getByRole('button', { name: '提交生成规范' }));
expect(requestUpload).toHaveBeenCalledWith('spec-reference');

View File

@@ -1,8 +1,18 @@
import { type CSSProperties } from 'react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
} from 'react';
import { ImagePlus } from 'lucide-react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
PlatformSelectField,
@@ -21,6 +31,15 @@ import type {
type ImageCanvasSpecGenerationPanelViewProps = {
dialog: GenerateDialogState;
style: CSSProperties;
isGenerationReferenceMenuOpen?: boolean;
generationReferenceButtonRef?: RefObject<HTMLButtonElement | null>;
setIsGenerationReferenceMenuOpen?: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas?: Dispatch<SetStateAction<boolean>>;
renderEditorPortal?: (node: ReactNode) => ReactNode;
buildPortalMenuStyle?: (
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
onRequestUpload: (target: UploadTarget) => void;
onSubmit: (dialog: GenerateDialogState) => void;
@@ -29,16 +48,39 @@ type ImageCanvasSpecGenerationPanelViewProps = {
export function ImageCanvasSpecGenerationPanelView({
dialog,
style,
isGenerationReferenceMenuOpen = false,
generationReferenceButtonRef,
setIsGenerationReferenceMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
renderEditorPortal = (node) => node,
buildPortalMenuStyle = () => ({}),
onUpdateSpecFormValue,
onRequestUpload,
onSubmit,
}: ImageCanvasSpecGenerationPanelViewProps) {
const isUiDesignDialog = dialog.mode === 'ui-design';
const referenceLabel = isUiDesignDialog ? 'UI设计图标素材规范' : '参考图';
const uploadTarget: UploadTarget = isUiDesignDialog
? 'ui-design-icon-spec'
: 'spec-reference';
const reference = isUiDesignDialog
? dialog.uiDesignSpecReference
: dialog.specReference;
const openReferenceMenu = () => {
if (setIsGenerationReferenceMenuOpen) {
setIsGenerationReferenceMenuOpen((open) => !open);
return;
}
onRequestUpload(uploadTarget);
};
return (
<>
<form
className="image-canvas-editor__generation-composer image-canvas-editor__spec-composer"
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__spec-composer"
style={style}
role="dialog"
aria-label="生成规范"
aria-label={isUiDesignDialog ? '生成UI设计图' : '生成规范'}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
@@ -48,7 +90,29 @@ export function ImageCanvasSpecGenerationPanelView({
}}
>
<div className="image-canvas-editor__spec-fields">
{dialog.specType === 'custom' ? (
{isUiDesignDialog ? (
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
UI设计要求
</PlatformFieldLabel>
<PlatformTextField
variant="textarea"
aria-label="UI设计要求"
value={dialog.prompt}
disabled={dialog.status === 'generating'}
placeholder="描述要生成的UI界面"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) =>
onUpdateSpecFormValue('customPrompt', event.target.value)
}
/>
</label>
) : dialog.specType === 'custom' ? (
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="form"
@@ -165,29 +229,31 @@ export function ImageCanvasSpecGenerationPanelView({
) : null}
</>
)}
{dialog.specType !== 'icon' ? (
<div className="image-canvas-editor__field-block">
{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={() => onRequestUpload('spec-reference')}
onClick={openReferenceMenu}
aria-label={referenceLabel}
>
<span className="image-canvas-editor__reference-tile-visual">
{dialog.specReference ? (
<img src={dialog.specReference.src} alt="" aria-hidden="true" />
{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">
{dialog.specReference?.label ?? '添加参考图'}
{reference?.label ?? '添加参考图'}
</span>
</button>
</div>
@@ -204,14 +270,14 @@ export function ImageCanvasSpecGenerationPanelView({
{dialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__spec-footer">
<div className="image-canvas-editor__generation-composer-footer image-canvas-editor__spec-footer">
<PlatformActionButton
type="submit"
tone="secondary"
size="sm"
className="image-canvas-editor__spec-submit"
className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit"
disabled={dialog.status === 'generating'}
aria-label="提交生成规范"
aria-label={isUiDesignDialog ? '生成UI设计图' : '提交生成规范'}
>
{dialog.status === 'generating'
? '生成中'
@@ -219,5 +285,37 @@ export function ImageCanvasSpecGenerationPanelView({
</PlatformActionButton>
</div>
</form>
{isGenerationReferenceMenuOpen && generationReferenceButtonRef
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
label="参考图来源"
placement="top-start"
style={buildPortalMenuStyle(
generationReferenceButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen?.(false);
setIsPickingGenerationReferenceFromCanvas?.(true);
}}
>
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
onClick={() => {
setIsGenerationReferenceMenuOpen?.(false);
setIsPickingGenerationReferenceFromCanvas?.(false);
onRequestUpload(uploadTarget);
}}
>
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</>
);
}

View File

@@ -23,8 +23,10 @@ type PersistedUploadAsset = {
type GenerationReferenceUploadTarget =
| 'character-reference'
| 'character-spec'
| 'generation-reference'
| 'icon-spec'
| 'spec-reference';
| 'spec-reference'
| 'ui-design-icon-spec';
export const UPLOAD_LAYER_FALLBACK_SIZE = {
width: 420,
@@ -114,6 +116,27 @@ export function applyGenerationReferenceUpload({
}
: dialog;
}
if (target === 'ui-design-icon-spec') {
return dialog?.mode === 'ui-design'
? {
...setFailedGenerationIdle(dialog),
uiDesignSpecReference: firstReference,
}
: dialog;
}
if (target === 'generation-reference') {
return dialog?.mode === 'generate' ||
dialog?.mode === 'video' ||
dialog?.mode === 'spec'
? {
...setFailedGenerationIdle(dialog),
generationReferences: [
...(dialog.generationReferences ?? []),
...references,
],
}
: dialog;
}
return dialog?.mode === 'character'
? {
...setFailedGenerationIdle(dialog),

View File

@@ -12,6 +12,7 @@ import {
generateEditorCharacterAnimation,
generateEditorIconSpritesheet,
generateEditorImage,
generateEditorVideo,
} from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationDialogState,
@@ -28,6 +29,7 @@ import {
createGeneratedResultLayer,
createIconSpritesheetResultLayers,
createQuickEditResultLayer,
createVideoResultLayer,
} from './ImageCanvasGenerationLayerModel';
import {
buildEditGenerationInputs,
@@ -242,6 +244,53 @@ export function useImageCanvasGenerationSubmissionWorkflow({
],
);
const addVideoResultLayer = useCallback(
(
generated: Parameters<typeof createVideoResultLayer>[0]['generated'],
title: string,
generationInputs: CanvasGenerationInputs,
frame?: GenerateDialogState['placeholder'],
dialogId?: string,
) => {
layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current;
const nextLayer = createVideoResultLayer({
generated,
generatedIndex,
title,
canvasSize,
viewport,
generationInputs,
frame,
});
appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id);
setActiveSidebarPanel('layers');
if (dialogId) {
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
...currentDialog,
status: 'idle',
composerOpen: true,
generatedLayerId: nextLayer.id,
placeholder: undefined,
errorMessage: undefined,
}));
}
setActiveTool('select');
},
[
appendCanvasLayersWithResources,
canvasSize,
layerCounterRef,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
updateCanvasGenerationDialogById,
viewport,
],
);
const submitIconSpritesheetGeneration = useCallback(
async (dialog: GenerateDialogState) => {
if (dialog.mode !== 'icon') {
@@ -397,6 +446,15 @@ export function useImageCanvasGenerationSubmissionWorkflow({
sourceLayer: submissionPlan.sourceLayer,
generationInputs: submissionPlan.generationInputs,
});
} else if (submissionPlan.kind === 'video') {
const generated = await generateEditorVideo(submissionPlan.input);
addVideoResultLayer(
generated,
submissionPlan.result.title,
submissionPlan.result.generationInputs,
getGeneratingDialogPlaceholder(dialog),
canvasDialog?.id,
);
} else {
const generated = await generateEditorImage(submissionPlan.input);
if (submissionPlan.rememberImageModel) {
@@ -432,6 +490,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
},
[
addGeneratedResultLayer,
addVideoResultLayer,
getGeneratingDialogPlaceholder,
layerCounterRef,
layers,

View File

@@ -63,6 +63,8 @@ function GenerationSurfaceHarness() {
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const [viewport, setViewport] = useState({ x: 10, y: 20, scale: 2 });
const dialogs = useCanvasGenerationDialogs();
const activeDialog = dialogs.generateDialog;
const activeCanvasDialog =
@@ -72,15 +74,18 @@ function GenerationSurfaceHarness() {
const surface = useImageCanvasGenerationSurface({
layers,
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
viewport,
setViewport,
layerCounterRef,
specToolWrapRef,
characterSpecButtonRef,
characterReferenceButtonRef,
iconSpecButtonRef,
generationReferenceButtonRef,
generateDialog: dialogs.generateDialog,
setGenerateDialog: dialogs.setGenerateDialog,
activeCanvasGenerationDialog: activeCanvasDialog,
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,

View File

@@ -34,14 +34,17 @@ type ImageCanvasGenerationSurfaceOptions = {
layers: CanvasLayer[];
canvasSize: { width: number; height: number };
viewport: CanvasViewport;
setViewport: Dispatch<SetStateAction<CanvasViewport>>;
layerCounterRef: MutableRefObject<number>;
specToolWrapRef: RefObject<HTMLSpanElement | null>;
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
generationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
generateDialog: GenerateDialogState | null;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
activeCanvasGenerationDialog: CanvasGenerationDialogState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
openCanvasGenerationDialog: (
dialog: Omit<CanvasGenerationDialogState, 'id'>,
) => void;
@@ -68,14 +71,17 @@ export function useImageCanvasGenerationSurface({
layers,
canvasSize,
viewport,
setViewport,
layerCounterRef,
specToolWrapRef,
characterSpecButtonRef,
characterReferenceButtonRef,
iconSpecButtonRef,
generationReferenceButtonRef,
generateDialog,
setGenerateDialog,
activeCanvasGenerationDialog,
canvasGenerationDialogs,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
@@ -94,6 +100,8 @@ export function useImageCanvasGenerationSurface({
layers,
canvasSize,
viewport,
setViewport,
canvasGenerationDialogs,
layerCounterRef,
generateDialog,
setGenerateDialog,
@@ -173,13 +181,21 @@ export function useImageCanvasGenerationSurface({
specToolWrapRef={specToolWrapRef}
characterSpecButtonRef={characterSpecButtonRef}
characterReferenceButtonRef={characterReferenceButtonRef}
generationReferenceButtonRef={generationReferenceButtonRef}
iconSpecButtonRef={iconSpecButtonRef}
isSpecMenuOpen={generationWorkflow.isSpecMenuOpen}
isGenerationReferenceMenuOpen={
generationWorkflow.isGenerationReferenceMenuOpen
}
isCharacterSpecMenuOpen={generationWorkflow.isCharacterSpecMenuOpen}
isCharacterReferenceMenuOpen={
generationWorkflow.isCharacterReferenceMenuOpen
}
isIconSpecMenuOpen={generationWorkflow.isIconSpecMenuOpen}
isUiDesignSpecMenuOpen={generationWorkflow.isUiDesignSpecMenuOpen}
isPickingGenerationReferenceFromCanvas={
generationWorkflow.isPickingGenerationReferenceFromCanvas
}
isPickingCharacterSpecFromCanvas={
generationWorkflow.isPickingCharacterSpecFromCanvas
}
@@ -189,6 +205,9 @@ export function useImageCanvasGenerationSurface({
isPickingIconSpecFromCanvas={
generationWorkflow.isPickingIconSpecFromCanvas
}
isPickingUiDesignSpecFromCanvas={
generationWorkflow.isPickingUiDesignSpecFromCanvas
}
generateDialog={generateDialog}
generationComposerStyle={generationComposerStyle}
iconComposerStyle={iconComposerStyle}
@@ -214,7 +233,16 @@ export function useImageCanvasGenerationSurface({
setIsCharacterReferenceMenuOpen={
generationWorkflow.setIsCharacterReferenceMenuOpen
}
setIsGenerationReferenceMenuOpen={
generationWorkflow.setIsGenerationReferenceMenuOpen
}
setIsIconSpecMenuOpen={generationWorkflow.setIsIconSpecMenuOpen}
setIsUiDesignSpecMenuOpen={
generationWorkflow.setIsUiDesignSpecMenuOpen
}
setIsPickingGenerationReferenceFromCanvas={
generationWorkflow.setIsPickingGenerationReferenceFromCanvas
}
setIsPickingCharacterSpecFromCanvas={
generationWorkflow.setIsPickingCharacterSpecFromCanvas
}
@@ -224,6 +252,9 @@ export function useImageCanvasGenerationSurface({
setIsPickingIconSpecFromCanvas={
generationWorkflow.setIsPickingIconSpecFromCanvas
}
setIsPickingUiDesignSpecFromCanvas={
generationWorkflow.setIsPickingUiDesignSpecFromCanvas
}
onOpenSpecDialog={generationWorkflow.openSpecDialog}
onRequestUpload={requestUpload}
onSubmitImageGeneration={(dialog) =>

View File

@@ -426,7 +426,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect.objectContaining({
size: '2048x1152',
kind: 'spec',
model: 'gpt-image-2',
model: 'gemini-3.1-flash-image-preview',
referenceImageSrcs: ['data:image/png;base64,ref'],
prompt: expect.stringContaining('参考图生成规范'),
}),

View File

@@ -22,7 +22,9 @@ import type {
} from './ImageCanvasEditorTypes';
import {
appendCharacterReference,
appendGenerationReference,
appendIconDescriptionToDialog,
assignUiDesignSpecReference,
assignCharacterSpecReference,
assignIconSpecReference,
closeGenerateComposerDialog,
@@ -38,6 +40,10 @@ import {
updateIconDescriptionInDialog,
updateSpecFormDialogValue,
} from './ImageCanvasGenerationDialogModel';
import {
centerViewportOnPlacement,
chooseGenerationPlacement,
} from './ImageCanvasGenerationPlacementModel';
import {
buildQuickEditModelOptions,
buildQuickEditSizeOptions,
@@ -57,6 +63,8 @@ type GenerationWorkflowOptions = {
layers: CanvasLayer[];
canvasSize: CanvasSize;
viewport: CanvasViewport;
setViewport: Dispatch<SetStateAction<CanvasViewport>>;
canvasGenerationDialogs: CanvasGenerationDialogState[];
layerCounterRef: MutableRefObject<number>;
generateDialog: GenerateDialogState | null;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
@@ -85,6 +93,8 @@ export function useImageCanvasGenerationWorkflow({
layers,
canvasSize,
viewport,
setViewport,
canvasGenerationDialogs,
layerCounterRef,
generateDialog,
setGenerateDialog,
@@ -102,9 +112,15 @@ export function useImageCanvasGenerationWorkflow({
setImageContextMenu,
}: GenerationWorkflowOptions) {
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
useState(false);
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false);
const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] =
useState(false);
const [
isPickingGenerationReferenceFromCanvas,
setIsPickingGenerationReferenceFromCanvas,
] = useState(false);
const [
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
@@ -116,6 +132,9 @@ export function useImageCanvasGenerationWorkflow({
const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(false);
const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] =
useState(false);
const [isUiDesignSpecMenuOpen, setIsUiDesignSpecMenuOpen] = useState(false);
const [isPickingUiDesignSpecFromCanvas, setIsPickingUiDesignSpecFromCanvas] =
useState(false);
const [quickEditPanel, setQuickEditPanel] =
useState<QuickEditPanelState | null>(null);
const [characterAnimationPanel, setCharacterAnimationPanel] =
@@ -153,17 +172,40 @@ export function useImageCanvasGenerationWorkflow({
: DEFAULT_ICON_DESCRIPTIONS;
const openGenerateDialog = useCallback(() => {
openCanvasGenerationDialog(
createGenerateDialogDraft({ canvasSize, viewport }),
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({
canvasSize,
viewport,
placement,
}),
);
setActiveTool('generate');
selectSingleLayer(null);
setQuickEditPanel(null);
}, [
canvasSize,
canvasGenerationDialogs,
layers,
openCanvasGenerationDialog,
selectSingleLayer,
setActiveTool,
setViewport,
viewport,
]);
@@ -202,9 +244,13 @@ export function useImageCanvasGenerationWorkflow({
const openCharacterGenerationDialog = useCallback(() => {
setIsSpecMenuOpen(false);
setIsGenerationReferenceMenuOpen(false);
setIsCharacterReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
setIsPickingCharacterSpecFromCanvas(false);
setIsPickingCharacterReferenceFromCanvas(false);
setIsUiDesignSpecMenuOpen(false);
setIsPickingUiDesignSpecFromCanvas(false);
openCanvasGenerationDialog(
createCharacterGenerationDialogDraft({
canvasSize,
@@ -226,9 +272,13 @@ export function useImageCanvasGenerationWorkflow({
const openIconGenerationDialog = useCallback(() => {
setIsSpecMenuOpen(false);
setIsGenerationReferenceMenuOpen(false);
setIsCharacterReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
setIsPickingCharacterSpecFromCanvas(false);
setIsPickingCharacterReferenceFromCanvas(false);
setIsUiDesignSpecMenuOpen(false);
setIsPickingUiDesignSpecFromCanvas(false);
setIsPickingIconSpecFromCanvas(false);
openCanvasGenerationDialog(
createIconGenerationDialogDraft({
@@ -292,6 +342,18 @@ export function useImageCanvasGenerationWorkflow({
[setGenerateDialog, setImageContextMenu],
);
const pickGenerationReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
appendGenerationReference(currentDialog, layer),
);
setIsPickingGenerationReferenceFromCanvas(false);
setIsGenerationReferenceMenuOpen(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
);
const pickCharacterReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
@@ -318,6 +380,18 @@ export function useImageCanvasGenerationWorkflow({
[setGenerateDialog, setImageContextMenu],
);
const pickUiDesignSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
assignUiDesignSpecReference(currentDialog, layer),
);
setIsPickingUiDesignSpecFromCanvas(false);
setIsUiDesignSpecMenuOpen(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
);
const updateIconDescription = useCallback(
(index: number, value: string) => {
setGenerateDialog((currentDialog) =>
@@ -422,10 +496,14 @@ export function useImageCanvasGenerationWorkflow({
iconDescriptionValues,
isSpecMenuOpen,
setIsSpecMenuOpen,
isGenerationReferenceMenuOpen,
setIsGenerationReferenceMenuOpen,
isCharacterSpecMenuOpen,
setIsCharacterSpecMenuOpen,
isCharacterReferenceMenuOpen,
setIsCharacterReferenceMenuOpen,
isPickingGenerationReferenceFromCanvas,
setIsPickingGenerationReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
@@ -434,6 +512,10 @@ export function useImageCanvasGenerationWorkflow({
setIsIconSpecMenuOpen,
isPickingIconSpecFromCanvas,
setIsPickingIconSpecFromCanvas,
isUiDesignSpecMenuOpen,
setIsUiDesignSpecMenuOpen,
isPickingUiDesignSpecFromCanvas,
setIsPickingUiDesignSpecFromCanvas,
openGenerateDialog,
openSpecDialog,
openCharacterAnimationPanel,
@@ -442,8 +524,10 @@ export function useImageCanvasGenerationWorkflow({
openEditDialog,
openQuickEditPanel,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
submitIconSpritesheetGeneration,
submitQuickEdit,
submitImageGeneration,
@@ -469,9 +553,13 @@ export function useImageCanvasGenerationWorkflow({
isCharacterReferenceMenuOpen,
isCharacterSpecMenuOpen,
isIconSpecMenuOpen,
isGenerationReferenceMenuOpen,
isPickingCharacterReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
isPickingGenerationReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingUiDesignSpecFromCanvas,
isUiDesignSpecMenuOpen,
isSpecMenuOpen,
openCharacterAnimationPanel,
openCharacterGenerationDialog,
@@ -482,7 +570,9 @@ export function useImageCanvasGenerationWorkflow({
openSpecDialog,
pickCharacterReferenceFromLayer,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
quickEditModelOptions,
quickEditPanel,
quickEditSizeOptions,

View File

@@ -193,6 +193,40 @@ export function useImageCanvasUploadWorkflow({
[setGenerateDialog],
);
const addGenerationReferenceFiles = useCallback(
async (files: FileList | File[]) => {
const imageFiles = Array.from(files).filter(isImageFile);
if (!imageFiles.length) {
window.alert('请选择图片文件');
return;
}
const references = await Promise.all(
imageFiles.map(async (file, index) => ({
file,
index,
imageSrc: await readImageFileAsDataUrl(file),
})),
);
setGenerateDialog((currentDialog) =>
applyGenerationReferenceUpload({
dialog: currentDialog,
target: 'generation-reference',
references: references.map(({ file, imageSrc, index }) =>
createUploadedGenerationReference({
idPrefix: 'upload-generation-reference',
index,
fileName: file.name,
fallbackLabel: `参考图${index + 1}`,
imageSrc,
}),
),
}),
);
},
[setGenerateDialog],
);
const addIconSpecReferenceFiles = useCallback(
async (files: FileList | File[]) => {
const imageFile = Array.from(files).find(isImageFile);
@@ -220,6 +254,33 @@ export function useImageCanvasUploadWorkflow({
[setGenerateDialog],
);
const addUiDesignSpecReferenceFiles = useCallback(
async (files: FileList | File[]) => {
const imageFile = Array.from(files).find(isImageFile);
if (!imageFile) {
window.alert('请选择图片文件');
return;
}
const imageSrc = await readImageFileAsDataUrl(imageFile);
setGenerateDialog((currentDialog) =>
applyGenerationReferenceUpload({
dialog: currentDialog,
target: 'ui-design-icon-spec',
references: [
createUploadedGenerationReference({
idPrefix: 'upload-ui-design-icon-spec',
fileName: imageFile.name,
fallbackLabel: 'UI设计图标素材规范',
imageSrc,
}),
],
}),
);
},
[setGenerateDialog],
);
const uploadAssetFile = useCallback(
async (file: File, options: UploadAssetFileOptions) => {
if (!isImageFile(file)) {
@@ -435,12 +496,16 @@ export function useImageCanvasUploadWorkflow({
if (files?.length) {
if (currentUploadTarget === 'spec-reference') {
void addSpecReferenceFiles(files);
} else if (currentUploadTarget === 'generation-reference') {
void addGenerationReferenceFiles(files);
} else if (currentUploadTarget === 'character-spec') {
void addCharacterSpecReferenceFiles(files);
} else if (currentUploadTarget === 'character-reference') {
void addCharacterReferenceFiles(files);
} else if (currentUploadTarget === 'icon-spec') {
void addIconSpecReferenceFiles(files);
} else if (currentUploadTarget === 'ui-design-icon-spec') {
void addUiDesignSpecReferenceFiles(files);
} else {
addUploadedFiles(files, { addToCanvas: activeTool === 'upload' });
}
@@ -452,8 +517,10 @@ export function useImageCanvasUploadWorkflow({
activeTool,
addCharacterReferenceFiles,
addCharacterSpecReferenceFiles,
addGenerationReferenceFiles,
addSpecReferenceFiles,
addIconSpecReferenceFiles,
addUiDesignSpecReferenceFiles,
addUploadedFiles,
setUploadTarget,
],
@@ -469,6 +536,8 @@ export function useImageCanvasUploadWorkflow({
addSpecReferenceFiles,
addCharacterSpecReferenceFiles,
addCharacterReferenceFiles,
addGenerationReferenceFiles,
addIconSpecReferenceFiles,
addUiDesignSpecReferenceFiles,
};
}