Files
Genarrative/src/components/image-editor/useImageCanvasGenerationSurface.tsx
T
kdletters a5906ac26e
Project CI / Repository checks (push) Successful in 47s
Project CI / Frontend tests (push) Successful in 1m39s
Project CI / Native shell tests (push) Successful in 2m15s
Project CI / Backend tests (push) Successful in 2m56s
修复图片交付尺寸与提示重触发
统一图片模型的业务交付像素并保留 GPT provider 合法请求尺寸
禁止欠交付图片被放大并返回尺寸降级提示
重置相同 warning 的三秒自动关闭计时
补充前后端回归测试与编辑器契约文档
2026-07-27 21:16:01 +08:00

557 lines
19 KiB
TypeScript

import { X } from 'lucide-react';
import {
type CSSProperties,
type Dispatch,
type MutableRefObject,
type ReactNode,
type RefObject,
type SetStateAction,
useCallback,
useEffect,
useRef,
} from 'react';
import type {
EditorAssetSnapshot,
EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast';
import { ImageCanvasEditorPortal } from './ImageCanvasEditorPortal';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasLayer,
CanvasTool,
CanvasViewport,
GenerateDialogState,
ImageContextMenuState,
PublicationMaterialsWorkflowId,
SidebarPanel,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
import {
resolveCharacterAnimationPanelStyle,
resolveCropExpandPanelStyle,
resolveGenerationAnchor,
resolveGenerationComposerStyle,
resolveIconComposerStyle,
resolveQuickEditPanelStyle,
} from './ImageCanvasOverlayModel';
import { PUBLICATION_MATERIALS_WORKFLOWS } from './ImageCanvasPublicationMaterialsModel';
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
type CanvasGenerationDialogUpdater = (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null;
type ToolbarOptionTool = Extract<CanvasTool, 'music' | 'spec' | 'publication'>;
type ImageCanvasGenerationSurfaceOptions = {
layers: CanvasLayer[];
canvasSize: { width: number; height: number };
viewport: CanvasViewport;
setViewport: Dispatch<SetStateAction<CanvasViewport>>;
canvasViewportRef: RefObject<HTMLDivElement | null>;
setLayers: Dispatch<SetStateAction<CanvasLayer[]>>;
layerCounterRef: MutableRefObject<number>;
specToolWrapRef: RefObject<HTMLSpanElement | null>;
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
generationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
publicationReferenceButtonRef: RefObject<HTMLButtonElement | null>;
generateDialog: GenerateDialogState | null;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
activeCanvasGenerationDialog: CanvasGenerationDialogState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
openCanvasGenerationDialog: (
dialog: Omit<CanvasGenerationDialogState, 'id'>,
) => string;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
hasCanvasGenerationDialogById: (dialogId: string) => boolean;
archiveActiveCanvasGenerationDialog: () => void;
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
getGeneratingDialogPlaceholder: (
dialog: GenerateDialogState,
) => GenerateDialogState['placeholder'];
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
selectSingleLayer: (layerId: string | null) => void;
fitLayers: (
targetLayers?: CanvasLayer[],
options?: { captureHistory?: boolean },
) => void;
captureCanvasHistory: (action: CanvasHistoryAction) => void;
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
requestUpload: (target: UploadTarget) => void;
persistGeneratedAsset?: (layer: CanvasLayer) => void;
persistUpdatedLayerResource?: (layer: CanvasLayer) => void;
projectId?: string | null;
currentUserId?: string | null;
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
applyProjectSnapshot?: (
project: EditorProjectSnapshot,
action?: CanvasHistoryAction,
) => void;
onWalletBalanceMayHaveChanged?: () => void;
};
function renderEditorPortal(node: ReactNode) {
return <ImageCanvasEditorPortal>{node}</ImageCanvasEditorPortal>;
}
function buildToolbarPortalMenuStyle(
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))',
};
}
const TOOLBAR_OPTION_CLOSE_DELAY_MS = 160;
export function useImageCanvasGenerationSurface({
layers,
canvasSize,
viewport,
setViewport,
setLayers,
layerCounterRef,
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
characterSpecButtonRef,
characterReferenceButtonRef,
iconSpecButtonRef,
generationReferenceButtonRef,
publicationReferenceButtonRef,
generateDialog,
setGenerateDialog,
activeCanvasGenerationDialog,
canvasGenerationDialogs,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
fitLayers,
captureCanvasHistory,
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
requestUpload,
persistGeneratedAsset,
persistUpdatedLayerResource,
projectId,
currentUserId,
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
onWalletBalanceMayHaveChanged,
}: ImageCanvasGenerationSurfaceOptions) {
const toolbarOptionCloseTimerRef = useRef<ReturnType<
typeof setTimeout
> | null>(null);
const generationWorkflow = useImageCanvasGenerationWorkflow({
layers,
canvasSize,
viewport,
setViewport,
setLayers,
canvasGenerationDialogs,
layerCounterRef,
generateDialog,
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
fitLayers,
captureCanvasHistory,
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
persistGeneratedAsset,
persistUpdatedLayerResource,
projectId,
currentUserId,
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
onWalletBalanceMayHaveChanged,
});
const activeGenerationLayer = activeCanvasGenerationDialog?.generatedLayerId
? (layers.find(
(layer) => layer.id === activeCanvasGenerationDialog.generatedLayerId,
) ?? null)
: null;
const generationAnchor = resolveGenerationAnchor({
dialog: activeCanvasGenerationDialog,
generatedLayer: activeGenerationLayer,
});
const generationComposerStyle = resolveGenerationComposerStyle({
dialog: activeCanvasGenerationDialog,
anchor: generationAnchor,
viewport,
});
const iconComposerStyle = resolveIconComposerStyle({
dialog: activeCanvasGenerationDialog,
composerStyle: generationComposerStyle,
});
const quickEditPanelStyle = resolveQuickEditPanelStyle({
panel: generationWorkflow.quickEditPanel,
sourceLayer: generationWorkflow.quickEditSourceLayer,
viewport,
canvasSize,
});
const cropExpandPanelStyle = resolveCropExpandPanelStyle({
panel: generationWorkflow.cropExpandPanel,
sourceLayer: generationWorkflow.cropExpandSourceLayer,
viewport,
canvasSize,
});
const characterAnimationPanelStyle =
activeCanvasGenerationDialog?.mode === 'character-animation'
? generationComposerStyle
: resolveCharacterAnimationPanelStyle({
panel: generationWorkflow.characterAnimationPanel,
sourceLayer: generationWorkflow.characterAnimationSourceLayer,
viewport,
canvasSize,
});
const clearToolbarOptionCloseTimer = useCallback(() => {
if (!toolbarOptionCloseTimerRef.current) {
return;
}
clearTimeout(toolbarOptionCloseTimerRef.current);
toolbarOptionCloseTimerRef.current = null;
}, []);
useEffect(
() => () => {
clearToolbarOptionCloseTimer();
},
[clearToolbarOptionCloseTimer],
);
const openToolbarOptionMenu = useCallback(
(tool: ToolbarOptionTool) => {
clearToolbarOptionCloseTimer();
generationWorkflow.setIsMusicMenuOpen(tool === 'music');
generationWorkflow.setIsSpecMenuOpen(tool === 'spec');
generationWorkflow.setIsPublicationMenuOpen(tool === 'publication');
},
[clearToolbarOptionCloseTimer, generationWorkflow],
);
const closeToolbarOptionMenu = useCallback(
(tool: ToolbarOptionTool) => {
clearToolbarOptionCloseTimer();
toolbarOptionCloseTimerRef.current = setTimeout(() => {
if (tool === 'music') {
generationWorkflow.setIsMusicMenuOpen(false);
return;
}
if (tool === 'publication') {
generationWorkflow.setIsPublicationMenuOpen(false);
return;
}
generationWorkflow.setIsSpecMenuOpen(false);
}, TOOLBAR_OPTION_CLOSE_DELAY_MS);
},
[clearToolbarOptionCloseTimer, generationWorkflow],
);
const switchGenerationTool = useCallback(
(tool: CanvasTool) => {
if (tool === 'generate') {
generationWorkflow.openGenerateDialog();
return true;
}
if (tool === 'video') {
generationWorkflow.openVideoGenerationDialog();
return true;
}
if (tool === 'music') {
openToolbarOptionMenu('music');
setActiveTool('music');
return true;
}
if (tool === 'spec') {
openToolbarOptionMenu('spec');
setActiveTool('spec');
return true;
}
if (tool === 'publication') {
openToolbarOptionMenu('publication');
setActiveTool('publication');
return true;
}
if (tool === 'character') {
generationWorkflow.openCharacterGenerationDialog();
return true;
}
if (tool === 'icon') {
generationWorkflow.openIconGenerationDialog();
return true;
}
if (tool === 'ui-design') {
generationWorkflow.openUiDesignGenerationDialog();
return true;
}
return false;
},
[generationWorkflow, openToolbarOptionMenu, setActiveTool],
);
const generationComposerNode = (
<>
{generationWorkflow.generationWarning ? (
<PlatformRuntimeStatusToast
key={generationWorkflow.generationWarningVersion}
tone="warning"
surface="solid"
size="sm"
className="image-canvas-editor__reference-pick-toast"
role="alert"
>
<span>{generationWorkflow.generationWarning}</span>
<button
type="button"
className="image-canvas-editor__reference-pick-toast-close"
aria-label="关闭提示"
onClick={generationWorkflow.clearGenerationWarning}
>
<X aria-hidden="true" size={14} strokeWidth={2.4} />
</button>
</PlatformRuntimeStatusToast>
) : null}
<ImageCanvasGenerationComposerView
specToolWrapRef={specToolWrapRef}
publicationReferenceButtonRef={publicationReferenceButtonRef}
characterSpecButtonRef={characterSpecButtonRef}
characterReferenceButtonRef={characterReferenceButtonRef}
generationReferenceButtonRef={generationReferenceButtonRef}
iconSpecButtonRef={iconSpecButtonRef}
isPublicationReferenceMenuOpen={
generationWorkflow.isPublicationReferenceMenuOpen
}
isSpecMenuOpen={generationWorkflow.isSpecMenuOpen}
isGenerationReferenceMenuOpen={
generationWorkflow.isGenerationReferenceMenuOpen
}
isCharacterSpecMenuOpen={generationWorkflow.isCharacterSpecMenuOpen}
isCharacterReferenceMenuOpen={
generationWorkflow.isCharacterReferenceMenuOpen
}
isIconSpecMenuOpen={generationWorkflow.isIconSpecMenuOpen}
isUiDesignSpecMenuOpen={generationWorkflow.isUiDesignSpecMenuOpen}
isPickingPublicationReferenceFromCanvas={
generationWorkflow.isPickingPublicationReferenceFromCanvas
}
isPickingGenerationReferenceFromCanvas={
generationWorkflow.isPickingGenerationReferenceFromCanvas
}
isPickingQuickEditReferenceFromCanvas={
generationWorkflow.isPickingQuickEditReferenceFromCanvas
}
isPickingCharacterSpecFromCanvas={
generationWorkflow.isPickingCharacterSpecFromCanvas
}
isPickingCharacterReferenceFromCanvas={
generationWorkflow.isPickingCharacterReferenceFromCanvas
}
isPickingIconSpecFromCanvas={
generationWorkflow.isPickingIconSpecFromCanvas
}
isPickingUiDesignSpecFromCanvas={
generationWorkflow.isPickingUiDesignSpecFromCanvas
}
generateDialog={generateDialog}
generationComposerStyle={generationComposerStyle}
iconComposerStyle={iconComposerStyle}
quickEditPanel={generationWorkflow.quickEditPanel}
quickEditSourceLayer={generationWorkflow.quickEditSourceLayer}
quickEditPanelStyle={quickEditPanelStyle}
cropExpandPanel={generationWorkflow.cropExpandPanel}
cropExpandSourceLayer={generationWorkflow.cropExpandSourceLayer}
cropExpandPanelStyle={cropExpandPanelStyle}
characterAnimationPanel={generationWorkflow.characterAnimationPanel}
characterAnimationSourceLayer={
generationWorkflow.characterAnimationSourceLayer
}
characterAnimationPanelStyle={characterAnimationPanelStyle}
characterAnimationPrice={generationWorkflow.characterAnimationPrice}
setGenerateDialog={setGenerateDialog}
setQuickEditPanel={generationWorkflow.setQuickEditPanel}
setCropExpandPanel={generationWorkflow.setCropExpandPanel}
setCharacterAnimationPanel={
generationWorkflow.setCharacterAnimationPanel
}
setIsCharacterSpecMenuOpen={
generationWorkflow.setIsCharacterSpecMenuOpen
}
setIsCharacterReferenceMenuOpen={
generationWorkflow.setIsCharacterReferenceMenuOpen
}
setIsGenerationReferenceMenuOpen={
generationWorkflow.setIsGenerationReferenceMenuOpen
}
setIsPublicationReferenceMenuOpen={
generationWorkflow.setIsPublicationReferenceMenuOpen
}
setIsIconSpecMenuOpen={generationWorkflow.setIsIconSpecMenuOpen}
setIsUiDesignSpecMenuOpen={generationWorkflow.setIsUiDesignSpecMenuOpen}
setIsPickingGenerationReferenceFromCanvas={
generationWorkflow.setIsPickingGenerationReferenceFromCanvas
}
setIsPickingQuickEditReferenceFromCanvas={
generationWorkflow.setIsPickingQuickEditReferenceFromCanvas
}
setIsPickingPublicationReferenceFromCanvas={
generationWorkflow.setIsPickingPublicationReferenceFromCanvas
}
setIsPickingCharacterSpecFromCanvas={
generationWorkflow.setIsPickingCharacterSpecFromCanvas
}
setIsPickingCharacterReferenceFromCanvas={
generationWorkflow.setIsPickingCharacterReferenceFromCanvas
}
setIsPickingIconSpecFromCanvas={
generationWorkflow.setIsPickingIconSpecFromCanvas
}
setIsPickingUiDesignSpecFromCanvas={
generationWorkflow.setIsPickingUiDesignSpecFromCanvas
}
onOpenSpecDialog={generationWorkflow.openSpecDialog}
onRequestUpload={requestUpload}
onSubmitImageGeneration={(dialog) =>
void generationWorkflow.submitImageGeneration(dialog)
}
onSubmitIconSpritesheetGeneration={(dialog) =>
void generationWorkflow.submitIconSpritesheetGeneration(dialog)
}
onSubmitQuickEdit={() => void generationWorkflow.submitQuickEdit()}
onSubmitCropExpand={() => void generationWorkflow.submitCropExpand()}
onSubmitCharacterAnimation={() =>
void generationWorkflow.submitCharacterAnimation()
}
onUpdateSpecFormValue={generationWorkflow.updateSpecFormValue}
onUpdateIconDescriptionText={
generationWorkflow.updateIconDescriptionsText
}
onUpdateCharacterAnimationDuration={
generationWorkflow.updateCharacterAnimationDuration
}
onRememberImageModel={generationWorkflow.rememberImageModel}
onSpecMenuPointerEnter={() => openToolbarOptionMenu('spec')}
onSpecMenuPointerLeave={() => closeToolbarOptionMenu('spec')}
/>
{generationWorkflow.isPublicationMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu image-canvas-editor__publication-menu"
label="宣发素材类型"
placement="top-start"
style={buildToolbarPortalMenuStyle(
publicationToolWrapRef.current,
)}
onPointerEnter={() => openToolbarOptionMenu('publication')}
onPointerLeave={() => closeToolbarOptionMenu('publication')}
onFocus={() => openToolbarOptionMenu('publication')}
onBlur={() => closeToolbarOptionMenu('publication')}
>
{PUBLICATION_MATERIALS_WORKFLOWS.map((workflow) => (
<PlatformFloatingMenuItem
key={workflow.id}
onClick={() =>
generationWorkflow.openPublicationGenerationDialog(
workflow.id as PublicationMaterialsWorkflowId,
)
}
>
{workflow.label}
</PlatformFloatingMenuItem>
))}
</PlatformFloatingMenu>,
)
: null}
{generationWorkflow.isMusicMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu image-canvas-editor__music-menu"
label="生成音乐类型"
placement="top-start"
style={buildToolbarPortalMenuStyle(musicToolWrapRef.current)}
onPointerEnter={() => openToolbarOptionMenu('music')}
onPointerLeave={() => closeToolbarOptionMenu('music')}
onFocus={() => openToolbarOptionMenu('music')}
onBlur={() => closeToolbarOptionMenu('music')}
>
<PlatformFloatingMenuItem
onClick={generationWorkflow.openSoundEffectGenerationDialog}
>
生成游戏音效
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
onClick={generationWorkflow.openBackgroundMusicGenerationDialog}
>
生成游戏背景音乐
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</>
);
return {
...generationWorkflow,
generationComposerStyle,
generationComposerNode,
openToolbarOptionMenu,
closeToolbarOptionMenu,
switchGenerationTool,
};
}