完善快速编辑参考图与生成占位

- 快速编辑支持最多8张额外参考图,并将原图自动作为最后一张参考图提交

- 快速编辑尺寸和模型沿用原图配置,按禁用态参数胶囊展示

- 快速编辑提交前同步改写原图引用为实际图序号

- 快速编辑生成改为创建独立画布生成占位,并支持键盘删除生成中对象

- 对齐生成类面板参考图图标、规范类固定参数和相关文档测试
This commit is contained in:
2026-06-18 22:39:03 +08:00
parent b04b6cee2b
commit ea29887d9c
65 changed files with 2819 additions and 531 deletions
@@ -1,9 +1,12 @@
import {
type Dispatch,
type MutableRefObject,
type PointerEvent as ReactPointerEvent,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -14,6 +17,7 @@ import type {
CanvasViewport,
CharacterAnimationPanelState,
CropExpandPanelState,
CropExpandResizeHandle,
GenerateDialogState,
ImageContextMenuState,
QuickEditPanelState,
@@ -24,6 +28,7 @@ import type {
import {
appendCharacterReference,
appendGenerationReference,
appendQuickEditReference,
assignCharacterSpecReference,
assignIconSpecReference,
assignUiDesignSpecReference,
@@ -46,8 +51,6 @@ import {
updateSpecFormDialogValue,
} from './ImageCanvasGenerationDialogModel';
import {
buildQuickEditModelOptions,
buildQuickEditSizeOptions,
calculateCharacterAnimationPrice,
CHARACTER_ANIMATION_DURATION_OPTIONS,
DEFAULT_ICON_DESCRIPTIONS,
@@ -60,15 +63,26 @@ import {
chooseGenerationPlacement,
} from './ImageCanvasGenerationPlacementModel';
import {
buildCropExpandLayerPatch,
parseCropExpandInsetValue,
buildCropExpandInsetsFromFrame,
removeImageBackground,
renderCropExpandImage,
} from './ImageCanvasRasterEditModel';
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow';
type CanvasSize = { width: number; height: number };
type CropExpandResizeDragState = {
pointerId: number;
handle: CropExpandResizeHandle;
sourceLayerId: string;
startClientX: number;
startClientY: number;
startScale: number;
startFrame: CropExpandPanelState['frame'];
ratio: CropExpandPanelState['ratio'];
};
type CanvasGenerationDialogUpdater = (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null;
@@ -142,7 +156,7 @@ type GenerationWorkflowOptions = {
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
openCanvasGenerationDialog: (
dialog: Omit<CanvasGenerationDialogState, 'id'>,
) => void;
) => string;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: CanvasGenerationDialogUpdater,
@@ -200,6 +214,10 @@ export function useImageCanvasGenerationWorkflow({
isPickingGenerationReferenceFromCanvas,
setIsPickingGenerationReferenceFromCanvas,
] = useState(false);
const [
isPickingQuickEditReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
] = useState(false);
const [
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
@@ -219,6 +237,10 @@ export function useImageCanvasGenerationWorkflow({
useState<QuickEditPanelState | null>(null);
const [cropExpandPanel, setCropExpandPanel] =
useState<CropExpandPanelState | null>(null);
const cropExpandPanelRef = useRef<CropExpandPanelState | null>(null);
const cropExpandResizeDragRef = useRef<CropExpandResizeDragState | null>(
null,
);
const [characterAnimationPanel, setCharacterAnimationPanel] =
useState<CharacterAnimationPanelState | null>(null);
const [lastImageModel, setLastImageModel] = useState(DEFAULT_IMAGE_MODEL);
@@ -232,6 +254,8 @@ export function useImageCanvasGenerationWorkflow({
const effectiveCharacterAnimationPanel =
characterAnimationDialogPanel ?? characterAnimationPanel;
cropExpandPanelRef.current = cropExpandPanel;
const quickEditSourceLayer = quickEditPanel
? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ??
null)
@@ -245,16 +269,6 @@ export function useImageCanvasGenerationWorkflow({
(layer) => layer.id === effectiveCharacterAnimationPanel.sourceLayerId,
) ?? null)
: null;
const quickEditSizeOptions = useMemo(
() =>
quickEditPanel ? buildQuickEditSizeOptions(quickEditPanel.size) : [],
[quickEditPanel],
);
const quickEditModelOptions = useMemo(
() =>
quickEditPanel ? buildQuickEditModelOptions(quickEditPanel.model) : [],
[quickEditPanel],
);
const characterAnimationPrice = effectiveCharacterAnimationPanel
? calculateCharacterAnimationPrice(
effectiveCharacterAnimationPanel.resolution,
@@ -275,6 +289,7 @@ export function useImageCanvasGenerationWorkflow({
const closeGenerationTransientState = useCallback(() => {
setIsSpecMenuOpen(false);
setIsGenerationReferenceMenuOpen(false);
setIsPickingQuickEditReferenceFromCanvas(false);
setIsCharacterSpecMenuOpen(false);
setIsCharacterReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
@@ -562,12 +577,13 @@ export function useImageCanvasGenerationWorkflow({
setCharacterAnimationPanel(null);
setCropExpandPanel({
sourceLayerId: sourceLayer.id,
insets: {
left: '0',
top: '0',
right: '0',
bottom: '0',
frame: {
x: sourceLayer.x,
y: sourceLayer.y,
width: sourceLayer.width,
height: sourceLayer.height,
},
ratio: 'free',
status: 'idle',
});
selectSingleLayer(sourceLayer.id);
@@ -582,18 +598,98 @@ export function useImageCanvasGenerationWorkflow({
],
);
const startCropExpandFrameResize = useCallback(
(
event: ReactPointerEvent<HTMLButtonElement>,
handle: CropExpandResizeHandle,
) => {
const currentPanel = cropExpandPanelRef.current;
if (!currentPanel) {
return;
}
event.preventDefault();
event.stopPropagation();
cropExpandResizeDragRef.current = {
pointerId: event.pointerId,
handle,
sourceLayerId: currentPanel.sourceLayerId,
startClientX: event.clientX,
startClientY: event.clientY,
startScale:
Number.isFinite(viewport.scale) && viewport.scale > 0
? viewport.scale
: 1,
startFrame: currentPanel.frame,
ratio: currentPanel.ratio,
};
selectSingleLayer(currentPanel.sourceLayerId);
},
[selectSingleLayer, viewport.scale],
);
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
const dragState = cropExpandResizeDragRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
const deltaX =
(event.clientX - dragState.startClientX) / dragState.startScale;
const deltaY =
(event.clientY - dragState.startClientY) / dragState.startScale;
const nextFrame = resizeCropExpandFrame({
frame: dragState.startFrame,
handle: dragState.handle,
deltaX,
deltaY,
ratio: dragState.ratio,
});
setCropExpandPanel((currentPanel) =>
currentPanel?.sourceLayerId === dragState.sourceLayerId
? {
...currentPanel,
frame: nextFrame,
status:
currentPanel.status === 'failed'
? 'idle'
: currentPanel.status,
errorMessage:
currentPanel.status === 'failed'
? undefined
: currentPanel.errorMessage,
}
: currentPanel,
);
};
const handlePointerUp = (event: PointerEvent) => {
const dragState = cropExpandResizeDragRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
cropExpandResizeDragRef.current = null;
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
}, [setCropExpandPanel]);
const submitCropExpand = useCallback(async () => {
if (!cropExpandPanel || !cropExpandSourceLayer) {
return;
}
try {
const insets = {
left: parseCropExpandInsetValue(cropExpandPanel.insets.left),
top: parseCropExpandInsetValue(cropExpandPanel.insets.top),
right: parseCropExpandInsetValue(cropExpandPanel.insets.right),
bottom: parseCropExpandInsetValue(cropExpandPanel.insets.bottom),
};
const insets = buildCropExpandInsetsFromFrame({
layer: cropExpandSourceLayer,
frame: cropExpandPanel.frame,
});
setCropExpandPanel({
...cropExpandPanel,
status: 'processing',
@@ -603,21 +699,32 @@ export function useImageCanvasGenerationWorkflow({
source: cropExpandSourceLayer.src,
insets,
});
const layerPatch = buildCropExpandLayerPatch({
layerX: cropExpandSourceLayer.x,
layerY: cropExpandSourceLayer.y,
insets,
outputWidth: result.width,
outputHeight: result.height,
});
updateSourceLayer(cropExpandSourceLayer.id, (layer) => ({
...layer,
...layerPatch,
layerCounterRef.current += 1;
const cropExpandIndex = layerCounterRef.current;
const nextLayer: CanvasLayer = {
...cropExpandSourceLayer,
id: `layer-crop-expand-${cropExpandIndex}`,
resourceId: `local-resource-crop-expand-${cropExpandIndex}`,
title: `${cropExpandSourceLayer.title} 裁扩`,
src: result.imageSrc,
x: cropExpandSourceLayer.x + cropExpandSourceLayer.width + 32,
y: cropExpandSourceLayer.y,
width: result.width,
height: result.height,
originalWidth: result.width,
originalHeight: result.height,
zIndex: cropExpandIndex + 10,
sourceType: 'generated',
sourceResourceId: cropExpandSourceLayer.resourceId,
objectKey: null,
assetObjectId: null,
sourceAssetId: null,
}));
};
captureCanvasHistory();
appendCanvasLayersWithResources([nextLayer]);
persistGeneratedAsset?.(nextLayer);
selectSingleLayer(nextLayer.id);
fitLayers([cropExpandSourceLayer, nextLayer]);
setCropExpandPanel(null);
setActiveSidebarPanel('layers');
} catch (error) {
@@ -633,8 +740,13 @@ export function useImageCanvasGenerationWorkflow({
}, [
cropExpandPanel,
cropExpandSourceLayer,
appendCanvasLayersWithResources,
captureCanvasHistory,
fitLayers,
layerCounterRef,
persistGeneratedAsset,
selectSingleLayer,
setActiveSidebarPanel,
updateSourceLayer,
]);
const removeSelectedLayerBackground = useCallback(
@@ -697,6 +809,17 @@ export function useImageCanvasGenerationWorkflow({
[setGenerateDialog, setImageContextMenu],
);
const pickQuickEditReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
setQuickEditPanel((currentPanel) =>
appendQuickEditReference(currentPanel, layer),
);
setIsPickingQuickEditReferenceFromCanvas(false);
setImageContextMenu(null);
},
[setImageContextMenu],
);
const pickCharacterReferenceFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
@@ -710,29 +833,30 @@ export function useImageCanvasGenerationWorkflow({
const pickIconSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
assignIconSpecReference(currentDialog, layer),
);
if (layer.assetKind !== 'icon-spec') {
const nextDialog = assignIconSpecReference(generateDialog, layer);
setGenerateDialog(nextDialog);
if (nextDialog === generateDialog) {
return;
}
setIsPickingIconSpecFromCanvas(false);
setIsIconSpecMenuOpen(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
[generateDialog, setGenerateDialog, setImageContextMenu],
);
const pickUiDesignSpecFromLayer = useCallback(
(layer: CanvasLayer) => {
setGenerateDialog((currentDialog) =>
assignUiDesignSpecReference(currentDialog, layer),
);
const nextDialog = assignUiDesignSpecReference(generateDialog, layer);
setGenerateDialog(nextDialog);
if (nextDialog === generateDialog) {
return;
}
setIsPickingUiDesignSpecFromCanvas(false);
setIsUiDesignSpecMenuOpen(false);
setImageContextMenu(null);
},
[setGenerateDialog, setImageContextMenu],
[generateDialog, setGenerateDialog, setImageContextMenu],
);
const updateIconDescriptionsText = useCallback(
@@ -757,6 +881,7 @@ export function useImageCanvasGenerationWorkflow({
characterAnimationSourceLayer,
setCharacterAnimationPanel,
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getGeneratingDialogPlaceholder,
@@ -865,6 +990,16 @@ export function useImageCanvasGenerationWorkflow({
setQuickEditPanel((currentPanel) =>
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
);
setQuickEditPanel((currentPanel) =>
currentPanel
? {
...currentPanel,
quickEditReferences: (
currentPanel.quickEditReferences ?? []
).filter((reference) => reference.id !== `canvas-${targetLayerId}`),
}
: currentPanel,
);
setCropExpandPanel((currentPanel) =>
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
);
@@ -890,8 +1025,6 @@ export function useImageCanvasGenerationWorkflow({
cropExpandPanel,
setCropExpandPanel,
cropExpandSourceLayer,
quickEditSizeOptions,
quickEditModelOptions,
characterAnimationPanel: effectiveCharacterAnimationPanel,
setCharacterAnimationPanel: setEffectiveCharacterAnimationPanel,
characterAnimationSourceLayer,
@@ -907,6 +1040,8 @@ export function useImageCanvasGenerationWorkflow({
setIsCharacterReferenceMenuOpen,
isPickingGenerationReferenceFromCanvas,
setIsPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
@@ -934,9 +1069,11 @@ export function useImageCanvasGenerationWorkflow({
openQuickEditPanel,
openRedrawPanel,
openCropExpandPanel,
startCropExpandFrameResize,
removeSelectedLayerBackground,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickQuickEditReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
@@ -971,6 +1108,7 @@ export function useImageCanvasGenerationWorkflow({
isPickingCharacterReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
isPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingUiDesignSpecFromCanvas,
isUiDesignSpecMenuOpen,
@@ -987,16 +1125,16 @@ export function useImageCanvasGenerationWorkflow({
openUiDesignGenerationDialog,
openQuickEditPanel,
openSpecDialog,
startCropExpandFrameResize,
openSoundEffectGenerationDialog,
openVideoGenerationDialog,
pickCharacterReferenceFromLayer,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickQuickEditReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
quickEditModelOptions,
quickEditPanel,
quickEditSizeOptions,
quickEditSourceLayer,
removeSelectedLayerBackground,
submitCharacterAnimation,