Files
Genarrative/src/components/image-editor/ImageCanvasGenerationComposerView.tsx
kdletters 946308b75e 保存图片画布生成器快照
将生成器对话框作为画布布局项序列化和恢复

生成成功后保留生成器快照并锚定到成品图层

图片类生成结果同步写入账号素材库

补充生成器持久化测试和浏览器回归相关文档
2026-06-17 23:57:25 +08:00

635 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ChevronDown, ImageIcon } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type ReactNode,
type RefObject,
type SetStateAction,
} from 'react';
import { createPortal } from 'react-dom';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
import { ImageCanvasSpecGenerationPanelView } from './ImageCanvasSpecGenerationPanelView';
import {
EDITOR_VIDEO_MODEL_OPTIONS,
SPEC_TYPE_LABEL,
calculateEditorVideoPrice,
} from './ImageCanvasGenerationModel';
import type {
CharacterAnimationPanelState,
CanvasLayer,
GenerateDialogState,
QuickEditPanelState,
SpecFormValues,
SpecGenerationType,
UploadTarget,
} from './ImageCanvasEditorTypes';
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;
quickEditPanel: QuickEditPanelState | null;
quickEditSourceLayer: CanvasLayer | null;
quickEditPanelStyle: CSSProperties | null;
quickEditSizeOptions: string[];
quickEditModelOptions: Array<{ label: string; value: string }>;
characterAnimationPanel: CharacterAnimationPanelState | null;
characterAnimationSourceLayer: CanvasLayer | null;
characterAnimationPanelStyle: CSSProperties | null;
characterAnimationPrice: number;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
setQuickEditPanel: Dispatch<SetStateAction<QuickEditPanelState | null>>;
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;
onSubmitIconSpritesheetGeneration: (dialog: GenerateDialogState) => void;
onSubmitQuickEdit: () => void;
onSubmitCharacterAnimation: () => void;
onCloseGenerateComposer: () => void;
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
onUpdateIconDescription: (index: number, value: string) => void;
onAddIconDescription: () => void;
onUpdateCharacterAnimationDuration: (frameCountValue: string) => void;
onRememberImageModel: (model: string) => void;
};
function buildPortalMenuStyle(
anchor: HTMLElement | null,
placement: 'above' | 'below',
): 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:
placement === 'above'
? Math.round(rect.top)
: Math.round(rect.bottom + 8),
right: 'auto',
bottom: 'auto',
zIndex: 70,
transform:
placement === 'above' ? 'translateY(calc(-100% - 0.45rem))' : undefined,
};
}
function renderEditorPortal(node: ReactNode) {
if (typeof document === 'undefined') {
return node;
}
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 currentModel =
EDITOR_VIDEO_MODEL_OPTIONS.find((item) => item.value === dialog.videoModel)
?? EDITOR_VIDEO_MODEL_OPTIONS[0];
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">
<PlatformInlineOptionButton
className="image-canvas-editor__generation-ratio"
aria-label={`视频参数 16:9 · ${durationSeconds}秒 · ${resolution}`}
disabled={dialog.status === 'generating'}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'video'
? {
...currentDialog,
videoDurationSeconds:
currentDialog.videoDurationSeconds === 5 ? 4 : 5,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
}
: currentDialog,
);
}}
>
16:9 · {resolution} · {durationSeconds}
</PlatformInlineOptionButton>
<PlatformInlineOptionButton
className="image-canvas-editor__generation-model"
aria-label={`模型 ${currentModel.label}`}
disabled={dialog.status === 'generating'}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => {
setGenerateDialog((currentDialog) => {
if (currentDialog?.mode !== 'video') {
return currentDialog;
}
const currentIndex = EDITOR_VIDEO_MODEL_OPTIONS.findIndex(
(item) => item.value === currentDialog.videoModel,
);
const nextModel =
EDITOR_VIDEO_MODEL_OPTIONS[
(Math.max(currentIndex, 0) + 1) %
EDITOR_VIDEO_MODEL_OPTIONS.length
] ?? EDITOR_VIDEO_MODEL_OPTIONS[0];
if (!nextModel) {
return currentDialog;
}
return {
...currentDialog,
videoModel: nextModel.value,
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
};
});
}}
>
{currentModel.label}
</PlatformInlineOptionButton>
<PlatformInlineOptionButton
className="image-canvas-editor__generation-ratio"
aria-label={`清晰度 ${resolution}`}
disabled={dialog.status === 'generating'}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'video'
? {
...currentDialog,
videoResolution:
currentDialog.videoResolution === '720p'
? '480p'
: '720p',
status:
currentDialog.status === 'failed'
? 'idle'
: currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
}
: currentDialog,
);
}}
>
{resolution}
</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' ? '生成中' : `${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,
quickEditPanel,
quickEditSourceLayer,
quickEditPanelStyle,
quickEditSizeOptions,
quickEditModelOptions,
characterAnimationPanel,
characterAnimationSourceLayer,
characterAnimationPanelStyle,
characterAnimationPrice,
setGenerateDialog,
setQuickEditPanel,
setCharacterAnimationPanel,
setIsGenerationReferenceMenuOpen,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsIconSpecMenuOpen,
setIsUiDesignSpecMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
setIsPickingCharacterSpecFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsPickingIconSpecFromCanvas,
setIsPickingUiDesignSpecFromCanvas,
onOpenSpecDialog,
onRequestUpload,
onSubmitImageGeneration,
onSubmitIconSpritesheetGeneration,
onSubmitQuickEdit,
onSubmitCharacterAnimation,
onCloseGenerateComposer,
onUpdateSpecFormValue,
onUpdateIconDescription,
onAddIconDescription,
onUpdateCharacterAnimationDuration,
onRememberImageModel,
}: ImageCanvasGenerationComposerViewProps) {
return (
<>
{isSpecMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
label="生成规范类型"
placement="top-start"
style={buildPortalMenuStyle(specToolWrapRef.current, 'above')}
>
{(['character', 'ui', 'custom'] as const).map((specType) => (
<PlatformFloatingMenuItem
key={specType}
className="image-canvas-editor__spec-menu-item"
onClick={() => onOpenSpecDialog(specType)}
>
{SPEC_TYPE_LABEL[specType]}
</PlatformFloatingMenuItem>
))}
</PlatformFloatingMenu>,
)
: null}
{generateDialog?.mode === 'generate' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasBasicGenerationComposerView
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}
/>
) : null}
{generateDialog?.mode === 'spec' &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasSpecGenerationPanelView
dialog={generateDialog}
style={generationComposerStyle}
isGenerationReferenceMenuOpen={isGenerationReferenceMenuOpen}
generationReferenceButtonRef={generationReferenceButtonRef}
setIsGenerationReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
setIsPickingGenerationReferenceFromCanvas={
setIsPickingGenerationReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
setGenerateDialog={setGenerateDialog}
onOpenSpecDialog={onOpenSpecDialog}
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}
setGenerateDialog={setGenerateDialog}
onOpenSpecDialog={onOpenSpecDialog}
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}
style={generationComposerStyle}
characterSpecButtonRef={characterSpecButtonRef}
characterReferenceButtonRef={characterReferenceButtonRef}
isCharacterSpecMenuOpen={isCharacterSpecMenuOpen}
isCharacterReferenceMenuOpen={isCharacterReferenceMenuOpen}
setGenerateDialog={setGenerateDialog}
setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen}
setIsCharacterReferenceMenuOpen={setIsCharacterReferenceMenuOpen}
setIsPickingCharacterSpecFromCanvas={
setIsPickingCharacterSpecFromCanvas
}
setIsPickingCharacterReferenceFromCanvas={
setIsPickingCharacterReferenceFromCanvas
}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onOpenSpecDialog={onOpenSpecDialog}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
onSubmit={onSubmitImageGeneration}
/>
) : null}
{generateDialog?.mode === 'icon' &&
generateDialog.composerOpen !== false &&
iconComposerStyle ? (
<ImageCanvasIconSpritesheetComposerView
dialog={generateDialog}
style={iconComposerStyle}
iconSpecButtonRef={iconSpecButtonRef}
isIconSpecMenuOpen={isIconSpecMenuOpen}
setGenerateDialog={setGenerateDialog}
setIsIconSpecMenuOpen={setIsIconSpecMenuOpen}
setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onOpenSpecDialog={onOpenSpecDialog}
onRequestUpload={onRequestUpload}
onUpdateIconDescription={onUpdateIconDescription}
onAddIconDescription={onAddIconDescription}
onRememberImageModel={onRememberImageModel}
onSubmit={onSubmitIconSpritesheetGeneration}
/>
) : null}
{isPickingCharacterSpecFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
Esc 退
</div>
) : null}
{isPickingCharacterReferenceFromCanvas ? (
<div className="image-canvas-editor__canvas-pick-hint">
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' &&
quickEditSourceLayer &&
quickEditPanelStyle ? (
<ImageCanvasQuickEditPanelView
panel={quickEditPanel}
sourceLayer={quickEditSourceLayer}
style={quickEditPanelStyle}
sizeOptions={quickEditSizeOptions}
modelOptions={quickEditModelOptions}
setQuickEditPanel={setQuickEditPanel}
onSubmit={onSubmitQuickEdit}
/>
) : null}
{characterAnimationPanel &&
characterAnimationSourceLayer &&
characterAnimationPanelStyle ? (
<ImageCanvasCharacterAnimationPanelView
panel={characterAnimationPanel}
style={characterAnimationPanelStyle}
price={characterAnimationPrice}
setCharacterAnimationPanel={setCharacterAnimationPanel}
onUpdateDuration={onUpdateCharacterAnimationDuration}
onSubmit={onSubmitCharacterAnimation}
/>
) : null}
<ImageCanvasEditGenerationModalView
dialog={generateDialog}
setGenerateDialog={setGenerateDialog}
onSubmit={onSubmitImageGeneration}
/>
</>
);
}