合并:同步最新master
合并 origin/master 最新提交 7b075df39
保留当前分支的 BGM 优化与共享音频面板方案
# Conflicts:
# docs/project-memory/shared-memory/decision-log.md
# docs/project-memory/shared-memory/pitfalls.md
# server-rs/crates/api-server/src/editor_generation_queue.rs
# src/components/image-editor/useImageCanvasGenerationSurface.tsx
# src/services/image-editor/editorProjectClient.ts
This commit is contained in:
@@ -54,6 +54,7 @@ type ImageCanvasBasicGenerationComposerViewProps = {
|
||||
onPickReferenceFromCanvas?: () => void;
|
||||
onToggleReferenceMenu?: () => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
dialogLabel?: string;
|
||||
includeDimensions?: boolean;
|
||||
@@ -107,6 +108,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
onPickReferenceFromCanvas,
|
||||
onToggleReferenceMenu,
|
||||
onRememberImageModel = () => {},
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
dialogLabel,
|
||||
includeDimensions = true,
|
||||
@@ -271,6 +273,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
includeDimensions={shouldIncludeDimensions}
|
||||
includeModel={includeModel}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
|
||||
dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix}
|
||||
optionLabelPrefix={resolvedOptionLabelPrefix}
|
||||
|
||||
@@ -44,6 +44,7 @@ type ImageCanvasCharacterGenerationComposerViewProps = {
|
||||
onOpenSpecDialog: (specType: SpecGenerationType) => void;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
@@ -97,6 +98,7 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
onOpenSpecDialog,
|
||||
onRequestUpload,
|
||||
onRememberImageModel,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
}: ImageCanvasCharacterGenerationComposerViewProps) {
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
@@ -289,6 +291,7 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
kind: 'character',
|
||||
|
||||
@@ -390,6 +390,7 @@ export type CanvasContextMenuState =
|
||||
};
|
||||
|
||||
export type QuickEditPanelState = {
|
||||
referenceUploadContextId?: string;
|
||||
mode?: 'quick-edit' | 'redraw';
|
||||
sourceLayerId: string;
|
||||
prompt: string;
|
||||
|
||||
@@ -58,12 +58,17 @@ import type {
|
||||
ImageContextMenuState,
|
||||
QuickEditPanelState,
|
||||
SidebarPanel,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
|
||||
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
|
||||
import {
|
||||
applyEditorGenerationPricingConfig,
|
||||
isCanvasGenerationDialog,
|
||||
QUICK_EDIT_REFERENCE_LIMIT,
|
||||
resolveDialogExtraImageReferenceLimit,
|
||||
resolveExtraImageReferenceLimit,
|
||||
UI_EXTRA_REFERENCE_LIMIT,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel';
|
||||
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
|
||||
@@ -97,7 +102,10 @@ import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||
import { useImageCanvasStageController } from './useImageCanvasStageController';
|
||||
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
|
||||
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
||||
import {
|
||||
type ImageReferenceUploadCapacity,
|
||||
useImageCanvasUploadWorkflow,
|
||||
} from './useImageCanvasUploadWorkflow';
|
||||
import {
|
||||
DEFAULT_IMAGE_CANVAS_VIEWPORT,
|
||||
useImageCanvasViewportControls,
|
||||
@@ -430,6 +438,9 @@ export function ImageCanvasEditorView({
|
||||
const appendUiAssetExtractionReferencesRef = useRef<
|
||||
(references: CharacterReferenceImage[]) => void
|
||||
>(() => {});
|
||||
const resolveImageReferenceUploadCapacityRef = useRef<
|
||||
(target: UploadTarget) => ImageReferenceUploadCapacity | null
|
||||
>(() => null);
|
||||
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
|
||||
() => {},
|
||||
);
|
||||
@@ -672,6 +683,9 @@ export function ImageCanvasEditorView({
|
||||
discardCanvasHistoryEntriesContainingLayerRef.current(matchesLayer),
|
||||
[],
|
||||
);
|
||||
const canDeleteLayersRef = useRef<(targetLayerIds: string[]) => boolean>(
|
||||
() => true,
|
||||
);
|
||||
const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({
|
||||
layers,
|
||||
setLayers,
|
||||
@@ -679,6 +693,8 @@ export function ImageCanvasEditorView({
|
||||
setSelectedLayerIds,
|
||||
onDeleteLayerSideEffects: (layerId) =>
|
||||
clearDeletedLayerGenerationStateRef.current(layerId),
|
||||
canDeleteLayers: (targetLayerIds) =>
|
||||
canDeleteLayersRef.current(targetLayerIds),
|
||||
discardHistoryEntriesContainingLayer: discardAssetRelatedCanvasHistory,
|
||||
});
|
||||
const {
|
||||
@@ -734,6 +750,14 @@ export function ImageCanvasEditorView({
|
||||
onDeleteAssets: removeCanvasLayersLinkedToAssets,
|
||||
});
|
||||
|
||||
const pendingImageReferenceUploadCountRef = useRef(0);
|
||||
const isPendingReferenceUploadContextMutationLocked = useCallback(
|
||||
() => pendingImageReferenceUploadCountRef.current > 0,
|
||||
[],
|
||||
);
|
||||
const rejectPendingReferenceUploadContextMutation = useCallback(() => {
|
||||
window.alert('参考图正在上传,请等待上传完成后再切换或关闭生成面板');
|
||||
}, []);
|
||||
const handleActivateCanvasGenerationDialog = useCallback(() => {
|
||||
closeGenerationTransientStateRef.current();
|
||||
setSelectedLayerId(null);
|
||||
@@ -762,6 +786,8 @@ export function ImageCanvasEditorView({
|
||||
getGeneratingDialogPlaceholder,
|
||||
} = useCanvasGenerationDialogs({
|
||||
onActivate: handleActivateCanvasGenerationDialog,
|
||||
isContextMutationLocked: isPendingReferenceUploadContextMutationLocked,
|
||||
onContextMutationRejected: rejectPendingReferenceUploadContextMutation,
|
||||
});
|
||||
canvasGenerationDialogsRef.current = canvasGenerationDialogs;
|
||||
const canvasHistoryRefs = useMemo(
|
||||
@@ -1239,7 +1265,7 @@ export function ImageCanvasEditorView({
|
||||
captureCanvasHistory(action);
|
||||
applyProjectSnapshot(project);
|
||||
if (action.type !== 'perfect-pixel') {
|
||||
void refreshAssetLibrary();
|
||||
void refreshAssetLibrary();
|
||||
}
|
||||
},
|
||||
[applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary],
|
||||
@@ -1345,6 +1371,7 @@ export function ImageCanvasEditorView({
|
||||
const {
|
||||
uploadInputRef,
|
||||
uploadTarget,
|
||||
pendingImageReferenceUploadCount,
|
||||
requestUpload,
|
||||
handleUploadInputChange,
|
||||
addUploadedFiles,
|
||||
@@ -1366,12 +1393,17 @@ export function ImageCanvasEditorView({
|
||||
setLayers,
|
||||
setGenerateDialog,
|
||||
setQuickEditPanel: (updater) => setQuickEditPanelRef.current(updater),
|
||||
resolveImageReferenceUploadCapacity: (target) =>
|
||||
resolveImageReferenceUploadCapacityRef.current(target),
|
||||
appendUiAssetExtractionReferences: (references) =>
|
||||
appendUiAssetExtractionReferencesRef.current(references),
|
||||
appendCanvasLayersWithResources,
|
||||
captureCanvasHistory,
|
||||
selectSingleLayer,
|
||||
refreshAssetLibrary,
|
||||
});
|
||||
pendingImageReferenceUploadCountRef.current =
|
||||
pendingImageReferenceUploadCount;
|
||||
const generationSurface = useImageCanvasGenerationSurface({
|
||||
layers,
|
||||
canvasSize,
|
||||
@@ -1388,6 +1420,7 @@ export function ImageCanvasEditorView({
|
||||
generationReferenceButtonRef,
|
||||
publicationReferenceButtonRef,
|
||||
iconSpecButtonRef,
|
||||
hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0,
|
||||
generateDialog,
|
||||
setGenerateDialog,
|
||||
activeCanvasGenerationDialog,
|
||||
@@ -1551,6 +1584,7 @@ export function ImageCanvasEditorView({
|
||||
clearDeletedLayerGenerationState,
|
||||
uiAssetExtractionState,
|
||||
uiAssetExtractionSourceLayer,
|
||||
quickEditPanel,
|
||||
quickEditSelectionState,
|
||||
quickEditSelectionSourceLayer,
|
||||
changeUiAssetExtractionTool,
|
||||
@@ -1595,6 +1629,137 @@ export function ImageCanvasEditorView({
|
||||
clearDeletedLayerGenerationState;
|
||||
appendUiAssetExtractionReferencesRef.current =
|
||||
appendUiAssetExtractionReferences;
|
||||
resolveImageReferenceUploadCapacityRef.current = (target) => {
|
||||
const imageReferenceCount = (references: CharacterReferenceImage[] = []) =>
|
||||
references.filter(
|
||||
(reference) => (reference.mediaType ?? 'image') === 'image',
|
||||
).length;
|
||||
const generateDialogContextId = generateDialog
|
||||
? (generateDialog.id ??
|
||||
`${generateDialog.mode}:${generateDialog.sourceLayerId ?? 'draft'}`)
|
||||
: undefined;
|
||||
if (target === 'spec-reference') {
|
||||
return generateDialog?.mode === 'spec'
|
||||
? {
|
||||
label: '规范参考图',
|
||||
currentCount: 0,
|
||||
limit: 1,
|
||||
contextId: generateDialogContextId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (target === 'character-spec') {
|
||||
if (generateDialog?.mode !== 'character') {
|
||||
return null;
|
||||
}
|
||||
const referencesFit =
|
||||
imageReferenceCount(generateDialog.characterReferences) <=
|
||||
resolveDialogExtraImageReferenceLimit(generateDialog);
|
||||
return {
|
||||
label: '角色主图',
|
||||
currentCount: 0,
|
||||
limit: referencesFit ? 1 : 0,
|
||||
contextId: generateDialogContextId,
|
||||
};
|
||||
}
|
||||
if (target === 'icon-spec') {
|
||||
if (generateDialog?.mode !== 'icon') {
|
||||
return null;
|
||||
}
|
||||
const referencesFit =
|
||||
imageReferenceCount(generateDialog.generationReferences) <=
|
||||
resolveDialogExtraImageReferenceLimit(generateDialog);
|
||||
return {
|
||||
label: '图标规范图',
|
||||
currentCount: 0,
|
||||
limit: referencesFit ? 1 : 0,
|
||||
contextId: generateDialogContextId,
|
||||
};
|
||||
}
|
||||
if (target === 'ui-design-icon-spec') {
|
||||
if (generateDialog?.mode !== 'ui-design') {
|
||||
return null;
|
||||
}
|
||||
const referencesFit =
|
||||
imageReferenceCount(generateDialog.generationReferences) <=
|
||||
resolveDialogExtraImageReferenceLimit(generateDialog);
|
||||
return {
|
||||
label: 'UI设计规范图',
|
||||
currentCount: 0,
|
||||
limit: referencesFit ? 1 : 0,
|
||||
contextId: generateDialogContextId,
|
||||
};
|
||||
}
|
||||
if (target === 'character-reference') {
|
||||
return generateDialog?.mode === 'character'
|
||||
? {
|
||||
label: '角色参考图',
|
||||
currentCount: imageReferenceCount(
|
||||
generateDialog.characterReferences,
|
||||
),
|
||||
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
|
||||
contextId: generateDialogContextId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (target === 'generation-reference') {
|
||||
return generateDialog && generateDialog.mode !== 'video'
|
||||
? {
|
||||
label: '生成参考图',
|
||||
currentCount: imageReferenceCount(
|
||||
generateDialog.generationReferences,
|
||||
),
|
||||
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
|
||||
contextId: generateDialogContextId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (target === 'publication-reference') {
|
||||
return generateDialog?.mode === 'publication'
|
||||
? {
|
||||
label: '宣发参考图',
|
||||
currentCount: imageReferenceCount(
|
||||
generateDialog.publicationReferences,
|
||||
),
|
||||
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
|
||||
contextId: generateDialogContextId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (target === 'quick-edit-reference') {
|
||||
return quickEditPanel && quickEditPanel.mode !== 'redraw'
|
||||
? {
|
||||
label: '快速编辑参考图',
|
||||
currentCount: imageReferenceCount(
|
||||
quickEditPanel.quickEditReferences,
|
||||
),
|
||||
limit: resolveExtraImageReferenceLimit(
|
||||
quickEditPanel.model,
|
||||
QUICK_EDIT_REFERENCE_LIMIT,
|
||||
1,
|
||||
),
|
||||
contextId: quickEditPanel.referenceUploadContextId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (target === 'ui-asset-extraction-reference') {
|
||||
return uiAssetExtractionState
|
||||
? {
|
||||
label: 'UI素材参考图',
|
||||
currentCount: imageReferenceCount(
|
||||
uiAssetExtractionState.references,
|
||||
),
|
||||
limit: resolveExtraImageReferenceLimit(
|
||||
uiAssetExtractionState.model,
|
||||
UI_EXTRA_REFERENCE_LIMIT,
|
||||
1,
|
||||
),
|
||||
contextId: uiAssetExtractionState.referenceUploadContextId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!isProjectReady || startupIntentConsumedRef.current) {
|
||||
return;
|
||||
@@ -1737,6 +1902,33 @@ export function ImageCanvasEditorView({
|
||||
contextMenu?.kind === 'layer'
|
||||
? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null)
|
||||
: null;
|
||||
const canDeleteLayersDuringReferenceUpload = useCallback(
|
||||
(targetLayerIds: string[]) => {
|
||||
if (pendingImageReferenceUploadCountRef.current <= 0) {
|
||||
return true;
|
||||
}
|
||||
const protectedSourceLayerIds = [
|
||||
generateDialog?.sourceLayerId,
|
||||
generationSurface.quickEditPanel?.sourceLayerId,
|
||||
uiAssetExtractionState?.sourceLayerId,
|
||||
].filter((layerId): layerId is string => Boolean(layerId));
|
||||
if (
|
||||
!targetLayerIds.some((layerId) =>
|
||||
protectedSourceLayerIds.includes(layerId),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
window.alert('参考图正在上传,请等待上传完成后再删除当前生成源图');
|
||||
return false;
|
||||
},
|
||||
[
|
||||
generateDialog?.sourceLayerId,
|
||||
generationSurface.quickEditPanel?.sourceLayerId,
|
||||
uiAssetExtractionState?.sourceLayerId,
|
||||
],
|
||||
);
|
||||
canDeleteLayersRef.current = canDeleteLayersDuringReferenceUpload;
|
||||
const {
|
||||
canvasClipboard,
|
||||
canCopyContextLayers,
|
||||
@@ -1781,6 +1973,8 @@ export function ImageCanvasEditorView({
|
||||
onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog,
|
||||
exportLayerImage,
|
||||
onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning,
|
||||
canDeleteLayers: (targetLayerIds) =>
|
||||
canDeleteLayersRef.current(targetLayerIds),
|
||||
});
|
||||
const {
|
||||
canvasMarquee,
|
||||
@@ -2330,6 +2524,7 @@ export function ImageCanvasEditorView({
|
||||
: uiAssetExtractionSourceLayer,
|
||||
quickEditSelectionState,
|
||||
quickEditSelectionSourceLayer,
|
||||
hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0,
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
perfectPixelLayerIds,
|
||||
|
||||
@@ -83,6 +83,7 @@ type ImageCanvasGenerationComposerViewProps = {
|
||||
isPickingCharacterReferenceFromCanvas: boolean;
|
||||
isPickingIconSpecFromCanvas: boolean;
|
||||
isPickingUiDesignSpecFromCanvas: boolean;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
generateDialog: GenerateDialogState | null;
|
||||
backgroundMusicPromptAssist?: BackgroundMusicPromptAssistComposerController;
|
||||
updateCanvasGenerationDialogById: (
|
||||
@@ -1002,6 +1003,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
setIsPickingCharacterReferenceFromCanvas,
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
setIsPickingUiDesignSpecFromCanvas,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onOpenSpecDialog,
|
||||
onRequestUpload,
|
||||
onSubmitImageGeneration,
|
||||
@@ -1083,6 +1085,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
setIsGenerationReferenceMenuOpen((open) => !open)
|
||||
}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1105,6 +1108,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
onOpenSpecDialog={onOpenSpecDialog}
|
||||
onUpdateSpecFormValue={onUpdateSpecFormValue}
|
||||
onRequestUpload={onRequestUpload}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1129,6 +1133,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1152,6 +1157,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
onUpdateSpecFormValue={onUpdateSpecFormValue}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1227,6 +1233,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
onOpenSpecDialog={onOpenSpecDialog}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmitImageGeneration}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1248,6 +1255,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
onRequestUpload={onRequestUpload}
|
||||
onUpdateIconDescriptionText={onUpdateIconDescriptionText}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmitIconSpritesheetGeneration}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1303,6 +1311,7 @@ export function ImageCanvasGenerationComposerView({
|
||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||
onRequestUpload={onRequestUpload}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
setQuickEditPanel={setQuickEditPanel}
|
||||
onSubmit={onSubmitQuickEdit}
|
||||
/>
|
||||
|
||||
@@ -1134,6 +1134,93 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('caps image references before a sixth reference can enter submission state', () => {
|
||||
const existingReferences = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `reference-${index}`,
|
||||
label: `参考图${index + 1}`,
|
||||
src: `generated-character-drafts/editor/reference-${index}.png`,
|
||||
}));
|
||||
const imageDialog: GenerateDialogState = {
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
generationReferences: existingReferences,
|
||||
};
|
||||
expect(
|
||||
appendGenerationReference(
|
||||
imageDialog,
|
||||
createLayer({ id: 'reference-6', title: '参考图6' }),
|
||||
),
|
||||
).toMatchObject({
|
||||
generationReferences: existingReferences,
|
||||
});
|
||||
|
||||
const characterDialog: GenerateDialogState = {
|
||||
mode: 'character',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
characterSpecReference: existingReferences[0],
|
||||
characterReferences: existingReferences.slice(1),
|
||||
};
|
||||
expect(
|
||||
appendCharacterReference(
|
||||
characterDialog,
|
||||
createLayer({ id: 'character-reference-6', title: '角色参考图5' }),
|
||||
),
|
||||
).toMatchObject({
|
||||
characterReferences: existingReferences.slice(1),
|
||||
});
|
||||
|
||||
const iconDialog: GenerateDialogState = {
|
||||
mode: 'icon',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||
generationReferences: existingReferences.slice(0, 4),
|
||||
};
|
||||
expect(
|
||||
appendGenerationReference(
|
||||
iconDialog,
|
||||
createLayer({ id: 'icon-reference-5', title: '图标参考图5' }),
|
||||
),
|
||||
).toMatchObject({
|
||||
generationReferences: existingReferences.slice(0, 4),
|
||||
});
|
||||
});
|
||||
|
||||
it('reserves the primary-image slot before character and UI specs are selected', () => {
|
||||
const references = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `reference-${index}`,
|
||||
label: `参考图${index + 1}`,
|
||||
src: `/reference-${index}.png`,
|
||||
}));
|
||||
const characterDialog: GenerateDialogState = {
|
||||
mode: 'character',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
characterReferences: references.slice(0, 4),
|
||||
};
|
||||
const uiDialog: GenerateDialogState = {
|
||||
mode: 'ui-design',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
generationReferences: references.slice(0, 4),
|
||||
};
|
||||
|
||||
expect(
|
||||
appendCharacterReference(
|
||||
characterDialog,
|
||||
createLayer({ id: 'character-reference-5' }),
|
||||
),
|
||||
).toMatchObject({ characterReferences: references.slice(0, 4) });
|
||||
expect(
|
||||
appendGenerationReference(
|
||||
uiDialog,
|
||||
createLayer({ id: 'ui-reference-5' }),
|
||||
),
|
||||
).toMatchObject({ generationReferences: references.slice(0, 4) });
|
||||
});
|
||||
|
||||
it('updates failed spec and icon dialog fields back to idle state', () => {
|
||||
const specDialog: GenerateDialogState = {
|
||||
mode: 'spec',
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SpecGenerationType,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
appendLimitedImageReferences,
|
||||
appendLimitedQuickEditReferences,
|
||||
AUDIO_FRAME_DISPLAY_SIZE,
|
||||
AUDIO_FRAME_ORIGINAL_SIZE,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
normalizeEditorImageModel,
|
||||
PUBLICATION_FRAME_ORIGINAL_SIZE,
|
||||
resizeGenerationPlaceholderToImageSelection,
|
||||
resolveDialogExtraImageReferenceLimit,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
resolveEditorVideoGenerationPixelSize,
|
||||
SPEC_FRAME_ORIGINAL_SIZE,
|
||||
@@ -644,10 +646,7 @@ function resolveGeneratedSourceDialogMode({
|
||||
if (sourceLayer.assetKind === 'character-animation') {
|
||||
return 'character-animation';
|
||||
}
|
||||
if (
|
||||
sourceLayer.assetKind === 'video' ||
|
||||
sourceLayer.mediaType === 'video'
|
||||
) {
|
||||
if (sourceLayer.assetKind === 'video' || sourceLayer.mediaType === 'video') {
|
||||
return 'video';
|
||||
}
|
||||
if (sourceLayer.assetKind === 'sound-effect') {
|
||||
@@ -1005,8 +1004,7 @@ export function createSameSourceGenerationDialogDraft({
|
||||
videoModel: sourceDialog?.videoModel ?? draft.videoModel,
|
||||
videoAspectRatio:
|
||||
sourceDialog?.videoAspectRatio ?? draft.videoAspectRatio,
|
||||
videoResolution:
|
||||
sourceDialog?.videoResolution ?? draft.videoResolution,
|
||||
videoResolution: sourceDialog?.videoResolution ?? draft.videoResolution,
|
||||
videoDurationSeconds:
|
||||
sourceDialog?.videoDurationSeconds ?? draft.videoDurationSeconds,
|
||||
videoMode: sourceDialog?.videoMode ?? draft.videoMode,
|
||||
@@ -1424,6 +1422,7 @@ export function assignCharacterSpecReference(
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
characterSpecReference: createCanvasLayerReference(layer),
|
||||
characterReferences: dialog.characterReferences,
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
@@ -1437,10 +1436,11 @@ export function appendCharacterReference(
|
||||
getReferenceMediaType(layer) === 'image'
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
characterReferences: [
|
||||
...(dialog.characterReferences ?? []),
|
||||
createCanvasLayerReference(layer),
|
||||
],
|
||||
characterReferences: appendLimitedImageReferences(
|
||||
dialog.characterReferences,
|
||||
[createCanvasLayerReference(layer)],
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
@@ -1474,10 +1474,11 @@ export function appendGenerationReference(
|
||||
}
|
||||
return {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
generationReferences: [
|
||||
...(dialog.generationReferences ?? []),
|
||||
createCanvasLayerReference(layer),
|
||||
],
|
||||
generationReferences: appendLimitedImageReferences(
|
||||
dialog.generationReferences,
|
||||
[createCanvasLayerReference(layer)],
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
composerOpen: true,
|
||||
};
|
||||
}
|
||||
@@ -1501,10 +1502,11 @@ export function appendPublicationReference(
|
||||
getReferenceMediaType(layer) === 'image'
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
publicationReferences: [
|
||||
...(dialog.publicationReferences ?? []),
|
||||
createCanvasLayerReference(layer),
|
||||
],
|
||||
publicationReferences: appendLimitedImageReferences(
|
||||
dialog.publicationReferences,
|
||||
[createCanvasLayerReference(layer)],
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
@@ -1521,6 +1523,11 @@ export function assignIconSpecReference(
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
iconSpecReference: createCanvasLayerReference(layer),
|
||||
generationReferences: appendLimitedImageReferences(
|
||||
[],
|
||||
dialog.generationReferences ?? [],
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
@@ -1537,6 +1544,7 @@ export function assignUiDesignSpecReference(
|
||||
? {
|
||||
...resetFailedGenerationDialog(dialog),
|
||||
uiDesignSpecReference: createCanvasLayerReference(layer),
|
||||
generationReferences: dialog.generationReferences,
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
function ImageOptionsHarness({
|
||||
initialDialog,
|
||||
includeDimensions = true,
|
||||
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onRememberImageModel = vi.fn(),
|
||||
lockedModel,
|
||||
}: {
|
||||
initialDialog: GenerateDialogState;
|
||||
includeDimensions?: boolean;
|
||||
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
lockedModel?: string;
|
||||
}) {
|
||||
@@ -35,6 +35,7 @@ function ImageOptionsHarness({
|
||||
dialog={dialog}
|
||||
setGenerateDialog={setDialog}
|
||||
includeDimensions={includeDimensions}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
lockedModel={lockedModel}
|
||||
cost={calculateEditorImageGenerationPrice({
|
||||
@@ -50,6 +51,13 @@ function ImageOptionsHarness({
|
||||
<output aria-label="当前风格">{dialog.style ?? '-'}</output>
|
||||
<output aria-label="当前状态">{dialog.status}</output>
|
||||
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
||||
<output aria-label="当前参考图数量">
|
||||
{dialog.mode === 'character'
|
||||
? (dialog.characterReferences?.length ?? 0)
|
||||
: dialog.mode === 'publication'
|
||||
? (dialog.publicationReferences?.length ?? 0)
|
||||
: (dialog.generationReferences?.length ?? 0)}
|
||||
</output>
|
||||
<output aria-label="当前占位">
|
||||
{dialog.placeholder
|
||||
? [
|
||||
@@ -122,9 +130,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('checkbox', { name: '像素艺术' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('checkbox', { name: '像素艺术' })).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -267,6 +273,90 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
||||
expect(rememberImageModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a model switch that would discard existing references', () => {
|
||||
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
const rememberImageModel = vi.fn();
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
initialDialog={{
|
||||
mode: 'icon',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
generationReferences: Array.from({ length: 8 }, (_, index) => ({
|
||||
id: `reference-${index + 1}`,
|
||||
label: `参考图${index + 1}`,
|
||||
src: `/reference-${index + 1}.png`,
|
||||
})),
|
||||
}}
|
||||
includeDimensions={false}
|
||||
onRememberImageModel={rememberImageModel}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8');
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
|
||||
'button',
|
||||
{ name: 'gpt-image-2' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(alertMock).toHaveBeenCalledWith(
|
||||
'当前已有 8 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换',
|
||||
);
|
||||
expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8');
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe(
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
);
|
||||
expect(rememberImageModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects model switching while reference uploads are pending', () => {
|
||||
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
const rememberImageModel = vi.fn();
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
initialDialog={{
|
||||
mode: 'generate',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||
}}
|
||||
hasPendingImageReferenceUploads
|
||||
onRememberImageModel={rememberImageModel}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
|
||||
'button',
|
||||
{ name: 'gpt-image-2' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(alertMock).toHaveBeenCalledWith(
|
||||
'参考图正在上传,请等待上传完成后再切换模型',
|
||||
);
|
||||
expect(screen.getByLabelText('当前模型').textContent).toBe(
|
||||
IMAGE_MODEL_NANOBANANA2,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
screen.getByRole('button', {
|
||||
name: '生成角色形象',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
expect(rememberImageModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders option menus and shows mud point text in the submit button', () => {
|
||||
render(
|
||||
<ImageOptionsHarness
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
countDialogExtraImageReferences,
|
||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
getEditorImageModelDisplayName,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
normalizeEditorImageModel,
|
||||
QUICK_EDIT_MODEL_OPTIONS,
|
||||
resizeGenerationPlaceholderToImageSelection,
|
||||
resolveDialogExtraImageReferenceLimit,
|
||||
resolveEditorImageSizeLabel,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||
@@ -30,6 +32,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
|
||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||
includeDimensions: boolean;
|
||||
includeModel?: boolean;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
optionLabelPrefix?: string;
|
||||
dimensionRatioAriaLabelPrefix?: string;
|
||||
@@ -121,6 +124,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
setGenerateDialog,
|
||||
includeDimensions,
|
||||
includeModel = true,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onRememberImageModel,
|
||||
optionLabelPrefix,
|
||||
dimensionRatioAriaLabelPrefix,
|
||||
@@ -192,7 +196,18 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
};
|
||||
|
||||
const updateImageModel = (model: string) => {
|
||||
onRememberImageModel(model);
|
||||
if (hasPendingImageReferenceUploads) {
|
||||
window.alert('参考图正在上传,请等待上传完成后再切换模型');
|
||||
return;
|
||||
}
|
||||
const referenceLimit = resolveDialogExtraImageReferenceLimit(dialog, model);
|
||||
const referenceCount = countDialogExtraImageReferences(dialog);
|
||||
if (referenceCount > referenceLimit) {
|
||||
window.alert(
|
||||
`当前已有 ${referenceCount} 张参考图,${getEditorImageModelDisplayName(model)} 最多允许 ${referenceLimit} 张,请先删除多余参考图后再切换`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setGenerateDialog((currentDialog) => {
|
||||
if (!currentDialog || currentDialog.mode !== dialog.mode) {
|
||||
return currentDialog;
|
||||
@@ -224,6 +239,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
};
|
||||
return resizeGenerationPlaceholderToImageSelection(nextDialog);
|
||||
});
|
||||
onRememberImageModel(model);
|
||||
};
|
||||
|
||||
const togglePanel = (panel: Exclude<OpenPanel, null>) => {
|
||||
@@ -408,7 +424,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className={submitButtonClassName}
|
||||
disabled={isGenerating}
|
||||
disabled={isGenerating || hasPendingImageReferenceUploads}
|
||||
aria-label={submitAriaLabel}
|
||||
>
|
||||
{isGenerating ? (
|
||||
|
||||
@@ -416,6 +416,79 @@ export const SEEDANCE_VIDEO_REFERENCE_LIMITS = {
|
||||
audio: 3,
|
||||
} as const;
|
||||
export const QUICK_EDIT_REFERENCE_LIMIT = 8;
|
||||
export const IMAGE_GENERATION_REFERENCE_LIMIT = 5;
|
||||
export const ICON_EXTRA_REFERENCE_LIMIT = 8;
|
||||
export const UI_EXTRA_REFERENCE_LIMIT = 5;
|
||||
|
||||
export function resolveImageProviderReferenceLimit(
|
||||
model: string | null | undefined,
|
||||
) {
|
||||
return normalizeEditorImageModel(model) === IMAGE_MODEL_NANOBANANA2 ? 14 : 5;
|
||||
}
|
||||
|
||||
export function resolveExtraImageReferenceLimit(
|
||||
model: string | null | undefined,
|
||||
productLimit: number,
|
||||
primaryReferenceCount = 0,
|
||||
) {
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
productLimit,
|
||||
resolveImageProviderReferenceLimit(model) - primaryReferenceCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDialogExtraImageReferenceLimit(
|
||||
dialog: GenerateDialogState,
|
||||
model: string | null | undefined = dialog.imageModel,
|
||||
) {
|
||||
if (dialog.mode === 'icon') {
|
||||
return resolveExtraImageReferenceLimit(
|
||||
model,
|
||||
ICON_EXTRA_REFERENCE_LIMIT,
|
||||
1,
|
||||
);
|
||||
}
|
||||
if (dialog.mode === 'ui-design') {
|
||||
return resolveExtraImageReferenceLimit(
|
||||
model,
|
||||
IMAGE_GENERATION_REFERENCE_LIMIT - 1,
|
||||
1,
|
||||
);
|
||||
}
|
||||
if (dialog.mode === 'quick-edit') {
|
||||
return resolveExtraImageReferenceLimit(
|
||||
model,
|
||||
QUICK_EDIT_REFERENCE_LIMIT,
|
||||
1,
|
||||
);
|
||||
}
|
||||
if (dialog.mode === 'character') {
|
||||
return resolveExtraImageReferenceLimit(
|
||||
model,
|
||||
IMAGE_GENERATION_REFERENCE_LIMIT - 1,
|
||||
1,
|
||||
);
|
||||
}
|
||||
return IMAGE_GENERATION_REFERENCE_LIMIT;
|
||||
}
|
||||
|
||||
export function countDialogExtraImageReferences(dialog: GenerateDialogState) {
|
||||
const references =
|
||||
dialog.mode === 'character'
|
||||
? dialog.characterReferences
|
||||
: dialog.mode === 'publication'
|
||||
? dialog.publicationReferences
|
||||
: dialog.mode === 'video'
|
||||
? []
|
||||
: dialog.generationReferences;
|
||||
return (references ?? []).filter(
|
||||
(reference) => (reference.mediaType ?? 'image') === 'image',
|
||||
).length;
|
||||
}
|
||||
|
||||
export const CHARACTER_ANIMATION_ACTION_PROMPTS = [
|
||||
{ label: '待机', text: '待机动作,轻微呼吸起伏。' },
|
||||
{ label: '行走', text: '循环行走动作,步伐稳定。' },
|
||||
@@ -1004,6 +1077,20 @@ export function appendLimitedQuickEditReferences(
|
||||
);
|
||||
}
|
||||
|
||||
export function appendLimitedImageReferences(
|
||||
references: CharacterReferenceImage[] | undefined,
|
||||
nextReferences: CharacterReferenceImage[],
|
||||
limit: number,
|
||||
) {
|
||||
const imageReferences = nextReferences.filter(
|
||||
(reference) => (reference.mediaType ?? 'image') === 'image',
|
||||
);
|
||||
return [...(references ?? []), ...imageReferences].slice(
|
||||
0,
|
||||
Math.max(0, limit),
|
||||
);
|
||||
}
|
||||
|
||||
export function createGenerationInputField(
|
||||
title: string,
|
||||
value: string | null | undefined,
|
||||
@@ -1300,17 +1387,17 @@ export function isCanvasGenerationDialog(
|
||||
): dialog is CanvasGenerationDialogState {
|
||||
return Boolean(
|
||||
dialog?.id &&
|
||||
(dialog.mode === 'generate' ||
|
||||
dialog.mode === 'spec' ||
|
||||
dialog.mode === 'character' ||
|
||||
dialog.mode === 'icon' ||
|
||||
dialog.mode === 'publication' ||
|
||||
dialog.mode === 'ui-design' ||
|
||||
dialog.mode === 'quick-edit' ||
|
||||
dialog.mode === 'character-animation' ||
|
||||
dialog.mode === 'video' ||
|
||||
dialog.mode === 'audio-sound-effect' ||
|
||||
dialog.mode === 'audio-background-music'),
|
||||
(dialog.mode === 'generate' ||
|
||||
dialog.mode === 'spec' ||
|
||||
dialog.mode === 'character' ||
|
||||
dialog.mode === 'icon' ||
|
||||
dialog.mode === 'publication' ||
|
||||
dialog.mode === 'ui-design' ||
|
||||
dialog.mode === 'quick-edit' ||
|
||||
dialog.mode === 'character-animation' ||
|
||||
dialog.mode === 'video' ||
|
||||
dialog.mode === 'audio-sound-effect' ||
|
||||
dialog.mode === 'audio-background-music'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,9 +19,7 @@ import type {
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||
import {
|
||||
calculateEditorIconSpritesheetPrice,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { calculateEditorIconSpritesheetPrice } from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||
|
||||
@@ -42,6 +40,7 @@ type ImageCanvasIconSpritesheetComposerViewProps = {
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onUpdateIconDescriptionText: (value: string) => void;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
@@ -59,6 +58,7 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
onRequestUpload,
|
||||
onUpdateIconDescriptionText,
|
||||
onRememberImageModel,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
}: ImageCanvasIconSpritesheetComposerViewProps) {
|
||||
const descriptionText =
|
||||
@@ -236,6 +236,7 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
optionLabelPrefix="生成图片"
|
||||
cost={calculateEditorIconSpritesheetPrice(
|
||||
dialog.imageModel,
|
||||
|
||||
@@ -47,6 +47,7 @@ type ImageCanvasPublicationMaterialsDemoPanelViewProps = {
|
||||
) => CSSProperties;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onRememberImageModel: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
@@ -116,6 +117,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
|
||||
buildPortalMenuStyle,
|
||||
onRequestUpload,
|
||||
onRememberImageModel,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
}: ImageCanvasPublicationMaterialsDemoPanelViewProps) {
|
||||
const references = dialog.publicationReferences ?? [];
|
||||
@@ -302,6 +304,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
|
||||
setGenerateDialog={setGenerateDialog}
|
||||
includeDimensions={false}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
|
||||
optionLabelPrefix="宣发素材"
|
||||
cost={calculateEditorImageModelPrice(
|
||||
|
||||
@@ -13,7 +13,10 @@ import type {
|
||||
QuickEditPanelState,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { QUICK_EDIT_REFERENCE_LIMIT } from './ImageCanvasGenerationModel';
|
||||
import {
|
||||
QUICK_EDIT_REFERENCE_LIMIT,
|
||||
resolveExtraImageReferenceLimit,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
export type ImageCanvasQuickEditPanelViewProps = {
|
||||
panel: QuickEditPanelState;
|
||||
@@ -31,6 +34,7 @@ export type ImageCanvasQuickEditPanelViewProps = {
|
||||
) => CSSProperties;
|
||||
onRequestUpload?: (target: UploadTarget) => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
@@ -100,13 +104,20 @@ export function ImageCanvasQuickEditPanelView({
|
||||
buildPortalMenuStyle = () => ({}),
|
||||
onRequestUpload,
|
||||
onRememberImageModel = () => {},
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
}: ImageCanvasQuickEditPanelViewProps) {
|
||||
const isRedraw = panel.mode === 'redraw';
|
||||
const isImageQuickEdit = !isRedraw && sourceLayer.mediaType !== 'video';
|
||||
const referenceCount = panel.quickEditReferences?.length ?? 0;
|
||||
const canAddReferences =
|
||||
!isRedraw && referenceCount < QUICK_EDIT_REFERENCE_LIMIT;
|
||||
!isRedraw &&
|
||||
referenceCount <
|
||||
resolveExtraImageReferenceLimit(
|
||||
panel.model,
|
||||
QUICK_EDIT_REFERENCE_LIMIT,
|
||||
1,
|
||||
);
|
||||
const quickEditDialog = createQuickEditDialog(panel);
|
||||
|
||||
return (
|
||||
@@ -141,6 +152,7 @@ export function ImageCanvasQuickEditPanelView({
|
||||
}
|
||||
onToggleReferenceMenu={() => setIsReferenceMenuOpen?.((open) => !open)}
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
onSubmit={onSubmit}
|
||||
dialogLabel={isRedraw ? '重绘图片' : '快速编辑图片'}
|
||||
includeDimensions={isRedraw || isImageQuickEdit}
|
||||
|
||||
@@ -58,6 +58,7 @@ type ImageCanvasSpecGenerationPanelViewProps = {
|
||||
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
|
||||
onRequestUpload: (target: UploadTarget) => void;
|
||||
onRememberImageModel?: (model: string) => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
onSubmit: (dialog: GenerateDialogState) => void;
|
||||
};
|
||||
|
||||
@@ -79,6 +80,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
onUpdateSpecFormValue,
|
||||
onRequestUpload,
|
||||
onRememberImageModel = () => {},
|
||||
hasPendingImageReferenceUploads = false,
|
||||
onSubmit,
|
||||
}: ImageCanvasSpecGenerationPanelViewProps) {
|
||||
const isUiDesignDialog = dialog.mode === 'ui-design';
|
||||
@@ -428,6 +430,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
setGenerateDialog={setGenerateDialog ?? (() => undefined)}
|
||||
includeDimensions
|
||||
onRememberImageModel={onRememberImageModel}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
|
||||
cost={calculateEditorUiDesignPrice(
|
||||
IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -473,7 +476,7 @@ export function ImageCanvasSpecGenerationPanelView({
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit"
|
||||
disabled={isGenerating}
|
||||
disabled={isGenerating || hasPendingImageReferenceUploads}
|
||||
aria-label="提交生成规范"
|
||||
>
|
||||
{isGenerating ? (
|
||||
|
||||
@@ -76,6 +76,7 @@ export type ImageCanvasStageViewProps = {
|
||||
uiAssetExtractionSourceLayer: CanvasLayer | null;
|
||||
quickEditSelectionState: UiAssetExtractionState | null;
|
||||
quickEditSelectionSourceLayer: CanvasLayer | null;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
generationComposerStyle: CSSProperties | null;
|
||||
selectedToolbarStyle: CSSProperties | null;
|
||||
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
|
||||
@@ -236,6 +237,7 @@ export function ImageCanvasStageView({
|
||||
uiAssetExtractionSourceLayer,
|
||||
quickEditSelectionState,
|
||||
quickEditSelectionSourceLayer,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
|
||||
@@ -445,6 +447,7 @@ export function ImageCanvasStageView({
|
||||
onRequestUpload={onRequestUiAssetExtractionReferenceUpload}
|
||||
onRemoveReference={onRemoveUiAssetExtractionReference}
|
||||
onSubmit={onSubmitUiAssetExtraction}
|
||||
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||
/>
|
||||
|
||||
<ImageCanvasUiAssetExtractionOverlayView
|
||||
|
||||
@@ -27,6 +27,7 @@ export type UiAssetExtractionMark =
|
||||
| UiAssetExtractionBrushMark;
|
||||
|
||||
export type UiAssetExtractionState = {
|
||||
referenceUploadContextId?: string;
|
||||
sourceLayerId: string;
|
||||
tool: UiAssetExtractionTool | null;
|
||||
model: string;
|
||||
|
||||
@@ -236,6 +236,79 @@ describe('ImageCanvasUiAssetExtractionOverlayView', () => {
|
||||
expect(onModelChange).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2);
|
||||
});
|
||||
|
||||
it('disables extraction submission while reference uploads are pending', () => {
|
||||
render(
|
||||
<ImageCanvasUiAssetExtractionOverlayView
|
||||
sourceLayer={createLayer({ src: 'data:image/png;base64,ui' })}
|
||||
viewport={{ x: 10, y: 20, scale: 1 }}
|
||||
state={createState({
|
||||
marks: [
|
||||
{
|
||||
id: 'mark-1',
|
||||
tool: 'rect',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 80,
|
||||
height: 60,
|
||||
},
|
||||
],
|
||||
})}
|
||||
onToolChange={vi.fn()}
|
||||
onPointerStart={vi.fn()}
|
||||
onPointerMove={vi.fn()}
|
||||
onPointerEnd={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
hasPendingImageReferenceUploads
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '提取' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the current UI extraction model and references when the target model is too small', () => {
|
||||
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
const onModelChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ImageCanvasUiAssetExtractionOverlayView
|
||||
sourceLayer={createLayer()}
|
||||
viewport={{ x: 10, y: 20, scale: 1 }}
|
||||
state={createState({
|
||||
references: Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `reference-${index + 1}`,
|
||||
label: `参考图${index + 1}`,
|
||||
src: `/reference-${index + 1}.png`,
|
||||
})),
|
||||
})}
|
||||
onToolChange={vi.fn()}
|
||||
onPointerStart={vi.fn()}
|
||||
onPointerMove={vi.fn()}
|
||||
onPointerEnd={vi.fn()}
|
||||
onModelChange={onModelChange}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '提取素材模型 nanobanana2' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '提取素材模型选项' })).getByRole(
|
||||
'button',
|
||||
{ name: 'gpt-image-2' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(alertMock).toHaveBeenCalledWith(
|
||||
'当前已有 5 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换',
|
||||
);
|
||||
expect(onModelChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('menu', { name: '提取素材模型选项' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('crops mark previews from the same source coordinates as the red selection', () => {
|
||||
render(
|
||||
<ImageCanvasUiAssetExtractionOverlayView
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
calculateEditorIconSpritesheetPrice,
|
||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||
getEditorImageModelDisplayName,
|
||||
resolveExtraImageReferenceLimit,
|
||||
UI_EXTRA_REFERENCE_LIMIT,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||
import type {
|
||||
@@ -48,6 +50,7 @@ type ImageCanvasUiAssetExtractionOverlayViewProps = {
|
||||
onRequestUpload?: () => void;
|
||||
onRemoveReference?: (referenceId: string) => void;
|
||||
onSubmit?: () => void;
|
||||
hasPendingImageReferenceUploads?: boolean;
|
||||
};
|
||||
|
||||
const TOOL_OPTIONS: Array<{
|
||||
@@ -275,6 +278,7 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
onRequestUpload,
|
||||
onRemoveReference,
|
||||
onSubmit,
|
||||
hasPendingImageReferenceUploads = false,
|
||||
}: ImageCanvasUiAssetExtractionOverlayViewProps) {
|
||||
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
||||
if (!sourceLayer || !state) {
|
||||
@@ -282,7 +286,10 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
}
|
||||
const isQuickEdit = variant === 'quick-edit';
|
||||
const marks = [...state.marks, ...(state.draftMark ? [state.draftMark] : [])];
|
||||
const canSubmit = state.marks.length > 0 && state.status !== 'extracting';
|
||||
const canSubmit =
|
||||
state.marks.length > 0 &&
|
||||
state.status !== 'extracting' &&
|
||||
!hasPendingImageReferenceUploads;
|
||||
const extractionPlan = resolveUiAssetExtractionGenerationPlan(
|
||||
state.marks.length,
|
||||
);
|
||||
@@ -487,6 +494,18 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
className="image-canvas-editor__option-popover-choice image-canvas-editor__option-popover-choice--model"
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
const referenceLimit =
|
||||
resolveExtraImageReferenceLimit(
|
||||
option.value,
|
||||
UI_EXTRA_REFERENCE_LIMIT,
|
||||
1,
|
||||
);
|
||||
if (state.references.length > referenceLimit) {
|
||||
window.alert(
|
||||
`当前已有 ${state.references.length} 张参考图,${option.label} 最多允许 ${referenceLimit} 张,请先删除多余参考图后再切换`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
onModelChange(option.value);
|
||||
setIsModelMenuOpen(false);
|
||||
}}
|
||||
|
||||
@@ -10,7 +10,12 @@ import type {
|
||||
GenerateDialogState,
|
||||
QuickEditPanelState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { appendLimitedQuickEditReferences } from './ImageCanvasGenerationModel';
|
||||
import {
|
||||
appendLimitedImageReferences,
|
||||
QUICK_EDIT_REFERENCE_LIMIT,
|
||||
resolveDialogExtraImageReferenceLimit,
|
||||
resolveExtraImageReferenceLimit,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
|
||||
type CanvasSize = { width: number; height: number };
|
||||
type CanvasPoint = { x: number; y: number };
|
||||
@@ -173,6 +178,7 @@ export function applyGenerationReferenceUpload({
|
||||
? {
|
||||
...setFailedGenerationIdle(dialog),
|
||||
characterSpecReference: firstReference,
|
||||
characterReferences: dialog.characterReferences,
|
||||
}
|
||||
: dialog;
|
||||
}
|
||||
@@ -181,6 +187,11 @@ export function applyGenerationReferenceUpload({
|
||||
? {
|
||||
...setFailedGenerationIdle(dialog),
|
||||
iconSpecReference: firstReference,
|
||||
generationReferences: appendLimitedImageReferences(
|
||||
[],
|
||||
dialog.generationReferences ?? [],
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
}
|
||||
: dialog;
|
||||
}
|
||||
@@ -189,6 +200,7 @@ export function applyGenerationReferenceUpload({
|
||||
? {
|
||||
...setFailedGenerationIdle(dialog),
|
||||
uiDesignSpecReference: firstReference,
|
||||
generationReferences: dialog.generationReferences,
|
||||
}
|
||||
: dialog;
|
||||
}
|
||||
@@ -196,10 +208,11 @@ export function applyGenerationReferenceUpload({
|
||||
return dialog?.mode === 'publication'
|
||||
? {
|
||||
...setFailedGenerationIdle(dialog),
|
||||
publicationReferences: [
|
||||
...(dialog.publicationReferences ?? []),
|
||||
...references,
|
||||
],
|
||||
publicationReferences: appendLimitedImageReferences(
|
||||
dialog.publicationReferences,
|
||||
references,
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
composerOpen: true,
|
||||
}
|
||||
: dialog;
|
||||
@@ -212,10 +225,18 @@ export function applyGenerationReferenceUpload({
|
||||
dialog?.mode === 'ui-design'
|
||||
? {
|
||||
...setFailedGenerationIdle(dialog),
|
||||
generationReferences: [
|
||||
...(dialog.generationReferences ?? []),
|
||||
...references,
|
||||
],
|
||||
generationReferences:
|
||||
dialog.mode === 'video'
|
||||
? appendLimitedVideoReferences(
|
||||
dialog.generationReferences ?? [],
|
||||
references,
|
||||
'image',
|
||||
)
|
||||
: appendLimitedImageReferences(
|
||||
dialog.generationReferences,
|
||||
references,
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
}
|
||||
: dialog;
|
||||
}
|
||||
@@ -245,10 +266,11 @@ export function applyGenerationReferenceUpload({
|
||||
return dialog?.mode === 'character'
|
||||
? {
|
||||
...setFailedGenerationIdle(dialog),
|
||||
characterReferences: [
|
||||
...(dialog.characterReferences ?? []),
|
||||
...references,
|
||||
],
|
||||
characterReferences: appendLimitedImageReferences(
|
||||
dialog.characterReferences,
|
||||
references,
|
||||
resolveDialogExtraImageReferenceLimit(dialog),
|
||||
),
|
||||
}
|
||||
: dialog;
|
||||
}
|
||||
@@ -267,9 +289,14 @@ export function applyQuickEditReferenceUpload({
|
||||
...panel,
|
||||
status: panel.status === 'failed' ? 'idle' : panel.status,
|
||||
errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage,
|
||||
quickEditReferences: appendLimitedQuickEditReferences(
|
||||
quickEditReferences: appendLimitedImageReferences(
|
||||
panel.quickEditReferences,
|
||||
references,
|
||||
resolveExtraImageReferenceLimit(
|
||||
panel.model,
|
||||
QUICK_EDIT_REFERENCE_LIMIT,
|
||||
1,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,6 +72,78 @@ function durablePerfectPixelDialog(
|
||||
}
|
||||
|
||||
describe('useCanvasGenerationDialogs', () => {
|
||||
it('keeps the active dialog context stable while reference uploads are pending', () => {
|
||||
let locked = false;
|
||||
const onContextMutationRejected = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useCanvasGenerationDialogs({
|
||||
isContextMutationLocked: () => locked,
|
||||
onContextMutationRejected,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openCanvasGenerationDialog(
|
||||
createDialog('generate', 'first'),
|
||||
);
|
||||
});
|
||||
const activeId = result.current.activeCanvasGenerationDialog?.id;
|
||||
expect(activeId).toBeTruthy();
|
||||
|
||||
locked = true;
|
||||
act(() => {
|
||||
result.current.openCanvasGenerationDialog(
|
||||
createDialog('character', 'second'),
|
||||
);
|
||||
result.current.setGenerateDialog(null);
|
||||
});
|
||||
|
||||
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
||||
expect.objectContaining({ id: activeId, prompt: 'first' }),
|
||||
);
|
||||
expect(result.current.inactiveGenerateDialogs).toEqual([]);
|
||||
expect(onContextMutationRejected).toHaveBeenCalledTimes(2);
|
||||
|
||||
act(() => {
|
||||
result.current.setGenerateDialog((currentDialog) =>
|
||||
currentDialog ? { ...currentDialog, prompt: 'updated' } : currentDialog,
|
||||
);
|
||||
});
|
||||
expect(result.current.activeCanvasGenerationDialog?.prompt).toBe('updated');
|
||||
});
|
||||
|
||||
it('rejects switching between same-mode non-canvas dialogs while uploads are pending', () => {
|
||||
let locked = false;
|
||||
const onContextMutationRejected = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useCanvasGenerationDialogs({
|
||||
isContextMutationLocked: () => locked,
|
||||
onContextMutationRejected,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setGenerateDialog({
|
||||
mode: 'edit',
|
||||
sourceLayerId: 'layer-a',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
});
|
||||
});
|
||||
locked = true;
|
||||
act(() => {
|
||||
result.current.setGenerateDialog({
|
||||
mode: 'edit',
|
||||
sourceLayerId: 'layer-b',
|
||||
prompt: '',
|
||||
status: 'idle',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.generateDialog?.sourceLayerId).toBe('layer-a');
|
||||
expect(onContextMutationRejected).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('archives, activates, updates, and removes canvas generation dialogs', () => {
|
||||
const onActivate = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -75,10 +75,23 @@ function withGenerationTimestamps<T extends GenerateDialogState | null>(
|
||||
};
|
||||
}
|
||||
|
||||
function generationDialogContextId(dialog: GenerateDialogState | null) {
|
||||
if (!dialog) {
|
||||
return null;
|
||||
}
|
||||
return isCanvasGenerationDialog(dialog)
|
||||
? dialog.id
|
||||
: `${dialog.mode}:${dialog.sourceLayerId ?? 'draft'}`;
|
||||
}
|
||||
|
||||
export function useCanvasGenerationDialogs({
|
||||
onActivate,
|
||||
isContextMutationLocked,
|
||||
onContextMutationRejected,
|
||||
}: {
|
||||
onActivate?: () => void;
|
||||
isContextMutationLocked?: () => boolean;
|
||||
onContextMutationRejected?: () => void;
|
||||
} = {}) {
|
||||
const generationDialogCounterRef = useRef(0);
|
||||
const generateDialogRef = useRef<GenerateDialogState | null>(null);
|
||||
@@ -96,21 +109,37 @@ export function useCanvasGenerationDialogs({
|
||||
? generateDialog
|
||||
: null;
|
||||
|
||||
const rejectContextMutation = useCallback(() => {
|
||||
if (!isContextMutationLocked?.()) {
|
||||
return false;
|
||||
}
|
||||
onContextMutationRejected?.();
|
||||
return true;
|
||||
}, [isContextMutationLocked, onContextMutationRejected]);
|
||||
|
||||
const setGenerateDialog = useCallback<
|
||||
Dispatch<SetStateAction<GenerateDialogState | null>>
|
||||
>((nextDialogOrUpdater) => {
|
||||
const currentDialog = generateDialogRef.current;
|
||||
const nextDialog =
|
||||
typeof nextDialogOrUpdater === 'function'
|
||||
? nextDialogOrUpdater(currentDialog)
|
||||
: nextDialogOrUpdater;
|
||||
const nextDialogWithTimestamps = withGenerationTimestamps(
|
||||
nextDialog,
|
||||
currentDialog,
|
||||
);
|
||||
generateDialogRef.current = nextDialogWithTimestamps;
|
||||
setGenerateDialogState(nextDialogWithTimestamps);
|
||||
}, []);
|
||||
>(
|
||||
(nextDialogOrUpdater) => {
|
||||
const currentDialog = generateDialogRef.current;
|
||||
const nextDialog =
|
||||
typeof nextDialogOrUpdater === 'function'
|
||||
? nextDialogOrUpdater(currentDialog)
|
||||
: nextDialogOrUpdater;
|
||||
const nextDialogWithTimestamps = withGenerationTimestamps(
|
||||
nextDialog,
|
||||
currentDialog,
|
||||
);
|
||||
const currentContextId = generationDialogContextId(currentDialog);
|
||||
const nextContextId = generationDialogContextId(nextDialogWithTimestamps);
|
||||
if (currentContextId !== nextContextId && rejectContextMutation()) {
|
||||
return;
|
||||
}
|
||||
generateDialogRef.current = nextDialogWithTimestamps;
|
||||
setGenerateDialogState(nextDialogWithTimestamps);
|
||||
},
|
||||
[rejectContextMutation],
|
||||
);
|
||||
const canvasGenerationDialogs = useMemo(
|
||||
() =>
|
||||
activeCanvasGenerationDialog
|
||||
@@ -137,6 +166,9 @@ export function useCanvasGenerationDialogs({
|
||||
if (!isCanvasGenerationDialog(currentDialog)) {
|
||||
return;
|
||||
}
|
||||
if (rejectContextMutation()) {
|
||||
return;
|
||||
}
|
||||
const nextInactiveDialogs = inactiveGenerateDialogsRef.current.some(
|
||||
(dialog) => dialog.id === currentDialog.id,
|
||||
)
|
||||
@@ -150,11 +182,14 @@ export function useCanvasGenerationDialogs({
|
||||
];
|
||||
inactiveGenerateDialogsRef.current = nextInactiveDialogs;
|
||||
setInactiveGenerateDialogs(nextInactiveDialogs);
|
||||
}, []);
|
||||
}, [rejectContextMutation]);
|
||||
|
||||
const openCanvasGenerationDialog = useCallback(
|
||||
(dialog: CanvasGenerationDialogDraft) => {
|
||||
const currentDialog = generateDialogRef.current;
|
||||
if (rejectContextMutation()) {
|
||||
return isCanvasGenerationDialog(currentDialog) ? currentDialog.id : '';
|
||||
}
|
||||
if (isCanvasGenerationDialog(currentDialog)) {
|
||||
inactiveGenerateDialogsRef.current =
|
||||
inactiveGenerateDialogsRef.current.some(
|
||||
@@ -193,12 +228,21 @@ export function useCanvasGenerationDialogs({
|
||||
archiveActiveCanvasGenerationDialog,
|
||||
createGenerationDialogId,
|
||||
getCanvasGenerationDialogsSnapshot,
|
||||
rejectContextMutation,
|
||||
],
|
||||
);
|
||||
|
||||
const updateCanvasGenerationDialogById = useCallback(
|
||||
(dialogId: string, updater: CanvasGenerationDialogUpdater) => {
|
||||
const currentDialogRef = generateDialogRef.current;
|
||||
if (
|
||||
isCanvasGenerationDialog(currentDialogRef) &&
|
||||
currentDialogRef.id === dialogId &&
|
||||
updater(currentDialogRef) === null &&
|
||||
rejectContextMutation()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isCanvasGenerationDialog(currentDialogRef) &&
|
||||
currentDialogRef.id === dialogId
|
||||
@@ -235,7 +279,7 @@ export function useCanvasGenerationDialogs({
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
[rejectContextMutation],
|
||||
);
|
||||
|
||||
// 中文注释:低层删除不再对未收口的完美像素 operation 抗命。删除占位不撤销任何在途请求
|
||||
@@ -273,6 +317,13 @@ export function useCanvasGenerationDialogs({
|
||||
const activateCanvasGenerationDialog = useCallback(
|
||||
(targetDialog: CanvasGenerationDialogState) => {
|
||||
const currentDialog = generateDialogRef.current;
|
||||
if (
|
||||
(!isCanvasGenerationDialog(currentDialog) ||
|
||||
currentDialog.id !== targetDialog.id) &&
|
||||
rejectContextMutation()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextInactiveDialogs = inactiveGenerateDialogsRef.current.filter(
|
||||
(dialog) => dialog.id !== targetDialog.id,
|
||||
);
|
||||
@@ -295,7 +346,7 @@ export function useCanvasGenerationDialogs({
|
||||
setGenerateDialogState(nextActiveDialog);
|
||||
onActivate?.();
|
||||
},
|
||||
[onActivate],
|
||||
[onActivate, rejectContextMutation],
|
||||
);
|
||||
|
||||
const restoreCanvasGenerationDialogs = useCallback(
|
||||
@@ -341,6 +392,13 @@ export function useCanvasGenerationDialogs({
|
||||
dialog.sourceLayerId !== targetLayerId &&
|
||||
dialog.generatedLayerId !== targetLayerId;
|
||||
const currentDialog = generateDialogRef.current;
|
||||
if (
|
||||
isCanvasGenerationDialog(currentDialog) &&
|
||||
!keepDialog(currentDialog) &&
|
||||
rejectContextMutation()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextActiveDialog =
|
||||
isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog)
|
||||
? null
|
||||
@@ -352,7 +410,7 @@ export function useCanvasGenerationDialogs({
|
||||
setGenerateDialogState(nextActiveDialog);
|
||||
setInactiveGenerateDialogs(nextInactiveDialogs);
|
||||
},
|
||||
[],
|
||||
[rejectContextMutation],
|
||||
);
|
||||
|
||||
const getGeneratingDialogPlaceholder = useCallback(
|
||||
|
||||
@@ -76,7 +76,10 @@ function AssetCanvasBridgeHarness({
|
||||
selectSingleLayer = vi.fn(),
|
||||
}: {
|
||||
asset?: EditorAsset;
|
||||
resolveCanvasPoint?: (clientX: number, clientY: number) => {
|
||||
resolveCanvasPoint?: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => {
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
@@ -97,7 +100,9 @@ function AssetCanvasBridgeHarness({
|
||||
const suppressAssetClickRef = useRef(false);
|
||||
const layerCounterRef = useRef(0);
|
||||
const [activeUploadFolderId, setActiveUploadFolderId] = useState('project');
|
||||
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>('hovered');
|
||||
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(
|
||||
'hovered',
|
||||
);
|
||||
const [assetPointerDrag, setAssetPointerDrag] =
|
||||
useState<AssetPointerDragState | null>(assetPointerDragRef.current);
|
||||
const [uploadDropTarget, setUploadDropTarget] = useState<
|
||||
@@ -132,10 +137,7 @@ function AssetCanvasBridgeHarness({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bridge.addAssetLayer(asset)}
|
||||
>
|
||||
<button type="button" onClick={() => bridge.addAssetLayer(asset)}>
|
||||
添加到画布
|
||||
</button>
|
||||
<span data-testid="folder">{activeUploadFolderId}</span>
|
||||
@@ -158,17 +160,21 @@ function AssetCleanupHarness({
|
||||
}),
|
||||
],
|
||||
onDeleteLayerSideEffects = vi.fn(),
|
||||
canDeleteLayers,
|
||||
discardHistoryEntriesContainingLayer = vi.fn(),
|
||||
}: {
|
||||
deletedAssets?: EditorAsset[];
|
||||
initialLayers?: CanvasLayer[];
|
||||
onDeleteLayerSideEffects?: (layerId: string) => void;
|
||||
canDeleteLayers?: (targetLayerIds: string[]) => boolean;
|
||||
discardHistoryEntriesContainingLayer?: (
|
||||
matchesLayer: (layer: CanvasLayer) => boolean,
|
||||
) => void;
|
||||
}) {
|
||||
const [layers, setLayers] = useState(initialLayers);
|
||||
const [selectedLayerId, setSelectedLayerId] = useState<string | null>('linked');
|
||||
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(
|
||||
'linked',
|
||||
);
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState(['linked', 'kept']);
|
||||
const cleanup = useImageCanvasAssetLayerCleanup({
|
||||
layers,
|
||||
@@ -176,6 +182,7 @@ function AssetCleanupHarness({
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
onDeleteLayerSideEffects,
|
||||
canDeleteLayers,
|
||||
discardHistoryEntriesContainingLayer,
|
||||
});
|
||||
|
||||
@@ -184,7 +191,9 @@ function AssetCleanupHarness({
|
||||
<button type="button" onClick={() => cleanup(deletedAssets)}>
|
||||
清理素材
|
||||
</button>
|
||||
<span data-testid="layers">{layers.map((layer) => layer.id).join(',')}</span>
|
||||
<span data-testid="layers">
|
||||
{layers.map((layer) => layer.id).join(',')}
|
||||
</span>
|
||||
<span data-testid="selected">{selectedLayerId ?? '-'}</span>
|
||||
<span data-testid="selected-many">{selectedLayerIds.join(',')}</span>
|
||||
</div>
|
||||
@@ -292,6 +301,32 @@ describe('useImageCanvasAssetCanvasBridge', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps linked layers and history when asset cleanup is blocked', () => {
|
||||
const onDeleteLayerSideEffects = vi.fn();
|
||||
const discardHistoryEntriesContainingLayer = vi.fn();
|
||||
const canDeleteLayers = vi.fn().mockReturnValue(false);
|
||||
render(
|
||||
<AssetCleanupHarness
|
||||
deletedAssets={[defaultAsset]}
|
||||
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
|
||||
canDeleteLayers={canDeleteLayers}
|
||||
discardHistoryEntriesContainingLayer={
|
||||
discardHistoryEntriesContainingLayer
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '清理素材' }).click();
|
||||
});
|
||||
|
||||
expect(canDeleteLayers).toHaveBeenCalledWith(['linked']);
|
||||
expect(screen.getByTestId('layers').textContent).toBe('linked,kept');
|
||||
expect(screen.getByTestId('selected').textContent).toBe('linked');
|
||||
expect(onDeleteLayerSideEffects).not.toHaveBeenCalled();
|
||||
expect(discardHistoryEntriesContainingLayer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalidates matching history even when no linked layer is currently mounted', () => {
|
||||
const discardHistoryEntriesContainingLayer = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -36,6 +36,7 @@ type UseImageCanvasAssetLayerCleanupOptions = {
|
||||
setSelectedLayerId: Dispatch<SetStateAction<string | null>>;
|
||||
setSelectedLayerIds: Dispatch<SetStateAction<string[]>>;
|
||||
onDeleteLayerSideEffects?: (layerId: string) => void;
|
||||
canDeleteLayers?: (targetLayerIds: string[]) => boolean;
|
||||
discardHistoryEntriesContainingLayer?: (
|
||||
matchesLayer: (layer: CanvasLayer) => boolean,
|
||||
) => void;
|
||||
@@ -73,21 +74,28 @@ export function useImageCanvasAssetLayerCleanup({
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
onDeleteLayerSideEffects,
|
||||
canDeleteLayers,
|
||||
discardHistoryEntriesContainingLayer,
|
||||
}: UseImageCanvasAssetLayerCleanupOptions) {
|
||||
return useCallback(
|
||||
(deletedAssets: EditorAsset[]) => {
|
||||
if (!deletedAssets.length) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
discardHistoryEntriesContainingLayer?.((layer) =>
|
||||
deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)),
|
||||
);
|
||||
const deletedLayerIds = layers
|
||||
.filter((layer) =>
|
||||
deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)),
|
||||
)
|
||||
.map((layer) => layer.id);
|
||||
if (
|
||||
deletedLayerIds.length > 0 &&
|
||||
canDeleteLayers?.(deletedLayerIds) === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
discardHistoryEntriesContainingLayer?.((layer) =>
|
||||
deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)),
|
||||
);
|
||||
setLayers((currentLayers) =>
|
||||
currentLayers.filter(
|
||||
(layer) =>
|
||||
@@ -111,16 +119,18 @@ export function useImageCanvasAssetLayerCleanup({
|
||||
}
|
||||
const currentLayer = layers.find((layer) => layer.id === currentId);
|
||||
return currentLayer &&
|
||||
deletedAssets.some((asset) => isLayerLinkedToAsset(currentLayer, asset))
|
||||
deletedAssets.some((asset) =>
|
||||
isLayerLinkedToAsset(currentLayer, asset),
|
||||
)
|
||||
? null
|
||||
: currentId;
|
||||
});
|
||||
deletedLayerIds.forEach((layerId) =>
|
||||
onDeleteLayerSideEffects?.(layerId),
|
||||
);
|
||||
deletedLayerIds.forEach((layerId) => onDeleteLayerSideEffects?.(layerId));
|
||||
return true;
|
||||
},
|
||||
[
|
||||
discardHistoryEntriesContainingLayer,
|
||||
canDeleteLayers,
|
||||
layers,
|
||||
onDeleteLayerSideEffects,
|
||||
setLayers,
|
||||
|
||||
@@ -36,7 +36,9 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
function createUploadedAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
|
||||
function createUploadedAsset(
|
||||
overrides: Partial<EditorAsset> = {},
|
||||
): EditorAsset {
|
||||
return {
|
||||
id: 'asset-a',
|
||||
label: '素材A',
|
||||
@@ -73,7 +75,7 @@ function AssetLibraryHarness({
|
||||
}: {
|
||||
canAccessProtectedData?: boolean;
|
||||
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
|
||||
onDeleteAssets?: (assets: EditorAsset[]) => void;
|
||||
onDeleteAssets?: (assets: EditorAsset[]) => boolean | void;
|
||||
}) {
|
||||
const assetListRef = useRef<HTMLDivElement | null>(null);
|
||||
const assetLibrary = useImageCanvasAssetLibrary({
|
||||
@@ -292,9 +294,7 @@ describe('useImageCanvasAssetLibrary', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AssetLibraryHarness openEditorLoginModal={openEditorLoginModal} />,
|
||||
);
|
||||
render(<AssetLibraryHarness openEditorLoginModal={openEditorLoginModal} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
|
||||
@@ -330,9 +330,7 @@ describe('useImageCanvasAssetLibrary', () => {
|
||||
});
|
||||
act(() => screen.getByRole('button', { name: 'commit folder' }).click());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('folders').textContent).toContain(
|
||||
'folder-',
|
||||
);
|
||||
expect(screen.getByTestId('folders').textContent).toContain('folder-');
|
||||
});
|
||||
act(() => {
|
||||
resolveCreateFolder({
|
||||
@@ -402,6 +400,20 @@ describe('useImageCanvasAssetLibrary', () => {
|
||||
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
|
||||
});
|
||||
|
||||
it('keeps uploaded assets when canvas cleanup vetoes deletion', async () => {
|
||||
const onDeleteAssets = vi.fn().mockReturnValue(false);
|
||||
render(<AssetLibraryHarness onDeleteAssets={onDeleteAssets} />);
|
||||
|
||||
await screen.findByText('素材A');
|
||||
act(() => screen.getByRole('button', { name: 'delete asset' }).click());
|
||||
|
||||
expect(screen.getByTestId('assets').textContent).toContain('asset-a:素材A');
|
||||
expect(onDeleteAssets).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ id: 'asset-a' }),
|
||||
]);
|
||||
expect(deleteEditorAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects and deletes selected uploaded assets', async () => {
|
||||
const onDeleteAssets = vi.fn();
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -120,7 +120,7 @@ export function useImageCanvasAssetLibrary({
|
||||
assetListRef: RefObject<HTMLDivElement | null>;
|
||||
canAccessProtectedData: boolean;
|
||||
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||
onDeleteAssets?: (assets: EditorAsset[]) => void;
|
||||
onDeleteAssets?: (assets: EditorAsset[]) => boolean | void;
|
||||
}) {
|
||||
const [assetFolders, setAssetFolders] =
|
||||
useState<EditorAssetFolder[]>(EDITOR_ASSET_FOLDERS);
|
||||
@@ -353,8 +353,10 @@ export function useImageCanvasAssetLibrary({
|
||||
if (asset.sourceKind !== 'uploaded') {
|
||||
return;
|
||||
}
|
||||
if (onDeleteAssets?.([asset]) === false) {
|
||||
return;
|
||||
}
|
||||
setAssets((currentAssets) => removeAssetById(currentAssets, asset.id));
|
||||
onDeleteAssets?.([asset]);
|
||||
setRenamingAsset((currentRename) =>
|
||||
currentRename?.assetId === asset.id ? null : currentRename,
|
||||
);
|
||||
@@ -506,10 +508,12 @@ export function useImageCanvasAssetLibrary({
|
||||
const ids = [...selectedAssetIds];
|
||||
const deletedAssets = removeSelectedAssets(assets, selectedAssetIds)
|
||||
.deletedAssets;
|
||||
if (onDeleteAssets?.(deletedAssets) === false) {
|
||||
return;
|
||||
}
|
||||
setAssets(
|
||||
(currentAssets) => removeSelectedAssets(currentAssets, selectedAssetIds).assets,
|
||||
);
|
||||
onDeleteAssets?.(deletedAssets);
|
||||
setSelectedAssetIds(new Set());
|
||||
ids.forEach((assetId) => {
|
||||
void deleteEditorAsset(assetId);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user