拆分图片画布生成表面编排
新增 useImageCanvasGenerationSurface 收口生成浮层编排。 主视图移除生成 Composer 大段 props 胶水。 舞台控制模型移除重复生成锚点派生。 补充生成表面 hook 单测并更新拆分文档与跟踪记录。
This commit is contained in:
@@ -1,18 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
||||
import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView';
|
||||
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
|
||||
import { ImageCanvasStageView } from './ImageCanvasStageView';
|
||||
import { ImageCanvasTopbarView } from './ImageCanvasTopbarView';
|
||||
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
|
||||
import {
|
||||
isCanvasGenerationComposerVisible,
|
||||
resolveCharacterAnimationPanelStyle,
|
||||
resolveIconComposerStyle,
|
||||
resolveQuickEditPanelStyle,
|
||||
} from './ImageCanvasOverlayModel';
|
||||
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
|
||||
import type {
|
||||
AssetPointerDragState,
|
||||
CanvasContextMenuState,
|
||||
@@ -30,7 +24,7 @@ import {
|
||||
} from './useImageCanvasAssetCanvasBridge';
|
||||
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
|
||||
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
|
||||
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
|
||||
import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface';
|
||||
import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts';
|
||||
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||
@@ -330,13 +324,44 @@ export function ImageCanvasEditorView() {
|
||||
projectId,
|
||||
projectTitle,
|
||||
});
|
||||
const generationWorkflow = useImageCanvasGenerationWorkflow({
|
||||
const {
|
||||
uploadInputRef,
|
||||
setUploadTarget,
|
||||
requestUpload,
|
||||
handleUploadInputChange,
|
||||
addUploadedFiles,
|
||||
} = useImageCanvasUploadWorkflow({
|
||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||
openEditorLoginModal,
|
||||
assetFolders,
|
||||
activeUploadFolderId,
|
||||
canvasSize,
|
||||
viewport,
|
||||
activeTool,
|
||||
allocateUploadIndex: () => {
|
||||
layerCounterRef.current += 1;
|
||||
return layerCounterRef.current;
|
||||
},
|
||||
setAssetFolders,
|
||||
setAssets,
|
||||
setLayers,
|
||||
setGenerateDialog,
|
||||
setActiveSidebarPanel,
|
||||
appendCanvasLayersWithResources,
|
||||
selectSingleLayer,
|
||||
});
|
||||
const generationSurface = useImageCanvasGenerationSurface({
|
||||
layers,
|
||||
canvasSize,
|
||||
viewport,
|
||||
layerCounterRef,
|
||||
specToolWrapRef,
|
||||
characterSpecButtonRef,
|
||||
characterReferenceButtonRef,
|
||||
iconSpecButtonRef,
|
||||
generateDialog,
|
||||
setGenerateDialog,
|
||||
activeCanvasGenerationDialog,
|
||||
openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
@@ -349,58 +374,35 @@ export function ImageCanvasEditorView() {
|
||||
setActiveSidebarPanel,
|
||||
setMetadataLayer,
|
||||
setImageContextMenu,
|
||||
requestUpload,
|
||||
});
|
||||
const {
|
||||
quickEditPanel,
|
||||
setQuickEditPanel,
|
||||
quickEditSourceLayer,
|
||||
quickEditSizeOptions,
|
||||
quickEditModelOptions,
|
||||
characterAnimationPanel,
|
||||
setCharacterAnimationPanel,
|
||||
characterAnimationSourceLayer,
|
||||
characterAnimationPrice,
|
||||
iconDescriptionValues,
|
||||
isSpecMenuOpen,
|
||||
setIsSpecMenuOpen,
|
||||
isCharacterSpecMenuOpen,
|
||||
setIsCharacterSpecMenuOpen,
|
||||
isCharacterReferenceMenuOpen,
|
||||
setIsCharacterReferenceMenuOpen,
|
||||
isPickingCharacterSpecFromCanvas,
|
||||
setIsPickingCharacterSpecFromCanvas,
|
||||
isPickingCharacterReferenceFromCanvas,
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
isIconSpecMenuOpen,
|
||||
setIsIconSpecMenuOpen,
|
||||
isPickingIconSpecFromCanvas,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
openGenerateDialog,
|
||||
openSpecDialog,
|
||||
openCharacterAnimationPanel,
|
||||
openCharacterGenerationDialog,
|
||||
openIconGenerationDialog,
|
||||
openEditDialog,
|
||||
openQuickEditPanel,
|
||||
pickCharacterSpecFromLayer,
|
||||
pickCharacterReferenceFromLayer,
|
||||
pickIconSpecFromLayer,
|
||||
submitIconSpritesheetGeneration,
|
||||
submitQuickEdit,
|
||||
submitImageGeneration,
|
||||
updateSpecFormValue,
|
||||
updateIconDescription,
|
||||
addIconDescription,
|
||||
updateCharacterAnimationDuration,
|
||||
rememberImageModel,
|
||||
submitCharacterAnimation,
|
||||
hideGeneratedLayerPanelAfterBlur,
|
||||
closeGenerateComposer,
|
||||
clearDeletedLayerGenerationState,
|
||||
} = generationWorkflow;
|
||||
generationComposerStyle,
|
||||
generationComposerNode,
|
||||
switchGenerationTool,
|
||||
} = generationSurface;
|
||||
const {
|
||||
selectedLayer,
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
imageContextMenuLayer,
|
||||
contextShouldShowLayer,
|
||||
@@ -412,7 +414,6 @@ export function ImageCanvasEditorView() {
|
||||
layers,
|
||||
selectedLayerId,
|
||||
selectedLayerIds,
|
||||
activeCanvasGenerationDialog,
|
||||
imageContextMenu,
|
||||
setImageContextMenu,
|
||||
contextMenu,
|
||||
@@ -423,23 +424,6 @@ export function ImageCanvasEditorView() {
|
||||
hideGeneratedLayerPanelAfterBlur,
|
||||
getCanvasPointFromClient,
|
||||
});
|
||||
const iconComposerStyle = resolveIconComposerStyle({
|
||||
dialog: activeCanvasGenerationDialog,
|
||||
composerStyle: generationComposerStyle,
|
||||
iconDescriptionCount: iconDescriptionValues.length,
|
||||
});
|
||||
const quickEditPanelStyle = resolveQuickEditPanelStyle({
|
||||
panel: quickEditPanel,
|
||||
sourceLayer: quickEditSourceLayer,
|
||||
viewport,
|
||||
canvasSize,
|
||||
});
|
||||
const characterAnimationPanelStyle = resolveCharacterAnimationPanelStyle({
|
||||
panel: characterAnimationPanel,
|
||||
sourceLayer: characterAnimationSourceLayer,
|
||||
viewport,
|
||||
canvasSize,
|
||||
});
|
||||
const {
|
||||
canvasClipboard,
|
||||
pasteCanvasClipboard,
|
||||
@@ -474,32 +458,6 @@ export function ImageCanvasEditorView() {
|
||||
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
|
||||
exportLayerImage,
|
||||
});
|
||||
const {
|
||||
uploadInputRef,
|
||||
setUploadTarget,
|
||||
requestUpload,
|
||||
handleUploadInputChange,
|
||||
addUploadedFiles,
|
||||
} = useImageCanvasUploadWorkflow({
|
||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||
openEditorLoginModal,
|
||||
assetFolders,
|
||||
activeUploadFolderId,
|
||||
canvasSize,
|
||||
viewport,
|
||||
activeTool,
|
||||
allocateUploadIndex: () => {
|
||||
layerCounterRef.current += 1;
|
||||
return layerCounterRef.current;
|
||||
},
|
||||
setAssetFolders,
|
||||
setAssets,
|
||||
setLayers,
|
||||
setGenerateDialog,
|
||||
setActiveSidebarPanel,
|
||||
appendCanvasLayersWithResources,
|
||||
selectSingleLayer,
|
||||
});
|
||||
const {
|
||||
canvasMarquee,
|
||||
isPanning,
|
||||
@@ -627,21 +585,7 @@ export function ImageCanvasEditorView() {
|
||||
requestUpload('asset');
|
||||
return;
|
||||
}
|
||||
if (tool === 'generate') {
|
||||
openGenerateDialog();
|
||||
return;
|
||||
}
|
||||
if (tool === 'spec') {
|
||||
setIsSpecMenuOpen((open) => !open);
|
||||
setActiveTool('spec');
|
||||
return;
|
||||
}
|
||||
if (tool === 'character') {
|
||||
openCharacterGenerationDialog();
|
||||
return;
|
||||
}
|
||||
if (tool === 'icon') {
|
||||
openIconGenerationDialog();
|
||||
if (switchGenerationTool(tool)) {
|
||||
return;
|
||||
}
|
||||
setActiveTool(tool);
|
||||
@@ -844,74 +788,7 @@ export function ImageCanvasEditorView() {
|
||||
onMinimapPointerDown={handleMinimapPointerDown}
|
||||
onSwitchTool={switchTool}
|
||||
>
|
||||
<ImageCanvasGenerationComposerView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
characterSpecButtonRef={characterSpecButtonRef}
|
||||
characterReferenceButtonRef={characterReferenceButtonRef}
|
||||
iconSpecButtonRef={iconSpecButtonRef}
|
||||
isSpecMenuOpen={isSpecMenuOpen}
|
||||
isCharacterSpecMenuOpen={isCharacterSpecMenuOpen}
|
||||
isCharacterReferenceMenuOpen={isCharacterReferenceMenuOpen}
|
||||
isIconSpecMenuOpen={isIconSpecMenuOpen}
|
||||
isPickingCharacterSpecFromCanvas={isPickingCharacterSpecFromCanvas}
|
||||
isPickingCharacterReferenceFromCanvas={
|
||||
isPickingCharacterReferenceFromCanvas
|
||||
}
|
||||
isPickingIconSpecFromCanvas={isPickingIconSpecFromCanvas}
|
||||
generateDialog={generateDialog}
|
||||
generationComposerStyle={generationComposerStyle}
|
||||
iconComposerStyle={iconComposerStyle}
|
||||
quickEditPanel={quickEditPanel}
|
||||
quickEditSourceLayer={quickEditSourceLayer}
|
||||
quickEditPanelStyle={quickEditPanelStyle}
|
||||
quickEditSizeOptions={quickEditSizeOptions}
|
||||
quickEditModelOptions={quickEditModelOptions}
|
||||
characterAnimationPanel={characterAnimationPanel}
|
||||
characterAnimationSourceLayer={characterAnimationSourceLayer}
|
||||
characterAnimationPanelStyle={characterAnimationPanelStyle}
|
||||
characterAnimationPrice={characterAnimationPrice}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
setQuickEditPanel={setQuickEditPanel}
|
||||
setCharacterAnimationPanel={setCharacterAnimationPanel}
|
||||
setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen}
|
||||
setIsCharacterReferenceMenuOpen={setIsCharacterReferenceMenuOpen}
|
||||
setIsIconSpecMenuOpen={setIsIconSpecMenuOpen}
|
||||
setIsPickingCharacterSpecFromCanvas={
|
||||
setIsPickingCharacterSpecFromCanvas
|
||||
}
|
||||
setIsPickingCharacterReferenceFromCanvas={
|
||||
setIsPickingCharacterReferenceFromCanvas
|
||||
}
|
||||
setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas}
|
||||
onOpenSpecDialog={openSpecDialog}
|
||||
onRequestUpload={requestUpload}
|
||||
onSubmitImageGeneration={(dialog) =>
|
||||
void submitImageGeneration(dialog)
|
||||
}
|
||||
onSubmitIconSpritesheetGeneration={(dialog) =>
|
||||
void submitIconSpritesheetGeneration(dialog)
|
||||
}
|
||||
onSubmitQuickEdit={() => void submitQuickEdit()}
|
||||
onSubmitCharacterAnimation={() => void submitCharacterAnimation()}
|
||||
onCloseGenerateComposer={() => {
|
||||
setGenerateDialog((currentDialog) =>
|
||||
currentDialog?.mode === 'generate'
|
||||
? {
|
||||
...currentDialog,
|
||||
composerOpen: false,
|
||||
}
|
||||
: currentDialog,
|
||||
);
|
||||
setActiveTool('select');
|
||||
}}
|
||||
onUpdateSpecFormValue={updateSpecFormValue}
|
||||
onUpdateIconDescription={updateIconDescription}
|
||||
onAddIconDescription={addIconDescription}
|
||||
onUpdateCharacterAnimationDuration={
|
||||
updateCharacterAnimationDuration
|
||||
}
|
||||
onRememberImageModel={rememberImageModel}
|
||||
/>
|
||||
{generationComposerNode}
|
||||
</ImageCanvasStageView>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type {
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
@@ -32,28 +31,8 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
};
|
||||
}
|
||||
|
||||
function createDialog(
|
||||
overrides: Partial<CanvasGenerationDialogState> = {},
|
||||
): CanvasGenerationDialogState {
|
||||
return {
|
||||
id: 'dialog-a',
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
placeholder: {
|
||||
x: 300,
|
||||
y: 200,
|
||||
width: 360,
|
||||
height: 260,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ImageCanvasStageControllerModel', () => {
|
||||
it('derives selected layer, generation anchor, and overlay positions', () => {
|
||||
it('derives selected layer, selected toolbar, and context image target', () => {
|
||||
const selectedLayer = createLayer({ id: 'selected', x: 40, y: 60 });
|
||||
const generatedLayer = createLayer({
|
||||
id: 'generated',
|
||||
@@ -62,13 +41,11 @@ describe('ImageCanvasStageControllerModel', () => {
|
||||
width: 320,
|
||||
height: 180,
|
||||
});
|
||||
const dialog = createDialog({ generatedLayerId: generatedLayer.id });
|
||||
|
||||
const model = resolveImageCanvasStageControllerModel({
|
||||
layers: [selectedLayer, generatedLayer],
|
||||
selectedLayerId: selectedLayer.id,
|
||||
selectedLayerIds: [selectedLayer.id, generatedLayer.id],
|
||||
activeCanvasGenerationDialog: dialog,
|
||||
imageContextMenu: { layerId: generatedLayer.id, x: 0, y: 0 },
|
||||
contextMenu: null,
|
||||
viewport: { x: 10, y: 20, scale: 2 },
|
||||
@@ -78,9 +55,6 @@ describe('ImageCanvasStageControllerModel', () => {
|
||||
expect(model.selectedLayer).toBe(selectedLayer);
|
||||
expect(model.selectedLayerCount).toBe(2);
|
||||
expect(model.hasMultipleSelectedLayers).toBe(true);
|
||||
expect(model.activeGenerationLayer).toBe(generatedLayer);
|
||||
expect(model.generationAnchor).toBe(generatedLayer);
|
||||
expect(model.generationComposerStyle).toEqual({ left: 730, top: 710 });
|
||||
expect(model.selectedToolbarStyle).toEqual({ left: 330, top: 128 });
|
||||
expect(model.imageContextMenuLayer).toBe(generatedLayer);
|
||||
});
|
||||
@@ -100,7 +74,6 @@ describe('ImageCanvasStageControllerModel', () => {
|
||||
layers: [visibleLayer, hiddenLayer],
|
||||
selectedLayerId: visibleLayer.id,
|
||||
selectedLayerIds: [visibleLayer.id, hiddenLayer.id],
|
||||
activeCanvasGenerationDialog: null,
|
||||
imageContextMenu: null,
|
||||
contextMenu,
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
|
||||
@@ -7,14 +7,11 @@ import {
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
ImageContextMenuState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
resolveGenerationAnchor,
|
||||
resolveGenerationComposerStyle,
|
||||
resolveSelectedToolbarStyle,
|
||||
} from './ImageCanvasOverlayModel';
|
||||
|
||||
@@ -22,9 +19,6 @@ export type ImageCanvasStageControllerModel = {
|
||||
selectedLayer: CanvasLayer | null;
|
||||
selectedLayerCount: number;
|
||||
hasMultipleSelectedLayers: boolean;
|
||||
activeGenerationLayer: CanvasLayer | null;
|
||||
generationAnchor: CanvasLayer | CanvasGenerationDialogState['placeholder'] | null;
|
||||
generationComposerStyle: ReturnType<typeof resolveGenerationComposerStyle>;
|
||||
selectedToolbarStyle: ReturnType<typeof resolveSelectedToolbarStyle>;
|
||||
imageContextMenuLayer: CanvasLayer | null;
|
||||
contextTargetIds: string[];
|
||||
@@ -37,7 +31,6 @@ type ResolveImageCanvasStageControllerModelOptions = {
|
||||
layers: CanvasLayer[];
|
||||
selectedLayerId: string | null;
|
||||
selectedLayerIds: string[];
|
||||
activeCanvasGenerationDialog: CanvasGenerationDialogState | null;
|
||||
imageContextMenu: ImageContextMenuState | null;
|
||||
contextMenu: CanvasContextMenuState | null;
|
||||
viewport: CanvasViewport;
|
||||
@@ -48,7 +41,6 @@ export function resolveImageCanvasStageControllerModel({
|
||||
layers,
|
||||
selectedLayerId,
|
||||
selectedLayerIds,
|
||||
activeCanvasGenerationDialog,
|
||||
imageContextMenu,
|
||||
contextMenu,
|
||||
viewport,
|
||||
@@ -57,18 +49,6 @@ export function resolveImageCanvasStageControllerModel({
|
||||
const selectedLayer =
|
||||
layers.find((layer) => layer.id === selectedLayerId) ?? null;
|
||||
const selectedLayerCount = selectedLayerIds.length;
|
||||
const activeGenerationLayer =
|
||||
activeCanvasGenerationDialog?.generatedLayerId
|
||||
? (layers.find(
|
||||
(layer) => layer.id === activeCanvasGenerationDialog.generatedLayerId,
|
||||
) ?? null)
|
||||
: null;
|
||||
const generationAnchor = activeCanvasGenerationDialog
|
||||
? resolveGenerationAnchor({
|
||||
dialog: activeCanvasGenerationDialog,
|
||||
generatedLayer: activeGenerationLayer,
|
||||
})
|
||||
: null;
|
||||
const imageContextMenuLayer = imageContextMenu
|
||||
? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null)
|
||||
: null;
|
||||
@@ -82,13 +62,6 @@ export function resolveImageCanvasStageControllerModel({
|
||||
selectedLayer,
|
||||
selectedLayerCount,
|
||||
hasMultipleSelectedLayers: selectedLayerCount > 1,
|
||||
activeGenerationLayer,
|
||||
generationAnchor,
|
||||
generationComposerStyle: resolveGenerationComposerStyle({
|
||||
dialog: activeCanvasGenerationDialog,
|
||||
anchor: generationAnchor,
|
||||
viewport,
|
||||
}),
|
||||
selectedToolbarStyle: resolveSelectedToolbarStyle({
|
||||
selectedLayer,
|
||||
viewport,
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasTool,
|
||||
GenerateDialogState,
|
||||
ImageContextMenuState,
|
||||
SidebarPanel,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface';
|
||||
|
||||
vi.mock('../../services/image-editor/editorImageReference', () => ({
|
||||
resolveEditorImageReferenceDataUrl: vi.fn(async (src: string) => src),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/image-editor/editorProjectClient')
|
||||
>('../../services/image-editor/editorProjectClient');
|
||||
return {
|
||||
...actual,
|
||||
editEditorImage: vi.fn(),
|
||||
generateEditorCharacterAnimation: vi.fn(),
|
||||
generateEditorIconSpritesheet: vi.fn(),
|
||||
generateEditorImage: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||||
const id = overrides.id ?? 'layer-a';
|
||||
return {
|
||||
id,
|
||||
resourceId: `resource-${id}`,
|
||||
title: id,
|
||||
src: `data:image/png;base64,${id}`,
|
||||
x: 80,
|
||||
y: 90,
|
||||
width: 240,
|
||||
height: 180,
|
||||
originalWidth: 240,
|
||||
originalHeight: 180,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function GenerationSurfaceHarness() {
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>([createLayer()]);
|
||||
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
|
||||
const [activeSidebarPanel, setActiveSidebarPanel] =
|
||||
useState<SidebarPanel | null>('assets');
|
||||
const [, setMetadataLayer] = useState<CanvasLayer | null>(null);
|
||||
const [, setImageContextMenu] = useState<ImageContextMenuState | null>(null);
|
||||
const layerCounterRef = useRef(0);
|
||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const dialogs = useCanvasGenerationDialogs();
|
||||
const activeDialog = dialogs.generateDialog;
|
||||
const activeCanvasDialog =
|
||||
activeDialog && 'id' in activeDialog
|
||||
? (activeDialog as CanvasGenerationDialogState)
|
||||
: null;
|
||||
const surface = useImageCanvasGenerationSurface({
|
||||
layers,
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
viewport: { x: 10, y: 20, scale: 2 },
|
||||
layerCounterRef,
|
||||
specToolWrapRef,
|
||||
characterSpecButtonRef,
|
||||
characterReferenceButtonRef,
|
||||
iconSpecButtonRef,
|
||||
generateDialog: dialogs.generateDialog,
|
||||
setGenerateDialog: dialogs.setGenerateDialog,
|
||||
activeCanvasGenerationDialog: activeCanvasDialog,
|
||||
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogsByLayerId:
|
||||
dialogs.removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
||||
appendCanvasLayersWithResources: (nextLayers) =>
|
||||
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
|
||||
selectSingleLayer: () => {},
|
||||
fitLayers: () => {},
|
||||
setActiveTool,
|
||||
setActiveSidebarPanel,
|
||||
setMetadataLayer,
|
||||
setImageContextMenu,
|
||||
requestUpload: () => {},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span ref={specToolWrapRef}>规范入口</span>
|
||||
<span data-testid="tool">{activeTool}</span>
|
||||
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
|
||||
<span data-testid="dialog">
|
||||
{activeDialog
|
||||
? `${activeDialog.mode}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.placeholder ? 'placeholder' : '-'}`
|
||||
: '-'}
|
||||
</span>
|
||||
<span data-testid="composer-left">
|
||||
{surface.generationComposerStyle?.left ?? '-'}
|
||||
</span>
|
||||
<span data-testid="composer-top">
|
||||
{surface.generationComposerStyle?.top ?? '-'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => surface.switchGenerationTool('generate')}
|
||||
>
|
||||
切换生成
|
||||
</button>
|
||||
<button type="button" onClick={() => surface.switchGenerationTool('spec')}>
|
||||
切换规范
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => surface.switchGenerationTool('character')}
|
||||
>
|
||||
切换角色
|
||||
</button>
|
||||
<button type="button" onClick={() => surface.switchGenerationTool('icon')}>
|
||||
切换图标
|
||||
</button>
|
||||
<button type="button" onClick={() => surface.switchGenerationTool('text')}>
|
||||
切换文字
|
||||
</button>
|
||||
{surface.generationComposerNode}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useImageCanvasGenerationSurface', () => {
|
||||
it('switches generation tools while leaving non-generation tools untouched', () => {
|
||||
render(<GenerationSurfaceHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '切换文字' }));
|
||||
expect(screen.getByTestId('tool').textContent).toBe('select');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '切换生成' }));
|
||||
expect(screen.getByTestId('tool').textContent).toBe('generate');
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:open:placeholder',
|
||||
);
|
||||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('derives composer position and closes the generated image composer', () => {
|
||||
render(<GenerationSurfaceHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '切换生成' }));
|
||||
expect(screen.getByTestId('composer-left').textContent).toBe('450');
|
||||
expect(screen.getByTestId('composer-top').textContent).toBe('750');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭生成图片' }));
|
||||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||||
expect(screen.getByTestId('tool').textContent).toBe('select');
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'generate:closed:placeholder',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the spec menu through the generation surface', () => {
|
||||
render(<GenerationSurfaceHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '切换规范' }));
|
||||
expect(screen.getByTestId('tool').textContent).toBe('spec');
|
||||
const menu = screen.getByRole('menu', { name: '生成规范类型' });
|
||||
expect(within(menu).getByRole('menuitem', { name: '角色形象规范' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
256
src/components/image-editor/useImageCanvasGenerationSurface.tsx
Normal file
256
src/components/image-editor/useImageCanvasGenerationSurface.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
|
||||
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
||||
import {
|
||||
resolveCharacterAnimationPanelStyle,
|
||||
resolveGenerationAnchor,
|
||||
resolveGenerationComposerStyle,
|
||||
resolveIconComposerStyle,
|
||||
resolveQuickEditPanelStyle,
|
||||
} from './ImageCanvasOverlayModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasTool,
|
||||
CanvasViewport,
|
||||
GenerateDialogState,
|
||||
ImageContextMenuState,
|
||||
SidebarPanel,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
|
||||
|
||||
type CanvasGenerationDialogUpdater = (
|
||||
dialog: CanvasGenerationDialogState,
|
||||
) => CanvasGenerationDialogState | null;
|
||||
|
||||
type ImageCanvasGenerationSurfaceOptions = {
|
||||
layers: CanvasLayer[];
|
||||
canvasSize: { width: number; height: number };
|
||||
viewport: CanvasViewport;
|
||||
layerCounterRef: MutableRefObject<number>;
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
characterSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
characterReferenceButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
iconSpecButtonRef: RefObject<HTMLButtonElement | null>;
|
||||
generateDialog: GenerateDialogState | null;
|
||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||
activeCanvasGenerationDialog: CanvasGenerationDialogState | null;
|
||||
openCanvasGenerationDialog: (
|
||||
dialog: Omit<CanvasGenerationDialogState, 'id'>,
|
||||
) => void;
|
||||
updateCanvasGenerationDialogById: (
|
||||
dialogId: string,
|
||||
updater: CanvasGenerationDialogUpdater,
|
||||
) => void;
|
||||
removeCanvasGenerationDialogById: (dialogId: string) => void;
|
||||
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
|
||||
getGeneratingDialogPlaceholder: (
|
||||
dialog: GenerateDialogState,
|
||||
) => GenerateDialogState['placeholder'];
|
||||
appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
|
||||
selectSingleLayer: (layerId: string | null) => void;
|
||||
fitLayers: (targetLayers?: CanvasLayer[]) => void;
|
||||
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
|
||||
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
|
||||
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
|
||||
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||||
requestUpload: (target: UploadTarget) => void;
|
||||
};
|
||||
|
||||
export function useImageCanvasGenerationSurface({
|
||||
layers,
|
||||
canvasSize,
|
||||
viewport,
|
||||
layerCounterRef,
|
||||
specToolWrapRef,
|
||||
characterSpecButtonRef,
|
||||
characterReferenceButtonRef,
|
||||
iconSpecButtonRef,
|
||||
generateDialog,
|
||||
setGenerateDialog,
|
||||
activeCanvasGenerationDialog,
|
||||
openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder,
|
||||
appendCanvasLayersWithResources,
|
||||
selectSingleLayer,
|
||||
fitLayers,
|
||||
setActiveTool,
|
||||
setActiveSidebarPanel,
|
||||
setMetadataLayer,
|
||||
setImageContextMenu,
|
||||
requestUpload,
|
||||
}: ImageCanvasGenerationSurfaceOptions) {
|
||||
const generationWorkflow = useImageCanvasGenerationWorkflow({
|
||||
layers,
|
||||
canvasSize,
|
||||
viewport,
|
||||
layerCounterRef,
|
||||
generateDialog,
|
||||
setGenerateDialog,
|
||||
openCanvasGenerationDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogById,
|
||||
removeCanvasGenerationDialogsByLayerId,
|
||||
getGeneratingDialogPlaceholder,
|
||||
appendCanvasLayersWithResources,
|
||||
selectSingleLayer,
|
||||
fitLayers,
|
||||
setActiveTool,
|
||||
setActiveSidebarPanel,
|
||||
setMetadataLayer,
|
||||
setImageContextMenu,
|
||||
});
|
||||
|
||||
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,
|
||||
iconDescriptionCount: generationWorkflow.iconDescriptionValues.length,
|
||||
});
|
||||
const quickEditPanelStyle = resolveQuickEditPanelStyle({
|
||||
panel: generationWorkflow.quickEditPanel,
|
||||
sourceLayer: generationWorkflow.quickEditSourceLayer,
|
||||
viewport,
|
||||
canvasSize,
|
||||
});
|
||||
const characterAnimationPanelStyle = resolveCharacterAnimationPanelStyle({
|
||||
panel: generationWorkflow.characterAnimationPanel,
|
||||
sourceLayer: generationWorkflow.characterAnimationSourceLayer,
|
||||
viewport,
|
||||
canvasSize,
|
||||
});
|
||||
|
||||
const switchGenerationTool = useCallback(
|
||||
(tool: CanvasTool) => {
|
||||
if (tool === 'generate') {
|
||||
generationWorkflow.openGenerateDialog();
|
||||
return true;
|
||||
}
|
||||
if (tool === 'spec') {
|
||||
generationWorkflow.setIsSpecMenuOpen((open) => !open);
|
||||
setActiveTool('spec');
|
||||
return true;
|
||||
}
|
||||
if (tool === 'character') {
|
||||
generationWorkflow.openCharacterGenerationDialog();
|
||||
return true;
|
||||
}
|
||||
if (tool === 'icon') {
|
||||
generationWorkflow.openIconGenerationDialog();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[generationWorkflow, setActiveTool],
|
||||
);
|
||||
|
||||
const generationComposerNode = (
|
||||
<ImageCanvasGenerationComposerView
|
||||
specToolWrapRef={specToolWrapRef}
|
||||
characterSpecButtonRef={characterSpecButtonRef}
|
||||
characterReferenceButtonRef={characterReferenceButtonRef}
|
||||
iconSpecButtonRef={iconSpecButtonRef}
|
||||
isSpecMenuOpen={generationWorkflow.isSpecMenuOpen}
|
||||
isCharacterSpecMenuOpen={generationWorkflow.isCharacterSpecMenuOpen}
|
||||
isCharacterReferenceMenuOpen={
|
||||
generationWorkflow.isCharacterReferenceMenuOpen
|
||||
}
|
||||
isIconSpecMenuOpen={generationWorkflow.isIconSpecMenuOpen}
|
||||
isPickingCharacterSpecFromCanvas={
|
||||
generationWorkflow.isPickingCharacterSpecFromCanvas
|
||||
}
|
||||
isPickingCharacterReferenceFromCanvas={
|
||||
generationWorkflow.isPickingCharacterReferenceFromCanvas
|
||||
}
|
||||
isPickingIconSpecFromCanvas={
|
||||
generationWorkflow.isPickingIconSpecFromCanvas
|
||||
}
|
||||
generateDialog={generateDialog}
|
||||
generationComposerStyle={generationComposerStyle}
|
||||
iconComposerStyle={iconComposerStyle}
|
||||
quickEditPanel={generationWorkflow.quickEditPanel}
|
||||
quickEditSourceLayer={generationWorkflow.quickEditSourceLayer}
|
||||
quickEditPanelStyle={quickEditPanelStyle}
|
||||
quickEditSizeOptions={generationWorkflow.quickEditSizeOptions}
|
||||
quickEditModelOptions={generationWorkflow.quickEditModelOptions}
|
||||
characterAnimationPanel={generationWorkflow.characterAnimationPanel}
|
||||
characterAnimationSourceLayer={
|
||||
generationWorkflow.characterAnimationSourceLayer
|
||||
}
|
||||
characterAnimationPanelStyle={characterAnimationPanelStyle}
|
||||
characterAnimationPrice={generationWorkflow.characterAnimationPrice}
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
setQuickEditPanel={generationWorkflow.setQuickEditPanel}
|
||||
setCharacterAnimationPanel={
|
||||
generationWorkflow.setCharacterAnimationPanel
|
||||
}
|
||||
setIsCharacterSpecMenuOpen={
|
||||
generationWorkflow.setIsCharacterSpecMenuOpen
|
||||
}
|
||||
setIsCharacterReferenceMenuOpen={
|
||||
generationWorkflow.setIsCharacterReferenceMenuOpen
|
||||
}
|
||||
setIsIconSpecMenuOpen={generationWorkflow.setIsIconSpecMenuOpen}
|
||||
setIsPickingCharacterSpecFromCanvas={
|
||||
generationWorkflow.setIsPickingCharacterSpecFromCanvas
|
||||
}
|
||||
setIsPickingCharacterReferenceFromCanvas={
|
||||
generationWorkflow.setIsPickingCharacterReferenceFromCanvas
|
||||
}
|
||||
setIsPickingIconSpecFromCanvas={
|
||||
generationWorkflow.setIsPickingIconSpecFromCanvas
|
||||
}
|
||||
onOpenSpecDialog={generationWorkflow.openSpecDialog}
|
||||
onRequestUpload={requestUpload}
|
||||
onSubmitImageGeneration={(dialog) =>
|
||||
void generationWorkflow.submitImageGeneration(dialog)
|
||||
}
|
||||
onSubmitIconSpritesheetGeneration={(dialog) =>
|
||||
void generationWorkflow.submitIconSpritesheetGeneration(dialog)
|
||||
}
|
||||
onSubmitQuickEdit={() => void generationWorkflow.submitQuickEdit()}
|
||||
onSubmitCharacterAnimation={() =>
|
||||
void generationWorkflow.submitCharacterAnimation()
|
||||
}
|
||||
onCloseGenerateComposer={generationWorkflow.closeGenerateComposer}
|
||||
onUpdateSpecFormValue={generationWorkflow.updateSpecFormValue}
|
||||
onUpdateIconDescription={generationWorkflow.updateIconDescription}
|
||||
onAddIconDescription={generationWorkflow.addIconDescription}
|
||||
onUpdateCharacterAnimationDuration={
|
||||
generationWorkflow.updateCharacterAnimationDuration
|
||||
}
|
||||
onRememberImageModel={generationWorkflow.rememberImageModel}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
...generationWorkflow,
|
||||
generationComposerStyle,
|
||||
generationComposerNode,
|
||||
switchGenerationTool,
|
||||
};
|
||||
}
|
||||
@@ -53,7 +53,6 @@ function StageControllerHarness({
|
||||
layers,
|
||||
selectedLayerId,
|
||||
selectedLayerIds,
|
||||
activeCanvasGenerationDialog: null,
|
||||
imageContextMenu,
|
||||
setImageContextMenu,
|
||||
contextMenu,
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
|
||||
import type {
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
ImageContextMenuState,
|
||||
@@ -23,7 +22,6 @@ type UseImageCanvasStageControllerOptions = {
|
||||
layers: CanvasLayer[];
|
||||
selectedLayerId: string | null;
|
||||
selectedLayerIds: string[];
|
||||
activeCanvasGenerationDialog: CanvasGenerationDialogState | null;
|
||||
imageContextMenu: ImageContextMenuState | null;
|
||||
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||||
contextMenu: CanvasContextMenuState | null;
|
||||
@@ -42,7 +40,6 @@ export function useImageCanvasStageController({
|
||||
layers,
|
||||
selectedLayerId,
|
||||
selectedLayerIds,
|
||||
activeCanvasGenerationDialog,
|
||||
imageContextMenu,
|
||||
setImageContextMenu,
|
||||
contextMenu,
|
||||
@@ -59,14 +56,12 @@ export function useImageCanvasStageController({
|
||||
layers,
|
||||
selectedLayerId,
|
||||
selectedLayerIds,
|
||||
activeCanvasGenerationDialog,
|
||||
imageContextMenu,
|
||||
contextMenu,
|
||||
viewport,
|
||||
canvasSize,
|
||||
}),
|
||||
[
|
||||
activeCanvasGenerationDialog,
|
||||
canvasSize,
|
||||
contextMenu,
|
||||
imageContextMenu,
|
||||
|
||||
Reference in New Issue
Block a user