Files
Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx
T
menghao e4e3cab559
Project CI / Repository checks (push) Successful in 3m3s
Project CI / Frontend tests (push) Successful in 3m32s
Project CI / Backend tests (push) Successful in 5m45s
Project CI / Native shell tests (push) Successful in 16m17s
修复画布参考图上传删除链路 (#164)
主站规范生成支持多张参考图

修复普通生成参考图删除与提交状态不同步

优化触屏设备删除按钮可达性并补充回归测试

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/164
Co-authored-by: 孟豪 <mh18530625731@163.com>
Co-committed-by: 孟豪 <mh18530625731@163.com>
2026-08-21 11:55:32 +08:00

2781 lines
91 KiB
TypeScript

import {
type Dispatch,
type PointerEvent as ReactPointerEvent,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
import { replaceAppHistoryPath } from '../../routing/activeAppPageRoutes';
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
import {
createEditorAsset,
createEditorProjectResource,
type EditorAssetSnapshot,
type EditorProjectSnapshot,
loadEditorGenerationPricing,
loadEditorProject,
} from '../../services/image-editor/editorProjectClient';
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
import {
PlatformRechargePaymentConfirmationMask,
PlatformRechargePaymentResultDialog,
} from '../platform-entry/PlatformRechargePaymentStatusDialogs';
import { usePlatformProfileCenterController } from '../platform-entry/usePlatformProfileCenterController';
import type { ImageCanvasActionResult } from './ImageCanvasActionsContext';
import { ImageCanvasActionsProvider } from './ImageCanvasActionsProvider';
import {
canvasAssetKindOrNull,
DEFAULT_CANVAS_BACKGROUND_COLOR,
generationInputsOrNull,
isCanvasAssetKindOverrideCompatible,
isInlineEditorMediaSource,
resolveContextMenuPosition,
resolveLayerResourceAssetKind,
} from './ImageCanvasEditorModel';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
import type {
AssetPointerDragState,
CanvasAssetKind,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasHistoryFeedback,
CanvasLayer,
CanvasTool,
CanvasViewport,
CharacterReferenceImage,
EditorAsset,
ImageContextMenuState,
QuickEditPanelState,
SidebarPanel,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
import {
createLayerGenerationDialogDraft,
resolveSpecGenerationReferences,
} from './ImageCanvasGenerationDialogModel';
import { hydrateCanvasGenerationInputsResult } from './ImageCanvasGenerationInputsModel';
import {
applyEditorGenerationPricingConfig,
isCanvasGenerationDialog,
QUICK_EDIT_REFERENCE_LIMIT,
resolveDialogExtraImageReferenceLimit,
resolveExtraImageReferenceLimit,
UI_EXTRA_REFERENCE_LIMIT,
} from './ImageCanvasGenerationModel';
import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel';
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
import type { CanvasLayerCopyBlockReason } from './ImageCanvasLayerCommandModel';
import {
isCanvasGenerationComposerVisible,
resolveQuickEditFocusViewport,
} from './ImageCanvasOverlayModel';
import {
getCanvasGenerationSelectionId,
getSelectedGenerationDialogIds,
getSelectedLayerIds,
} from './ImageCanvasSelectionModel';
import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView';
import {
requiresGenerationDeleteConfirmation,
useCanvasGenerationDialogs,
} from './useCanvasGenerationDialogs';
import { useCanvasHistory } from './useCanvasHistory';
import {
useImageCanvasAssetCanvasBridge,
useImageCanvasAssetLayerCleanup,
} from './useImageCanvasAssetCanvasBridge';
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
import { useImageCanvasContextStore } from './useImageCanvasContextStore.ts';
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
import { useImageCanvasGenerationSurface } from './useImageCanvasGenerationSurface';
import { useImageCanvasKeyboardShortcuts } from './useImageCanvasKeyboardShortcuts';
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
import { useImageCanvasStageController } from './useImageCanvasStageController';
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
import {
type ImageReferenceUploadCapacity,
useImageCanvasUploadWorkflow,
} from './useImageCanvasUploadWorkflow';
import {
DEFAULT_IMAGE_CANVAS_VIEWPORT,
useImageCanvasViewportControls,
} from './useImageCanvasViewportControls';
import { useInlineGenerationPlaceholderExpiry } from './useInlineGenerationPlaceholderExpiry';
const TASK_FOCUS_HORIZONTAL_INSET = 28;
const TASK_FOCUS_TOP_INSET = 82;
const TASK_FOCUS_BOTTOM_INSET = 90;
const TASK_FOCUS_DIALOG_BOTTOM_INSET = 280;
const TASK_FOCUS_PADDING = 18;
type CanvasStartupTool =
| 'character-spec'
| 'icon-spec'
| 'ui-design'
| 'background-music'
| 'image'
| 'publication-cover'
| 'scene';
const CANVAS_STARTUP_TOOLS: CanvasStartupTool[] = [
'character-spec',
'icon-spec',
'ui-design',
'background-music',
'image',
'publication-cover',
'scene',
];
type ImageCanvasEditorViewProps = {
legacyWalletBalance?: number | null;
onProjectAccessLost?: () => void;
};
function isCanvasStartupTool(value: string | null): value is CanvasStartupTool {
return CANVAS_STARTUP_TOOLS.includes(value as CanvasStartupTool);
}
function readCanvasStartupIntent() {
if (typeof window === 'undefined') {
return { showToolbarGuide: false, tool: null };
}
const params = new URLSearchParams(window.location.search);
const rawTool = params.get('tool');
return {
showToolbarGuide: params.get('guide') === 'toolbar',
tool: isCanvasStartupTool(rawTool) ? rawTool : null,
};
}
function clearCanvasStartupIntent() {
if (typeof window === 'undefined') {
return;
}
const params = new URLSearchParams(window.location.search);
params.delete('tool');
params.delete('guide');
const search = params.toString();
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash}`,
);
}
function openCanvasStartupTool(
tool: CanvasStartupTool,
generationSurface: ReturnType<typeof useImageCanvasGenerationSurface>,
) {
if (tool === 'character-spec') {
generationSurface.openSpecDialog('character');
return;
}
if (tool === 'icon-spec') {
generationSurface.openSpecDialog('icon');
return;
}
if (tool === 'ui-design') {
generationSurface.openUiDesignGenerationDialog();
return;
}
if (tool === 'background-music') {
generationSurface.openBackgroundMusicGenerationDialog();
return;
}
if (tool === 'scene') {
generationSurface.openSceneGenerationDialog();
return;
}
if (tool === 'publication-cover') {
generationSurface.openPublicationGenerationDialog(
'publication-cover-image',
);
return;
}
generationSurface.openGenerateDialog();
}
function resolveLayerProjectResourceImageSrc(layer: CanvasLayer) {
const objectKey = layer.objectKey?.trim();
if (objectKey) {
return `/${objectKey.replace(/^\/+/u, '')}`;
}
if (isInlineEditorMediaSource(layer.src)) {
return null;
}
return layer.src;
}
function findSourceAnimationLayer(
layers: CanvasLayer[],
sourceLayer: CanvasLayer,
) {
if (sourceLayer.assetKind !== 'character-animation') {
return null;
}
const sourceResourceId = sourceLayer.sourceResourceId?.trim();
if (!sourceResourceId) {
return null;
}
return (
layers.find(
(layer) =>
layer.resourceId === sourceResourceId || layer.id === sourceResourceId,
) ?? null
);
}
function isEditablePasteTarget(target: EventTarget | null) {
const element = target instanceof HTMLElement ? target : null;
if (!element) {
return false;
}
return (
element.tagName === 'INPUT' ||
element.tagName === 'TEXTAREA' ||
element.isContentEditable
);
}
function getClipboardImageFiles(clipboardData: DataTransfer | null) {
if (!clipboardData) {
return [];
}
const files = Array.from(clipboardData.files ?? []).filter(isImageFile);
if (files.length) {
return files;
}
return Array.from(clipboardData.items ?? [])
.filter((item) => item.kind === 'file' && item.type.startsWith('image/'))
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null && isImageFile(file));
}
async function readSystemClipboardImageFiles() {
const read = globalThis.navigator?.clipboard?.read;
if (!read) {
return [];
}
const items = await read();
const imageFiles: File[] = [];
for (const item of items) {
const imageType = item.types.find((type) => type.startsWith('image/'));
if (!imageType) {
continue;
}
const blob = await item.getType(imageType);
imageFiles.push(
new File([blob], `剪贴板图片.${imageType.split('/')[1] ?? 'png'}`, {
type: imageType,
}),
);
}
return imageFiles;
}
function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
return {
id: `asset-download-${asset.id}`,
resourceId:
asset.sourceResourceId?.trim() || asset.assetObjectId?.trim() || asset.id,
title: asset.label,
src: asset.src,
mediaType: asset.mediaType,
thumbnailSrc: asset.thumbnailSrc,
x: 0,
y: 0,
width: asset.width,
height: asset.height,
originalWidth: asset.width,
originalHeight: asset.height,
zIndex: 0,
sourceType: asset.sourceType,
prompt: asset.prompt ?? null,
actualPrompt: asset.actualPrompt ?? null,
model: asset.model ?? null,
provider: asset.provider ?? null,
taskId: asset.taskId ?? null,
objectKey: asset.objectKey ?? null,
assetObjectId: asset.assetObjectId ?? null,
sourceResourceId: asset.sourceResourceId ?? null,
sourceAssetId: asset.id,
assetKind: asset.assetKind ?? null,
generationInputs: asset.generationInputs ?? null,
generationInputsHydrationState: asset.generationInputsHydrationState,
imageSequenceFrames: asset.imageSequenceFrames,
imageSequenceDurationMs: asset.imageSequenceDurationMs,
};
}
// 中文注释:服务端持久化顺序是 OSS PUT → asset object → project resource → editor asset
// → 画布收口,非事务。看到孤儿 generating 占位只能说明最后一步没做完,前面几步可能已经
// 成功。所以不能断言「什么都没发生」,只能指路让用户自己核对素材库。
// 加载期剥离与页面打开期的到期清理共用这一条,两条路径对用户完全一致。
const DEAD_INLINE_PLACEHOLDER_NOTICE =
'上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。';
export function ImageCanvasEditorView({
legacyWalletBalance = null,
onProjectAccessLost,
}: ImageCanvasEditorViewProps = {}) {
const authUi = useAuthUi();
const [, setGenerationPricingVersion] = useState(0);
const walletOwnerUserId = usePlatformWalletStore(
(state) => state.ownerUserId,
);
const storedMudPointBalance = usePlatformWalletStore(
(state) => state.mudPointBalance,
);
const storedLegacyWalletBalance = usePlatformWalletStore(
(state) => state.legacyWalletBalance,
);
const storedMudPointBalanceStatus = usePlatformWalletStore(
(state) => state.mudPointBalanceStatus,
);
const storedMudPointBalanceError = usePlatformWalletStore(
(state) => state.mudPointBalanceError,
);
const onWalletBalanceMayHaveChanged = usePlatformWalletStore(
(state) => state.onWalletBalanceMayHaveChanged,
);
const currentWalletOwnerUserId =
authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null;
const walletOwnerMatchesCurrentUser =
Boolean(currentWalletOwnerUserId) &&
walletOwnerUserId === currentWalletOwnerUserId;
const mudPointBalance = walletOwnerMatchesCurrentUser
? storedMudPointBalance
: null;
const mudPointBalanceError = walletOwnerMatchesCurrentUser
? storedMudPointBalanceError
: '';
const walletBalance =
mudPointBalance?.totalPoints ??
(walletOwnerMatchesCurrentUser
? (storedLegacyWalletBalance ?? legacyWalletBalance)
: null);
const isWalletBalanceLoading =
Boolean(currentWalletOwnerUserId) &&
(!walletOwnerMatchesCurrentUser ||
storedMudPointBalanceStatus === 'idle' ||
storedMudPointBalanceStatus === 'loading');
const editorRootRef = useRef<HTMLElement | null>(null);
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
const assetListRef = useRef<HTMLDivElement | null>(null);
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
const authUiRef = useRef(authUi);
const hasRequestedLoginRef = useRef(false);
const layerCounterRef = useRef(0);
const layersRef = useRef<CanvasLayer[]>([]);
const canvasGenerationDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
const canvasBackgroundColorRef = useRef(DEFAULT_CANVAS_BACKGROUND_COLOR);
const captureCanvasHistoryRef = useRef<(action: CanvasHistoryAction) => void>(
() => {},
);
const discardCanvasHistoryEntriesContainingLayerRef = useRef<
(matchesLayer: (layer: CanvasLayer) => boolean) => void
>(() => {});
const layerResourcePersistenceVersionRef = useRef(new Map<string, number>());
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
const musicToolWrapRef = useRef<HTMLSpanElement | null>(null);
const publicationToolWrapRef = useRef<HTMLSpanElement | null>(null);
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const characterReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const generationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const publicationReferenceButtonRef = useRef<HTMLButtonElement | null>(null);
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const startupIntentConsumedRef = useRef(false);
const hasLoadedAgentRuntimeConfigRef = useRef(false);
const selectedLayerIdRef = useRef<string | null>(null);
const showRechargeEntry = shouldShowRechargeEntry();
const currentEditorUserId = authUi?.user?.id ?? null;
const [isToolbarGuideVisible, setIsToolbarGuideVisible] = useState(false);
const [isAgentConversationEnabled, setIsAgentConversationEnabled] =
useState(false);
const [isAgentConversationOpen, setIsAgentConversationOpen] = useState(false);
useEffect(() => {
let isMounted = true;
void loadEditorGenerationPricing()
.then((pricing) => {
if (!isMounted) {
return;
}
applyEditorGenerationPricingConfig(pricing);
setGenerationPricingVersion((version) => version + 1);
})
.catch(() => {
// 中文注释:价格接口异常时保留前端内置兜底,不阻断画板使用。
});
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
let isMounted = true;
let requestId = 0;
const refreshAgentRuntimeConfig = () => {
const currentRequestId = requestId + 1;
requestId = currentRequestId;
void loadFrontendRuntimeConfig()
.then((config) => {
if (!isMounted || currentRequestId !== requestId) {
return;
}
const enabled = config.imageEditorAgentSidebarEnabled === true;
const shouldAutoOpen = !hasLoadedAgentRuntimeConfigRef.current;
hasLoadedAgentRuntimeConfigRef.current = true;
setIsAgentConversationEnabled(enabled);
setIsAgentConversationOpen((currentOpen) => {
if (!enabled) {
return false;
}
return shouldAutoOpen ? true : currentOpen;
});
})
.catch(() => {
if (!isMounted || currentRequestId !== requestId) {
return;
}
hasLoadedAgentRuntimeConfigRef.current = true;
setIsAgentConversationEnabled(false);
setIsAgentConversationOpen(false);
});
};
refreshAgentRuntimeConfig();
const handleWindowFocus = () => {
refreshAgentRuntimeConfig();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
refreshAgentRuntimeConfig();
}
};
window.addEventListener('focus', handleWindowFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
isMounted = false;
window.removeEventListener('focus', handleWindowFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [currentEditorUserId]);
const selectedLayerIdsRef = useRef<string[]>([]);
const setQuickEditPanelRef = useRef<
Dispatch<SetStateAction<QuickEditPanelState | null>>
>(() => {});
const appendUiAssetExtractionReferencesRef = useRef<
(references: CharacterReferenceImage[]) => void
>(() => {});
const resolveImageReferenceUploadCapacityRef = useRef<
(target: UploadTarget) => ImageReferenceUploadCapacity | null
>(() => null);
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
() => {},
);
const clearDeletedLayerGenerationStateRef = useRef<
(targetLayerId: string) => void
>(() => {});
const suppressAssetClickRef = useRef(false);
const [layers, setLayers] = useState<CanvasLayer[]>([]);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
const [pendingGenerationDeleteDialog, setPendingGenerationDeleteDialog] =
useState<CanvasGenerationDialogState | null>(null);
const [imageContextMenu, setImageContextMenu] =
useState<ImageContextMenuState | null>(null);
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
null,
);
const [isViewportInteracting, setIsViewportInteracting] = useState(false);
const [uploadDropTarget, setUploadDropTarget] = useState<
'canvas' | 'assets' | null
>(null);
const [isShortcutDialogOpen, setIsShortcutDialogOpen] = useState(false);
const captureViewportHistory = useCallback(() => {
captureCanvasHistoryRef.current({ type: 'change-viewport' });
}, []);
const {
viewport,
setViewport,
animateViewportTo,
canvasSize,
minimapModel,
updateScaleFromCenter,
fitLayers,
resolveCanvasPoint,
getCanvasDropPoint,
getCanvasPointFromClient,
moveViewportFromMinimapPointer,
updateViewportFromMinimapDrag,
flushMinimapViewportDrag,
} = useImageCanvasViewportControls({
canvasViewportRef,
layers,
captureCanvasHistory: captureViewportHistory,
});
const beginViewportInteraction = useCallback(() => {
setIsViewportInteracting(true);
}, []);
const endViewportInteraction = useCallback(() => {
setIsViewportInteracting(false);
}, []);
selectedLayerIdRef.current = selectedLayerId;
selectedLayerIdsRef.current = selectedLayerIds;
layersRef.current = layers;
viewportRef.current = viewport;
authUiRef.current = authUi;
const openEditorLoginModal = useCallback(
(postLoginAction?: (() => void) | null) => {
authUiRef.current?.openLoginModal(postLoginAction);
},
[],
);
const {
buyRechargeProduct,
closeNativeWechatPayment,
confirmNativeWechatPayment,
isLoadingRechargeCenter,
isLoadingWalletLedger,
isRechargeOpen,
isRewardCodeOpen,
isSubmittingRewardCode,
isWalletLedgerOpen,
loadRechargeCenter,
loadWalletLedger,
nativeWechatPayment,
openRechargeOrRewardCodeModal,
openWalletLedgerPanel,
rechargeCenter,
rechargeError,
rechargePaymentResult,
rewardCodeError,
rewardCodeInput,
rewardCodeSuccess,
setIsRechargeOpen,
setIsRewardCodeOpen,
setIsWalletLedgerOpen,
setRechargePaymentResult,
setRewardCodeInput,
submittingRechargeProductId,
submitRewardCode,
walletLedger,
walletLedgerError,
wechatRechargeOrderConfirmationState,
} = usePlatformProfileCenterController({
activeTab: 'editor-canvas',
isAuthenticated: Boolean(authUi?.user),
showRechargeEntry,
requestLogin: () => authUiRef.current?.openLoginModal(),
currentUser: authUi?.user ?? null,
});
const refreshEditorWalletState = useCallback(() => {
if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) {
return;
}
void onWalletBalanceMayHaveChanged();
}, [onWalletBalanceMayHaveChanged]);
const isAccountPaymentModalOpen =
isRewardCodeOpen ||
isRechargeOpen ||
isWalletLedgerOpen ||
Boolean(nativeWechatPayment) ||
Boolean(rechargePaymentResult) ||
Boolean(wechatRechargeOrderConfirmationState);
useEffect(() => {
if (!authUi || authUi.user || authUi.canAccessProtectedData) {
hasRequestedLoginRef.current = false;
return;
}
if (hasRequestedLoginRef.current) {
return;
}
hasRequestedLoginRef.current = true;
authUi.openLoginModal(() => {
window.location.reload();
});
}, [authUi]);
const {
projectTitle,
setProjectTitle,
projectRenameValue,
setProjectRenameValue,
isRenamingProject,
isProjectRenameSaving,
projectRenameError,
activeSidebarPanel,
setActiveSidebarPanel,
activeTool,
setActiveTool,
isZoomMenuOpen,
isBackgroundSettingsOpen,
isMinimapOpen,
canvasBackgroundColor,
canvasBackgroundHexValue,
startProjectRename,
cancelProjectRename,
submitProjectRename,
resetProjectRenameError,
applyCanvasBackgroundColor,
handleCanvasBackgroundHexChange,
closeEditorChromePanels,
toggleSidebarPanel,
toggleZoomMenu,
closeZoomMenu,
toggleBackgroundSettings,
toggleMinimap,
} = useImageCanvasEditorChrome({ openEditorLoginModal });
canvasBackgroundColorRef.current = canvasBackgroundColor;
const discardAssetRelatedCanvasHistory = useCallback(
(matchesLayer: (layer: CanvasLayer) => boolean) =>
discardCanvasHistoryEntriesContainingLayerRef.current(matchesLayer),
[],
);
const canDeleteLayersRef = useRef<(targetLayerIds: string[]) => boolean>(
() => true,
);
const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({
layers,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
onDeleteLayerSideEffects: (layerId) =>
clearDeletedLayerGenerationStateRef.current(layerId),
canDeleteLayers: (targetLayerIds) =>
canDeleteLayersRef.current(targetLayerIds),
discardHistoryEntriesContainingLayer: discardAssetRelatedCanvasHistory,
});
const {
assetFolders,
setAssetFolders,
assets,
setAssets,
groupedAssets,
renamingAsset,
setRenamingAsset,
renamingFolder,
setRenamingFolder,
creatingFolder,
setCreatingFolder,
newFolderName,
setNewFolderName,
activeUploadFolderId,
setActiveUploadFolderId,
isAssetSelectionMode,
selectedAssetIds,
selectedAssets,
assetMarquee,
assetPointerDrag,
setAssetPointerDrag,
assetMoveDropFolderId,
pinnedAssetMoveFolderId,
resolveAssetFolderId,
updateAssetMoveDropFolder,
startRenamingAsset,
commitAssetRename,
toggleAssetFolder,
commitNewAssetFolder,
deleteUploadedAsset,
submitAssetToShowcase,
startRenamingFolder,
commitFolderRename,
deleteAssetFolder,
toggleAssetSelected,
toggleAllAssetsSelected,
deleteSelectedAssets,
moveAssetToFolder,
closeAssetSelectionMode,
openAssetSelectionMode,
handleAssetMarqueePointerDown,
handleAssetMarqueePointerMove,
handleAssetMarqueePointerUp,
handleAssetMarqueePointerCancel,
handleAssetMarqueeLostPointerCapture,
refreshAssetLibrary,
} = useImageCanvasAssetLibrary({
assetListRef,
assetPointerDragRef,
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
openEditorLoginModal,
onDeleteAssets: removeCanvasLayersLinkedToAssets,
onOpenSelectionMode: () => setUploadDropTarget(null),
});
useEffect(() => {
if (activeSidebarPanel !== 'assets' && isAssetSelectionMode) {
closeAssetSelectionMode();
}
}, [activeSidebarPanel, closeAssetSelectionMode, isAssetSelectionMode]);
const pendingImageReferenceUploadCountRef = useRef(0);
const isPendingReferenceUploadContextMutationLocked = useCallback(
() => pendingImageReferenceUploadCountRef.current > 0,
[],
);
const rejectPendingReferenceUploadContextMutation = useCallback(() => {
window.alert('参考图正在上传,请等待上传完成后再切换或关闭生成面板');
}, []);
const handleActivateCanvasGenerationDialog = useCallback(() => {
closeGenerationTransientStateRef.current();
setSelectedLayerId(null);
setSelectedLayerIds([]);
setImageContextMenu(null);
setContextMenu(null);
}, []);
const {
generateDialog,
setGenerateDialog,
generateDialogRef,
setInactiveGenerateDialogs,
inactiveGenerateDialogsRef,
activeCanvasGenerationDialog,
canvasGenerationDialogs,
getCanvasGenerationDialogsSnapshot,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
activateCanvasGenerationDialog,
restoreCanvasGenerationDialogs,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
} = useCanvasGenerationDialogs({
onActivate: handleActivateCanvasGenerationDialog,
isContextMutationLocked: isPendingReferenceUploadContextMutationLocked,
onContextMutationRejected: rejectPendingReferenceUploadContextMutation,
});
canvasGenerationDialogsRef.current = canvasGenerationDialogs;
const canvasHistoryRefs = useMemo(
() => ({
layersRef,
viewportRef,
generateDialogRef,
inactiveGenerateDialogsRef,
selectedLayerIdRef,
selectedLayerIdsRef,
}),
[generateDialogRef, inactiveGenerateDialogsRef],
);
const canvasHistorySetters = useMemo(
() => ({
setLayers,
setViewport,
setGenerateDialog,
setInactiveGenerateDialogs,
setSelectedLayerId,
setSelectedLayerIds,
}),
[
setGenerateDialog,
setInactiveGenerateDialogs,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
setViewport,
],
);
const canvasHistoryResetters = useMemo(
() => ({
setHoveredLayerId,
setMetadataLayer,
resetCanvasInteractionState: () =>
resetCanvasInteractionStateRef.current(),
}),
[],
);
const {
canUndo,
canRedo,
getCanvasHistorySnapshot,
captureCanvasHistory,
discardCanvasHistoryEntriesContainingLayer,
undoCanvasChange,
redoCanvasChange,
} = useCanvasHistory({
refs: canvasHistoryRefs,
setters: canvasHistorySetters,
resetters: canvasHistoryResetters,
});
captureCanvasHistoryRef.current = captureCanvasHistory;
discardCanvasHistoryEntriesContainingLayerRef.current =
discardCanvasHistoryEntriesContainingLayer;
const [historyFeedback, setHistoryFeedback] =
useState<CanvasHistoryFeedback | null>(null);
useEffect(() => {
if (!historyFeedback) {
return;
}
const timer = window.setTimeout(() => setHistoryFeedback(null), 3000);
return () => window.clearTimeout(timer);
}, [historyFeedback]);
const showCanvasLayerCopyWarning = useCallback(
(reason: CanvasLayerCopyBlockReason) => {
setHistoryFeedback((currentFeedback) => ({
id: (currentFeedback?.id ?? 0) + 1,
tone: 'warning',
text:
reason === 'self-contained-local-unsupported'
? '历史序列帧素材暂不支持复制'
: reason === 'unresolved-resource'
? '素材资源尚未登记,暂不能复制'
: '素材仍在保存,请稍后重试',
}));
},
[],
);
const handleUndoCanvasChange = useCallback(() => {
const result = undoCanvasChange();
if (result.status === 'empty') {
return;
}
const actionLabel = formatCanvasHistoryAction(result.action);
setHistoryFeedback((currentFeedback) => ({
id: (currentFeedback?.id ?? 0) + 1,
tone: result.status === 'blocked' ? 'warning' : 'neutral',
text:
result.status === 'blocked'
? `无法撤销:${actionLabel}操作,可能会使图片消失`
: `已撤销:${actionLabel}操作`,
}));
}, [undoCanvasChange]);
const handleRedoCanvasChange = useCallback(() => {
const result = redoCanvasChange();
if (result.status === 'empty') {
return;
}
const actionLabel = formatCanvasHistoryAction(result.action);
setHistoryFeedback((currentFeedback) => ({
id: (currentFeedback?.id ?? 0) + 1,
tone: result.status === 'blocked' ? 'warning' : 'neutral',
text:
result.status === 'blocked'
? `无法恢复:${actionLabel}操作,可能会使图片消失`
: `已恢复:${actionLabel}操作`,
}));
}, [redoCanvasChange]);
const selectSingleLayer = useCallback(
(layerId: string | null) => {
setSelectedLayerId(layerId);
setSelectedLayerIds(layerId ? [layerId] : []);
if (layerId) {
setGenerateDialog((currentDialog) =>
isCanvasGenerationComposerVisible(currentDialog)
? {
...currentDialog,
composerOpen: false,
}
: currentDialog,
);
}
},
[setGenerateDialog],
);
const focusCanvasBounds = useCallback(
(
bounds: { x: number; y: number; width: number; height: number },
options: { reserveDialogSpace?: boolean; animate?: boolean } = {},
) => {
const nextViewport = fitViewportToBounds({
bounds,
canvasSize,
insets: {
left: TASK_FOCUS_HORIZONTAL_INSET,
right: TASK_FOCUS_HORIZONTAL_INSET,
top: TASK_FOCUS_TOP_INSET,
bottom:
TASK_FOCUS_BOTTOM_INSET +
(options.reserveDialogSpace ? TASK_FOCUS_DIALOG_BOTTOM_INSET : 0),
},
padding: TASK_FOCUS_PADDING,
});
if (options.animate) {
animateViewportTo(nextViewport);
return;
}
setViewport(nextViewport);
},
[animateViewportTo, canvasSize, setViewport],
);
const focusCanvasResource = useCallback(
(resourceId: string): ImageCanvasActionResult => {
const normalizedResourceId = resourceId.trim();
if (!normalizedResourceId) {
return { successed: false, reason: 'other' };
}
// 同一 resourceId 对应多个画布图层时,定位顺序中的首个图层,保持单目标聚焦行为。
const targetLayer = layersRef.current.find(
(layer) => layer.resourceId === normalizedResourceId,
);
if (!targetLayer) {
return { successed: false, reason: 'not-found-on-canva' };
}
focusCanvasBounds(targetLayer, { animate: true });
return { successed: true };
},
[focusCanvasBounds],
);
const focusCanvasLayerById = useCallback(
(layerId: string) => {
const targetLayer = layersRef.current.find(
(layer) => layer.id === layerId,
);
if (!targetLayer) {
return;
}
selectSingleLayer(targetLayer.id);
setActiveTool('select');
setActiveSidebarPanel('layers');
focusCanvasBounds(targetLayer);
},
[
focusCanvasBounds,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
],
);
const focusCanvasGenerationDialog = useCallback(
(dialog: CanvasGenerationDialogState) => {
if (
dialog.mode === 'quick-edit' &&
dialog.sourceLayerId &&
!dialog.generatedLayerId
) {
const sourceLayer = layersRef.current.find(
(layer) => layer.id === dialog.sourceLayerId,
);
if (sourceLayer) {
setSelectedLayerId(sourceLayer.id);
setSelectedLayerIds([sourceLayer.id]);
setActiveTool('generate');
setActiveSidebarPanel('layers');
setViewport(
resolveQuickEditFocusViewport({ sourceLayer, canvasSize }),
);
return;
}
}
if (dialog.generatedLayerId) {
focusCanvasLayerById(dialog.generatedLayerId);
return;
}
if (!dialog.placeholder) {
return;
}
setSelectedLayerId(null);
setSelectedLayerIds([getCanvasGenerationSelectionId(dialog.id)]);
setActiveTool('select');
focusCanvasBounds(dialog.placeholder, {
reserveDialogSpace: isCanvasGenerationComposerVisible(dialog),
});
},
[
canvasSize,
focusCanvasBounds,
focusCanvasLayerById,
setActiveSidebarPanel,
setActiveTool,
setViewport,
],
);
const focusExternalGenerationTask = useCallback(
(task: ExternalGenerationTaskRecord) => {
const targetLayer = layersRef.current.find(
(layer) =>
layer.taskId === task.jobId ||
layer.id === task.sourceEntityId ||
layer.resourceId === task.sourceEntityId ||
layer.sourceResourceId === task.sourceEntityId,
);
if (targetLayer) {
focusCanvasLayerById(targetLayer.id);
return;
}
const targetDialog = canvasGenerationDialogsRef.current.find(
(dialog) =>
dialog.id === task.sourceEntityId ||
dialog.sourceLayerId === task.sourceEntityId ||
dialog.generatedLayerId === task.sourceEntityId,
);
if (targetDialog) {
focusCanvasGenerationDialog(targetDialog);
}
},
[focusCanvasGenerationDialog, focusCanvasLayerById],
);
const persistGeneratedAsset = useCallback(
(layer: CanvasLayer) => {
if (authUiRef.current && !authUiRef.current.canAccessProtectedData) {
openEditorLoginModal();
return;
}
createEditorAsset({
folderId: activeUploadFolderId,
label: layer.title,
imageSrc: layer.src,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
width: layer.originalWidth,
height: layer.originalHeight,
sourceType: 'generated',
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
imageSequenceFrames: layer.imageSequenceFrames,
imageSequenceDurationMs: layer.imageSequenceDurationMs,
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
})
.then((asset) => {
const persistedAssetKind = canvasAssetKindOrNull(asset.assetKind);
if (
layer.assetKind === 'character-animation' &&
(persistedAssetKind !== 'character-animation' ||
(asset.imageSequenceFrames?.length ?? 0) < 2 ||
!(
asset.imageSequenceDurationMs &&
asset.imageSequenceDurationMs > 0
))
) {
throw new Error('服务器未返回完整的角色动作正式字段');
}
setAssets((currentAssets) => [
...currentAssets.filter(
(currentAsset) => currentAsset.id !== asset.assetId,
),
{
id: asset.assetId,
label: asset.label,
src: asset.imageSrc,
mediaType: layer.mediaType,
width: asset.width,
height: asset.height,
folderId: asset.folderId,
sourceKind: 'uploaded',
sourceType: asset.sourceType,
persisted: true,
prompt: asset.prompt ?? undefined,
actualPrompt: asset.actualPrompt ?? undefined,
model: asset.model ?? undefined,
provider: asset.provider ?? undefined,
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
imageSequenceFrames: asset.imageSequenceFrames ?? undefined,
imageSequenceDurationMs:
asset.imageSequenceDurationMs ?? undefined,
assetKind: persistedAssetKind,
generationInputs: generationInputsOrNull(asset.generationInputs),
generationInputsHydrationState:
hydrateCanvasGenerationInputsResult(asset.generationInputs)
.state,
},
]);
setAssetFolders((currentFolders) =>
currentFolders.map((folder) =>
folder.id === asset.folderId
? {
...folder,
collapsed: false,
}
: folder,
),
);
setLayers((currentLayers) =>
currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? {
...currentLayer,
sourceAssetId: asset.assetId,
objectKey: asset.objectKey ?? currentLayer.objectKey,
assetObjectId:
asset.assetObjectId ?? currentLayer.assetObjectId,
}
: currentLayer,
),
);
})
.catch((error: unknown) => {
const isAuthError =
error instanceof Error &&
'status' in error &&
(error.status === 401 || error.status === 403);
if (isAuthError) {
openEditorLoginModal();
return;
}
window.alert(
error instanceof Error && error.message.trim()
? error.message
: '素材保存失败,请稍后重试',
);
});
},
[activeUploadFolderId, openEditorLoginModal, setAssetFolders, setAssets],
);
const upsertGeneratedAsset = useCallback(
(asset: EditorAssetSnapshot) => {
setAssets((currentAssets) => [
...currentAssets.filter(
(currentAsset) => currentAsset.id !== asset.assetId,
),
{
id: asset.assetId,
label: asset.label,
src: asset.imageSrc,
width: asset.width,
height: asset.height,
folderId: asset.folderId,
sourceKind: 'uploaded',
sourceType: asset.sourceType,
persisted: true,
prompt: asset.prompt ?? undefined,
actualPrompt: asset.actualPrompt ?? undefined,
model: asset.model ?? undefined,
provider: asset.provider ?? undefined,
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
thumbnailSrc: asset.thumbnailSrc ?? undefined,
imageSequenceFrames: asset.imageSequenceFrames ?? undefined,
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
generationInputsHydrationState: hydrateCanvasGenerationInputsResult(
asset.generationInputs,
).state,
},
]);
setAssetFolders((currentFolders) =>
currentFolders.some((folder) => folder.id === asset.folderId)
? currentFolders.map((folder) =>
folder.id === asset.folderId
? { ...folder, collapsed: false }
: folder,
)
: [
...currentFolders,
{
id: asset.folderId,
label: '项目素材',
collapsed: false,
systemDefault: true,
persisted: true,
},
],
);
},
[setAssetFolders, setAssets],
);
const projectPersistenceRefs = useMemo(
() => ({
layersRef,
viewportRef,
canvasGenerationDialogsRef,
getCanvasGenerationDialogsSnapshot,
canvasBackgroundColorRef,
selectedLayerIdRef,
selectedLayerIdsRef,
}),
[getCanvasGenerationDialogsSnapshot],
);
const projectPersistenceSetters = useMemo(
() => ({
setProjectTitle,
setProjectRenameValue,
setViewport,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
selectSingleLayer,
setLayerCounter: (value: number) => {
layerCounterRef.current = value;
},
restoreCanvasGenerationDialogs,
applyCanvasBackgroundColor,
}),
[
applyCanvasBackgroundColor,
setSelectedLayerId,
setSelectedLayerIds,
restoreCanvasGenerationDialogs,
selectSingleLayer,
setLayers,
setProjectRenameValue,
setProjectTitle,
setViewport,
],
);
const {
projectId,
isProjectReady,
assetKindFallbackNotice,
appendCanvasLayersWithResources,
applyProjectSnapshot,
flushProjectPersistence,
deadInlinePlaceholderDropCount,
} = useImageCanvasProjectPersistence({
refs: projectPersistenceRefs,
setters: projectPersistenceSetters,
layers,
canvasGenerationDialogs,
viewport,
canvasSize,
canvasBackgroundColor,
isViewportInteracting,
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
currentUserId: currentEditorUserId,
openEditorLoginModal,
onProjectAccessLost,
});
const returnToProjects = useCallback(async () => {
await flushProjectPersistence();
replaceAppHistoryPath('/project');
window.dispatchEvent(new PopStateEvent('popstate'));
}, [flushProjectPersistence]);
const setEditorProjectContextId = useImageCanvasContextStore(
(state) => state.setProjectId,
);
useEffect(() => {
setEditorProjectContextId(projectId);
}, [projectId, setEditorProjectContextId]);
const applyGeneratedProjectSnapshot = useCallback(
(
project: EditorProjectSnapshot,
action: CanvasHistoryAction = { type: 'generate-image', count: 1 },
) => {
captureCanvasHistory(action);
applyProjectSnapshot(project);
if (action.type !== 'perfect-pixel') {
void refreshAssetLibrary();
}
},
[applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary],
);
const refreshCanvas = useCallback(() => {
if (!projectId) {
return;
}
void loadEditorProject(projectId)
.then(applyGeneratedProjectSnapshot)
.catch(() => undefined);
}, [applyGeneratedProjectSnapshot, projectId]);
const persistUpdatedLayerResource = useCallback(
(layer: CanvasLayer) => {
const requestVersion =
(layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1;
layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion);
if (!projectId) {
return;
}
const imageSrc = resolveLayerProjectResourceImageSrc(layer);
if (!imageSrc) {
return;
}
const resourceAssetKind = resolveLayerResourceAssetKind(layer);
void createEditorProjectResource(projectId, {
imageSrc,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
width: layer.originalWidth,
height: layer.originalHeight,
sourceType: layer.sourceType,
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
imageSequenceFrames: layer.imageSequenceFrames,
imageSequenceDurationMs: layer.imageSequenceDurationMs,
sourceResourceId: layer.sourceResourceId,
assetKind: resourceAssetKind,
generationInputs: layer.generationInputs,
})
.then((resource) => {
if (
layerResourcePersistenceVersionRef.current.get(layer.id) !==
requestVersion
) {
return;
}
const confirmedResourceAssetKind =
canvasAssetKindOrNull(resource.assetKind) ?? resourceAssetKind;
setLayers((currentLayers) => {
const nextLayers = currentLayers.map((currentLayer) =>
currentLayer.id === layer.id
? {
...currentLayer,
resourceId: resource.resourceId,
resourcePersistenceState: 'registered' as const,
resourceAssetKind: confirmedResourceAssetKind,
assetKind:
currentLayer.assetKindOverride ??
confirmedResourceAssetKind,
}
: currentLayer,
);
layersRef.current = nextLayers;
return nextLayers;
});
setMetadataLayer((currentLayer) =>
currentLayer?.id === layer.id
? {
...currentLayer,
resourceId: resource.resourceId,
resourcePersistenceState: 'registered' as const,
resourceAssetKind: confirmedResourceAssetKind,
assetKind:
currentLayer.assetKindOverride ??
confirmedResourceAssetKind,
}
: currentLayer,
);
})
.catch(() => undefined);
},
[projectId],
);
const {
assetExportStatus,
isExportingAssets,
exportCanvasAssets,
exportSelectedAssets,
exportLayerImage,
reportAssetError,
} = useImageCanvasAssetExportWorkflow({
layers,
projectId,
projectTitle,
});
const downloadAsset = useCallback(
(asset: EditorAsset) => {
exportLayerImage(createAssetActionLayer(asset));
},
[exportLayerImage],
);
const downloadSelectedAssets = useCallback(() => {
const selectedLayers = selectedAssets.map((asset, index) => ({
...createAssetActionLayer(asset),
zIndex: index,
}));
return exportSelectedAssets(selectedLayers);
}, [exportSelectedAssets, selectedAssets]);
const {
uploadInputRef,
uploadTarget,
pendingImageReferenceUploadCount,
requestUpload,
handleUploadInputChange,
addUploadedFiles,
} = useImageCanvasUploadWorkflow({
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
openEditorLoginModal,
projectId,
assetFolders,
activeUploadFolderId,
canvasSize,
viewport,
activeTool,
allocateUploadIndex: () => {
layerCounterRef.current += 1;
return layerCounterRef.current;
},
setAssetFolders,
setAssets,
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,
viewport,
setViewport,
canvasViewportRef,
setLayers,
layerCounterRef,
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
characterSpecButtonRef,
characterReferenceButtonRef,
generationReferenceButtonRef,
publicationReferenceButtonRef,
iconSpecButtonRef,
hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0,
generateDialog,
setGenerateDialog,
activeCanvasGenerationDialog,
canvasGenerationDialogs,
openCanvasGenerationDialog,
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
archiveActiveCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources,
selectSingleLayer,
fitLayers,
captureCanvasHistory,
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
requestUpload,
persistGeneratedAsset,
persistUpdatedLayerResource,
projectId,
currentUserId: currentEditorUserId,
assetFolderId: activeUploadFolderId,
upsertGeneratedAsset,
applyProjectSnapshot: applyGeneratedProjectSnapshot,
applyProjectSnapshotWithoutHistory: applyProjectSnapshot,
flushProjectPersistence,
refreshAssetLibrary,
onWalletBalanceMayHaveChanged: refreshEditorWalletState,
});
const handleEditorAgentConfirmSent = useCallback(() => {
generationSurface.refreshTaskList();
}, [generationSurface]);
const showGenerationWarning = generationSurface.showGenerationWarning;
useEffect(() => {
if (assetKindFallbackNotice) {
showGenerationWarning(assetKindFallbackNotice.message);
}
}, [assetKindFallbackNotice, showGenerationWarning]);
useEffect(() => {
if (deadInlinePlaceholderDropCount === 0) {
return;
}
showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE);
}, [deadInlinePlaceholderDropCount, showGenerationWarning]);
const handleInlinePlaceholdersExpired = useCallback(() => {
showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE);
}, [showGenerationWarning]);
const handleExternalGenerationTasksCompleted = useCallback(
(tasks: ExternalGenerationTaskRecord[]) => {
if (!projectId || tasks.length === 0) {
return;
}
const warning = tasks
.find((task) => task.warning?.trim())
?.warning?.trim();
if (warning) {
showGenerationWarning(warning);
}
},
[projectId, showGenerationWarning],
);
const effectiveIsAgentConversationOpen =
isAgentConversationEnabled && isAgentConversationOpen;
const toggleAgentConversation = useCallback(() => {
if (!isAgentConversationEnabled) {
return;
}
const nextOpen = !isAgentConversationOpen;
if (nextOpen && generationSurface.isTaskSidebarOpen) {
generationSurface.toggleTaskSidebar();
}
setIsAgentConversationOpen(nextOpen);
}, [generationSurface, isAgentConversationEnabled, isAgentConversationOpen]);
const toggleTaskSidebar = useCallback(() => {
if (
!generationSurface.isTaskSidebarOpen &&
effectiveIsAgentConversationOpen
) {
setIsAgentConversationOpen(false);
}
generationSurface.toggleTaskSidebar();
}, [effectiveIsAgentConversationOpen, generationSurface]);
const openQuickEditPanelWithAvailableCanvas = useCallback(
(layer: CanvasLayer) => {
if (generationSurface.isTaskSidebarOpen) {
generationSurface.toggleTaskSidebar();
}
setIsAgentConversationOpen(false);
generationSurface.openQuickEditPanel(layer);
},
[generationSurface],
);
const toggleCanvasSidebarPanel = useCallback(
(panel: SidebarPanel) => {
toggleSidebarPanel(panel);
},
[toggleSidebarPanel],
);
useEffect(() => {
if (!isAgentConversationEnabled && isAgentConversationOpen) {
setIsAgentConversationOpen(false);
return;
}
if (
isAgentConversationEnabled &&
generationSurface.isTaskSidebarOpen &&
isAgentConversationOpen
) {
generationSurface.toggleTaskSidebar();
}
}, [
generationSurface,
generationSurface.isTaskSidebarOpen,
isAgentConversationEnabled,
isAgentConversationOpen,
]);
const {
setQuickEditPanel,
setCropExpandPanel,
setCharacterAnimationPanel,
setIsSpecMenuOpen,
setIsGenerationReferenceMenuOpen,
setIsPublicationReferenceMenuOpen,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
isPickingGenerationReferenceFromCanvas,
setIsPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
isPickingPublicationReferenceFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
isPickingCharacterSpecFromCanvas,
setIsPickingCharacterSpecFromCanvas,
isPickingCharacterReferenceFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsIconSpecMenuOpen,
isPickingIconSpecFromCanvas,
setIsPickingIconSpecFromCanvas,
setIsUiDesignSpecMenuOpen,
isPickingUiDesignSpecFromCanvas,
setIsPickingUiDesignSpecFromCanvas,
openCharacterAnimationPanel,
openRedrawPanel,
openCropExpandPanel,
removeSelectedLayerBackground,
snapSelectedLayerToPerfectPixels,
activeInlineGenerationDialogOwnership,
perfectPixelLayerIds,
pendingPerfectPixelLayerIds,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
extractUiDesignAssets,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickQuickEditReferenceFromLayer,
pickPublicationReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
hideGeneratedLayerPanelAfterBlur,
clearDeletedLayerGenerationState,
uiAssetExtractionState,
uiAssetExtractionSourceLayer,
quickEditPanel,
quickEditSelectionState,
quickEditSelectionSourceLayer,
changeUiAssetExtractionTool,
changeUiAssetExtractionModel,
changeQuickEditSelectionTool,
startUiAssetExtractionPointer,
startQuickEditSelectionPointer,
moveUiAssetExtractionPointer,
moveQuickEditSelectionPointer,
endUiAssetExtractionPointer,
endQuickEditSelectionPointer,
cancelUiAssetExtractionPointer,
cancelQuickEditSelectionPointer,
cancelUiAssetExtraction,
appendUiAssetExtractionReferences,
removeUiAssetExtractionReference,
submitUiAssetExtraction,
generationComposerStyle,
generationComposerNode,
switchGenerationTool,
} = generationSurface;
closeGenerationTransientStateRef.current = () => {
setIsSpecMenuOpen(false);
setIsGenerationReferenceMenuOpen(false);
setIsPublicationReferenceMenuOpen(false);
setIsPickingGenerationReferenceFromCanvas(false);
setIsPickingQuickEditReferenceFromCanvas(false);
setIsPickingPublicationReferenceFromCanvas(false);
setIsCharacterSpecMenuOpen(false);
setIsCharacterReferenceMenuOpen(false);
setIsPickingCharacterSpecFromCanvas(false);
setIsPickingCharacterReferenceFromCanvas(false);
setIsIconSpecMenuOpen(false);
setIsPickingIconSpecFromCanvas(false);
setIsUiDesignSpecMenuOpen(false);
setIsPickingUiDesignSpecFromCanvas(false);
setImageContextMenu(null);
setContextMenu(null);
};
setQuickEditPanelRef.current = setQuickEditPanel;
clearDeletedLayerGenerationStateRef.current =
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:
generateDialog.specType === 'icon'
? 0
: imageReferenceCount(
resolveSpecGenerationReferences(generateDialog),
),
limit:
generateDialog.specType === 'icon'
? 1
: resolveDialogExtraImageReferenceLimit(generateDialog),
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.mode === 'spec'
? resolveSpecGenerationReferences(generateDialog)
: 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
? {
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;
}
const startupIntent = readCanvasStartupIntent();
if (!startupIntent.showToolbarGuide && !startupIntent.tool) {
startupIntentConsumedRef.current = true;
return;
}
startupIntentConsumedRef.current = true;
clearCanvasStartupIntent();
if (startupIntent.tool) {
setIsToolbarGuideVisible(false);
openCanvasStartupTool(startupIntent.tool, generationSurface);
return;
}
if (
startupIntent.showToolbarGuide &&
layersRef.current.length === 0 &&
canvasGenerationDialogsRef.current.length === 0
) {
setIsToolbarGuideVisible(true);
}
}, [generationSurface, isProjectReady]);
useEffect(() => {
if (isToolbarGuideVisible && isCanvasGenerationDialog(generateDialog)) {
setIsToolbarGuideVisible(false);
}
}, [generateDialog, isToolbarGuideVisible]);
const clearGenerationPanelsAfterBlur = useCallback(() => {
setQuickEditPanel(null);
setCropExpandPanel(null);
setCharacterAnimationPanel(null);
}, [setCharacterAnimationPanel, setCropExpandPanel, setQuickEditPanel]);
const {
selectedLayer,
selectedToolbarStyle,
imageContextMenuLayer,
contextShouldShowLayer,
contextShouldUnlockLayer,
clearCanvasFocus,
handleCanvasContextMenu,
handleLayerContextMenu,
handleGenerationFrameContextMenu,
} = useImageCanvasStageController({
layers,
canvasGenerationDialogs,
selectedLayerId,
selectedLayerIds,
setSelectedLayerId,
setSelectedLayerIds,
imageContextMenu,
setImageContextMenu,
contextMenu,
setContextMenu,
viewport,
canvasSize,
selectSingleLayer,
hideGeneratedLayerPanelAfterBlur,
clearGenerationPanelsAfterBlur,
getCanvasPointFromClient,
});
const openLayerGenerationDialog = useCallback(
(layer: CanvasLayer) => {
if (layer.generationInputsHydrationState === 'invalid-versioned') {
showGenerationWarning(
'保存的生成参数版本不受支持或数据损坏,无法改造。',
);
return false;
}
const existingDialog = [...canvasGenerationDialogsRef.current]
.reverse()
.find((dialog) => dialog.generatedLayerId === layer.id);
if (existingDialog) {
activateCanvasGenerationDialog(existingDialog);
setSelectedLayerId(layer.id);
setSelectedLayerIds([layer.id]);
return true;
}
const restoredDraft = createLayerGenerationDialogDraft({
sourceLayer: layer,
canvasSize,
viewport: viewportRef.current,
sourceAnimationLayer: findSourceAnimationLayer(
layersRef.current,
layer,
),
availableLayers: layersRef.current,
});
if (!restoredDraft) {
return false;
}
openCanvasGenerationDialog(restoredDraft);
setSelectedLayerId(layer.id);
setSelectedLayerIds([layer.id]);
return true;
},
[
activateCanvasGenerationDialog,
canvasSize,
openCanvasGenerationDialog,
setSelectedLayerId,
setSelectedLayerIds,
showGenerationWarning,
],
);
const openGeneratedLayerGenerationDialog = useCallback(
(layer: CanvasLayer) => {
// TODO(产品确认): 已完成的生成结果被普通点击时,是否仍应恢复并显示其生成浮层?
// 当前实现会这样做,但现有文档只定义点击图层后的选中/工具栏行为,以及用户显式
// 点击“改造”时的配方恢复,未定义这一隐式恢复入口。确认前保留既有行为。
if (layer.sourceType !== 'generated') {
return false;
}
return openLayerGenerationDialog(layer);
},
[openLayerGenerationDialog],
);
const removeCanvasGenerationDialog = useCallback(
(dialogId: string) => {
captureCanvasHistory({ type: 'delete-generation-result', count: 1 });
removeCanvasGenerationDialogById(dialogId);
setSelectedLayerId(null);
setSelectedLayerIds([]);
setImageContextMenu(null);
setContextMenu(null);
setActiveTool('select');
},
[
captureCanvasHistory,
removeCanvasGenerationDialogById,
setActiveTool,
setContextMenu,
setImageContextMenu,
setSelectedLayerId,
setSelectedLayerIds,
],
);
const requestRemoveCanvasGenerationDialog = useCallback(
(dialog: CanvasGenerationDialogState) => {
if (requiresGenerationDeleteConfirmation(dialog)) {
activateCanvasGenerationDialog(dialog);
setPendingGenerationDeleteDialog(dialog);
return;
}
removeCanvasGenerationDialog(dialog.id);
},
[activateCanvasGenerationDialog, removeCanvasGenerationDialog],
);
const contextMenuLayer =
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,
contextLayerCopyBlockReason,
isCanvasClipboardCopyBlocked,
pasteCanvasClipboard,
copyContextLayers,
copySelectedLayers,
duplicateContextLayers,
duplicateSelectedLayers,
moveContextLayers,
moveSelectedLayers,
groupContextLayers,
ungroupContextLayers,
toggleContextLayerVisibility,
toggleContextLayerLock,
flipContextLayers,
deleteContextLayers,
exportContextLayer,
deleteLayerById,
deleteSelectedLayer,
groupSelectedLayers,
} = useImageCanvasLayerCommands({
layers,
contextMenu,
selectedLayerId,
selectedLayerIds,
canvasGenerationDialogs,
setLayers,
appendCanvasLayersWithResources,
setSelectedLayerId,
setSelectedLayerIds,
setHoveredLayerId,
setMetadataLayer,
setContextMenu,
setImageContextMenu,
setActiveTool,
captureCanvasHistory,
selectSingleLayer,
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
onDeleteGenerationDialogSideEffects: removeCanvasGenerationDialogById,
onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog,
exportLayerImage,
onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning,
canDeleteLayers: (targetLayerIds) =>
canDeleteLayersRef.current(targetLayerIds),
});
const {
canvasMarquee,
isPanning,
snapGuide,
effectiveTool,
setIsSpacePanning,
setShiftPressed,
clearActiveInteraction,
handleCanvasPointerDown,
handleLayerPointerDown,
handleLayerClick,
handleGenerationFramePointerDown,
handleMinimapPointerDown,
handlePointerMove,
finishDrag,
} = useImageCanvasStageInteractions({
canvasViewportRef,
activeTool,
layers,
setLayers,
viewport,
setViewport,
selectedLayerIds,
setSelectedLayerId,
setSelectedLayerIds,
generateDialog,
canvasGenerationDialogs,
setGenerateDialog,
isPickingCharacterSpecFromCanvas,
isPickingGenerationReferenceFromCanvas,
isPickingQuickEditReferenceFromCanvas,
isPickingPublicationReferenceFromCanvas,
isPickingCharacterReferenceFromCanvas,
isPickingIconSpecFromCanvas,
isPickingUiDesignSpecFromCanvas,
clearCanvasFocus,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
pickQuickEditReferenceFromLayer,
pickPublicationReferenceFromLayer,
pickCharacterReferenceFromLayer,
pickIconSpecFromLayer,
pickUiDesignSpecFromLayer,
openLayerGenerationDialog: openGeneratedLayerGenerationDialog,
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
moveViewportFromMinimapPointer,
updateViewportFromMinimapDrag,
flushMinimapViewportDrag,
minimapScale: minimapModel?.scale ?? 1,
onViewportInteractionStart: beginViewportInteraction,
onViewportInteractionEnd: endViewportInteraction,
onCloseImageContextMenu: () => setImageContextMenu(null),
getCanvasHistorySnapshot,
captureCanvasHistory,
});
resetCanvasInteractionStateRef.current = clearActiveInteraction;
useEffect(() => {
if (!isAccountPaymentModalOpen) {
return;
}
clearActiveInteraction();
cancelUiAssetExtractionPointer();
cancelQuickEditSelectionPointer();
}, [
cancelQuickEditSelectionPointer,
cancelUiAssetExtractionPointer,
clearActiveInteraction,
isAccountPaymentModalOpen,
]);
const openAccountPaymentModal = useCallback(() => {
clearActiveInteraction();
cancelUiAssetExtractionPointer();
cancelQuickEditSelectionPointer();
openRechargeOrRewardCodeModal();
}, [
cancelQuickEditSelectionPointer,
cancelUiAssetExtractionPointer,
clearActiveInteraction,
openRechargeOrRewardCodeModal,
]);
const handleCanvasPointerDownWithUiExtractionDismiss = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (uiAssetExtractionState) {
event.preventDefault();
cancelUiAssetExtraction();
return;
}
handleCanvasPointerDown(event);
},
[cancelUiAssetExtraction, handleCanvasPointerDown, uiAssetExtractionState],
);
const deleteLayerByIdFromShortcut = useCallback(
(layerId: string | null) => deleteLayerByIdRef.current(layerId),
[],
);
// 中文注释:加载期的剥离只跑一次,当时未到期而被保留的孤儿占位需要这里补上到期清理,
// 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。
useInlineGenerationPlaceholderExpiry({
canvasGenerationDialogs,
activeInlineGenerationDialogOwnership,
removeCanvasGenerationDialogById,
onPlaceholdersExpired: handleInlinePlaceholdersExpired,
});
const confirmRemoveGeneratingDialog = useCallback(() => {
const dialog = pendingGenerationDeleteDialog;
if (!dialog) {
return;
}
setPendingGenerationDeleteDialog(null);
removeCanvasGenerationDialog(dialog.id);
}, [pendingGenerationDeleteDialog, removeCanvasGenerationDialog]);
const updateLayerAssetKind = useCallback(
(layerId: string, assetKind: CanvasAssetKind | null) => {
const targetLayer = layersRef.current.find(
(layer) => layer.id === layerId,
);
if (!targetLayer || targetLayer.assetKind === assetKind) {
return;
}
const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer);
if (!isCanvasAssetKindOverrideCompatible(resourceAssetKind, assetKind)) {
showGenerationWarning('该标签与素材的媒体类型不兼容,已保留原标签。');
return;
}
captureCanvasHistory({
type: 'change-asset-kind',
count: 1,
layerIds: [layerId],
});
const assetKindOverride =
assetKind === resourceAssetKind ? null : assetKind;
const nextLayer = {
...targetLayer,
resourceAssetKind,
assetKindOverride,
assetKind: assetKindOverride ?? resourceAssetKind,
};
setLayers((currentLayers) => {
const nextLayers = currentLayers.map((layer) =>
layer.id === layerId ? nextLayer : layer,
);
layersRef.current = nextLayers;
return nextLayers;
});
setMetadataLayer((currentLayer) =>
currentLayer?.id === layerId ? nextLayer : currentLayer,
);
setImageContextMenu(null);
setContextMenu(null);
},
[captureCanvasHistory, showGenerationWarning],
);
const selectAllCanvasObjects = useCallback(() => {
const layerSelectionIds = layersRef.current
.filter((layer) => !layer.hidden)
.map((layer) => layer.id);
const layerIds = new Set(layersRef.current.map((layer) => layer.id));
const generationSelectionIds = canvasGenerationDialogsRef.current
.filter((dialog) => {
if (!dialog.placeholder) {
return false;
}
return !(
dialog.generatedLayerId && layerIds.has(dialog.generatedLayerId)
);
})
.map((dialog) => getCanvasGenerationSelectionId(dialog.id));
const nextSelectionIds = [...layerSelectionIds, ...generationSelectionIds];
setSelectedLayerIds(nextSelectionIds);
setSelectedLayerId(layerSelectionIds[0] ?? null);
setImageContextMenu(null);
setContextMenu(null);
setGenerateDialog((currentDialog) =>
isCanvasGenerationComposerVisible(currentDialog)
? {
...currentDialog,
composerOpen: false,
}
: currentDialog,
);
}, [setGenerateDialog]);
const clearCanvasSelection = useCallback(() => {
setSelectedLayerId(null);
setSelectedLayerIds([]);
setImageContextMenu(null);
setContextMenu(null);
}, []);
const nudgeSelectedCanvasObjects = useCallback(
(delta: { x: number; y: number }) => {
const selectionIds = selectedLayerIdsRef.current.length
? selectedLayerIdsRef.current
: selectedLayerIdRef.current
? [selectedLayerIdRef.current]
: [];
const targetLayerIds = getSelectedLayerIds(selectionIds);
const targetDialogIds = getSelectedGenerationDialogIds(selectionIds);
if (!targetLayerIds.length && !targetDialogIds.length) {
return;
}
captureCanvasHistory({
type: targetLayerIds.length ? 'move-image' : 'move-generation-result',
count: targetLayerIds.length || targetDialogIds.length,
});
if (targetLayerIds.length) {
setLayers((currentLayers) =>
currentLayers.map((layer) =>
targetLayerIds.includes(layer.id)
? {
...layer,
x: layer.x + delta.x,
y: layer.y + delta.y,
}
: layer,
),
);
}
targetDialogIds.forEach((dialogId) => {
updateCanvasGenerationDialogById(dialogId, (dialog) =>
dialog.placeholder
? {
...dialog,
placeholder: {
...dialog.placeholder,
x: dialog.placeholder.x + delta.x,
y: dialog.placeholder.y + delta.y,
},
}
: dialog,
);
});
setImageContextMenu(null);
setContextMenu(null);
},
[captureCanvasHistory, updateCanvasGenerationDialogById],
);
const exportCanvasAssetsFromShortcut = useCallback(() => {
if (!layersRef.current.some((layer) => layer.src.trim().length > 0)) {
return;
}
void exportCanvasAssets();
}, [exportCanvasAssets]);
const zoomViewportBy = useCallback(
(scaleFactor: number) => {
updateScaleFromCenter(viewportRef.current.scale * scaleFactor);
},
[updateScaleFromCenter],
);
const switchTool = useCallback(
(tool: CanvasTool) => {
clearActiveInteraction();
if (tool === 'upload') {
requestUpload('asset', { addToCanvas: true });
return;
}
if (switchGenerationTool(tool)) {
return;
}
setActiveTool(tool);
},
[
clearActiveInteraction,
requestUpload,
setActiveTool,
switchGenerationTool,
],
);
useImageCanvasKeyboardShortcuts({
isInteractionPaused: isAccountPaymentModalOpen,
generateDialogRef,
selectedLayerIdRef,
selectedLayerIdsRef,
undoCanvasChange: handleUndoCanvasChange,
redoCanvasChange: handleRedoCanvasChange,
deleteLayerById: deleteLayerByIdFromShortcut,
deleteSelectedCanvasObjects: deleteSelectedLayer,
selectAllCanvasObjects,
clearCanvasSelection,
copySelectedCanvasLayers: copySelectedLayers,
pasteCanvasClipboard,
duplicateSelectedCanvasLayers: duplicateSelectedLayers,
moveSelectedCanvasLayers: moveSelectedLayers,
nudgeSelectedCanvasObjects,
requestRemoveCanvasGenerationDialog,
setActiveTool,
switchTool,
updateScaleFromCenter,
zoomViewportBy,
fitLayers,
toggleSidebarPanel,
toggleMinimap,
startProjectRename,
exportCanvasAssets: exportCanvasAssetsFromShortcut,
setGenerateDialog,
setImageContextMenu,
setContextMenu,
setQuickEditPanel,
closeEditorChromePanels,
setIsSpecMenuOpen,
setIsGenerationReferenceMenuOpen,
setIsPublicationReferenceMenuOpen:
generationSurface.setIsPublicationReferenceMenuOpen,
setIsPickingGenerationReferenceFromCanvas,
setIsPickingQuickEditReferenceFromCanvas,
setIsPickingPublicationReferenceFromCanvas,
setIsCharacterSpecMenuOpen,
setIsCharacterReferenceMenuOpen,
setIsPickingCharacterSpecFromCanvas,
setIsPickingCharacterReferenceFromCanvas,
setIsIconSpecMenuOpen,
setIsPickingIconSpecFromCanvas,
setIsUiDesignSpecMenuOpen,
setIsPickingUiDesignSpecFromCanvas,
setIsSpacePanning,
setShiftPressed,
});
const pasteCanvasOrSystemClipboard = useCallback(
(canvasPoint?: { x: number; y: number }) => {
if (pasteCanvasClipboard(canvasPoint)) {
return;
}
void readSystemClipboardImageFiles()
.then((imageFiles) => {
if (imageFiles.length) {
addUploadedFiles(imageFiles, { addToCanvas: true, canvasPoint });
}
})
.catch(() => {
// 系统剪贴板读取受浏览器权限限制,失败时保留原生粘贴路径。
});
},
[addUploadedFiles, pasteCanvasClipboard],
);
useEffect(() => {
const handleClipboardPaste = (event: ClipboardEvent) => {
if (isEditablePasteTarget(event.target)) {
return;
}
if (isAccountPaymentModalOpen) {
return;
}
const didPasteCanvasClipboard = pasteCanvasClipboard();
if (didPasteCanvasClipboard) {
event.preventDefault();
return;
}
const imageFiles = getClipboardImageFiles(event.clipboardData);
if (imageFiles.length) {
event.preventDefault();
addUploadedFiles(imageFiles, { addToCanvas: true });
return;
}
};
window.addEventListener('paste', handleClipboardPaste);
return () => {
window.removeEventListener('paste', handleClipboardPaste);
};
}, [addUploadedFiles, isAccountPaymentModalOpen, pasteCanvasClipboard]);
useEffect(() => {
const blockBrowserZoom = (event: WheelEvent) => {
const editorElement = editorRootRef.current;
if (
editorElement &&
event.target instanceof Node &&
editorElement.contains(event.target) &&
(event.ctrlKey || event.metaKey)
) {
event.preventDefault();
}
};
window.addEventListener('wheel', blockBrowserZoom, {
capture: true,
passive: false,
});
return () => {
window.removeEventListener('wheel', blockBrowserZoom, { capture: true });
};
}, []);
const {
addAssetLayer,
handleCanvasDragOver,
handleCanvasDragLeave,
handleCanvasDrop,
} = useImageCanvasAssetCanvasBridge({
assetPointerDragRef,
suppressAssetClickRef,
assets,
assetFolders,
layerCounterRef,
viewport,
canvasSize,
resolveAssetFolderId,
resolveCanvasPoint,
getCanvasDropPoint,
setAssetPointerDrag,
setActiveUploadFolderId,
setUploadDropTarget,
setHoveredLayerId,
updateAssetMoveDropFolder,
moveAssetToFolder,
captureCanvasHistory,
appendCanvasLayersWithResources,
selectSingleLayer,
addUploadedFiles,
onAssetError: reportAssetError,
});
deleteLayerByIdRef.current = deleteLayerById;
const assetDragPreview = assetPointerDrag?.active
? {
x: assetPointerDrag.currentClientX,
y: assetPointerDrag.currentClientY,
label:
assets.find((asset) => asset.id === assetPointerDrag.assetId)
?.label ?? '素材',
}
: null;
const sidebarProps = {
activeSidebarPanel,
assetListRef,
assetPointerDragRef,
suppressAssetClickRef,
assets,
groupedAssets,
assetFolders,
layers,
selectedLayerId,
selectedLayerIds,
isAssetSelectionMode,
selectedAssetIds,
assetMoveDropFolderId,
pinnedAssetMoveFolderId,
creatingFolder,
newFolderName,
renamingFolder,
renamingAsset,
isExportingSelectedAssets: isExportingAssets,
assetMarquee,
setCreatingFolder,
setNewFolderName,
setRenamingFolder,
setRenamingAsset,
setActiveUploadFolderId,
setUploadDropTarget,
setAssetPointerDrag,
setImageContextMenu,
setContextMenu,
onAssetMarqueePointerDown: handleAssetMarqueePointerDown,
onAssetMarqueePointerMove: handleAssetMarqueePointerMove,
onAssetMarqueePointerUp: handleAssetMarqueePointerUp,
onAssetMarqueePointerCancel: handleAssetMarqueePointerCancel,
onAssetMarqueeLostPointerCapture: handleAssetMarqueeLostPointerCapture,
updateAssetMoveDropFolder,
addUploadedFiles,
requestUpload,
moveAssetToFolder,
commitNewAssetFolder,
toggleAssetFolder,
startRenamingFolder,
commitFolderRename,
deleteAssetFolder,
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
submitAssetToShowcase,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset: downloadAsset,
toggleAllAssetsSelected,
downloadSelectedAssets,
deleteSelectedAssets,
closeAssetSelectionMode,
openAssetSelectionMode,
groupSelectedLayers,
selectSingleLayer,
resolveContextMenuPosition,
getCanvasPointFromClient,
};
const topbarProps = {
projectTitle,
projectRenameValue,
isRenamingProject,
isProjectRenameSaving,
projectRenameError,
layers,
walletBalance,
walletBreakdown: mudPointBalance,
isWalletBalanceLoading,
isWalletDetailsLoading: isLoadingRechargeCenter,
walletDetailsError: rechargeError || mudPointBalanceError || null,
currentUser: authUi?.user,
assetExportStatus,
isExportingAssets,
setProjectRenameValue,
startProjectRename,
cancelProjectRename,
submitProjectRename,
resetProjectRenameError,
exportCanvasAssets,
onOpenShortcuts: () => setIsShortcutDialogOpen(true),
onRequestWalletDetails: loadRechargeCenter,
onRecharge: openAccountPaymentModal,
onOpenWalletLedger: openWalletLedgerPanel,
onReturnToProjects: returnToProjects,
onOpenAccount: () => {
if (authUi?.user) {
authUi.openAccountModal();
return;
}
authUi?.openLoginModal();
},
};
const stageProps = {
canvasViewportRef,
specToolWrapRef,
musicToolWrapRef,
publicationToolWrapRef,
isInteractionPaused: isAccountPaymentModalOpen,
isPanning,
effectiveTool,
canvasBackgroundColor,
canvasBackgroundHexValue,
viewport,
snapGuide,
layers,
selectedLayer,
selectedLayerIds,
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
editorAgentAssets: assets,
isToolbarGuideVisible,
taskListRefreshKey: generationSurface.taskListRefreshKey,
isTaskSidebarOpen: generationSurface.isTaskSidebarOpen,
isAgentConversationEnabled,
isAgentConversationOpen: effectiveIsAgentConversationOpen,
generateDialog,
cropExpandPanel: generationSurface.cropExpandPanel,
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
uiAssetExtractionState: isAccountPaymentModalOpen
? null
: uiAssetExtractionState,
uiAssetExtractionSourceLayer: isAccountPaymentModalOpen
? null
: uiAssetExtractionSourceLayer,
quickEditSelectionState,
quickEditSelectionSourceLayer,
hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0,
generationComposerStyle,
selectedToolbarStyle,
perfectPixelLayerIds,
pendingPerfectPixelLayerIds,
splittingIconSpritesheetLayerIds,
uploadDropTarget,
contextMenu,
canvasClipboard,
imageContextMenu,
imageContextMenuLayer,
contextShouldShowLayer,
contextShouldUnlockLayer,
canCopyContextLayers,
contextLayerCopyBlockReason,
isCanvasClipboardCopyBlocked,
canUndo,
canRedo,
historyFeedback,
isZoomMenuOpen,
isBackgroundSettingsOpen,
activeSidebarPanel,
isMinimapOpen,
minimapModel,
onCanvasPointerDown: handleCanvasPointerDownWithUiExtractionDismiss,
onCanvasPointerMove: handlePointerMove,
onCanvasPointerUp: finishDrag,
onCanvasDragOver: handleCanvasDragOver,
onCanvasDragLeave: handleCanvasDragLeave,
onCanvasDrop: handleCanvasDrop,
onCanvasContextMenu: handleCanvasContextMenu,
onLayerPointerDown: handleLayerPointerDown,
onLayerClick: handleLayerClick,
onSelectLayer: selectSingleLayer,
onLayerContextMenu: handleLayerContextMenu,
onLayerMouseEnter: setHoveredLayerId,
onLayerMouseLeave: (layerId: string) =>
setHoveredLayerId((currentId) =>
currentId === layerId ? null : currentId,
),
onOpenLayerMetadata: (layer: CanvasLayer) => {
setMetadataLayer(layer);
selectSingleLayer(layer.id);
},
onUpdateLayerAssetKind: updateLayerAssetKind,
onGenerationFramePointerDown: handleGenerationFramePointerDown,
onGenerationFrameContextMenu: handleGenerationFrameContextMenu,
onActivateGenerationDialog: activateCanvasGenerationDialog,
onFocusExternalTask: focusExternalGenerationTask,
onExternalTasksCompleted: handleExternalGenerationTasksCompleted,
onExternalTaskWalletMayHaveChanged: refreshEditorWalletState,
onEditorAgentConfirmSent: handleEditorAgentConfirmSent,
onToggleTaskSidebar: toggleTaskSidebar,
onToggleAgentConversation: toggleAgentConversation,
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
onOpenQuickEditPanel: openQuickEditPanelWithAvailableCanvas,
onOpenRedrawPanel: openRedrawPanel,
onOpenCropExpandPanel: openCropExpandPanel,
onRemoveBackground: removeSelectedLayerBackground,
onPerfectPixel: (layer: CanvasLayer) => {
void snapSelectedLayerToPerfectPixels(layer);
},
onSplitIconSpritesheet: (layer: CanvasLayer) => {
void flushProjectPersistence().then(() =>
splitSelectedIconSpritesheet(layer),
);
},
onExtractUiDesignAssets: extractUiDesignAssets,
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
onUiAssetExtractionModelChange: changeUiAssetExtractionModel,
onUiAssetExtractionPointerStart: startUiAssetExtractionPointer,
onUiAssetExtractionPointerMove: moveUiAssetExtractionPointer,
onUiAssetExtractionPointerEnd: endUiAssetExtractionPointer,
onRequestUiAssetExtractionReferenceUpload: () =>
requestUpload('ui-asset-extraction-reference'),
onRemoveUiAssetExtractionReference: removeUiAssetExtractionReference,
onSubmitUiAssetExtraction: () => void submitUiAssetExtraction(),
contextMenuLayer,
onQuickEditSelectionToolChange: changeQuickEditSelectionTool,
onQuickEditSelectionPointerStart: startQuickEditSelectionPointer,
onQuickEditSelectionPointerMove: moveQuickEditSelectionPointer,
onQuickEditSelectionPointerEnd: endQuickEditSelectionPointer,
onOpenCharacterAnimationPanel: openCharacterAnimationPanel,
onDownloadLayer: exportLayerImage,
onPasteCanvasClipboard: pasteCanvasOrSystemClipboard,
onCopyContextLayers: copyContextLayers,
onDuplicateContextLayers: duplicateContextLayers,
onMoveContextLayers: moveContextLayers,
onGroupContextLayers: groupContextLayers,
onUngroupContextLayers: ungroupContextLayers,
onToggleContextLayerVisibility: toggleContextLayerVisibility,
onToggleContextLayerLock: toggleContextLayerLock,
onFlipContextLayers: flipContextLayers,
onExportContextLayer: exportContextLayer,
onDeleteContextLayers: deleteContextLayers,
onDeleteLayerById: deleteLayerById,
onCloseContextMenu: () => setContextMenu(null),
onCloseImageContextMenu: () => setImageContextMenu(null),
onUpdateScaleFromCenter: updateScaleFromCenter,
onFitLayers: fitLayers,
onUndoCanvasChange: handleUndoCanvasChange,
onRedoCanvasChange: handleRedoCanvasChange,
onToggleZoomMenu: toggleZoomMenu,
onCloseZoomMenu: closeZoomMenu,
onToggleBackgroundSettings: toggleBackgroundSettings,
onApplyCanvasBackgroundColor: applyCanvasBackgroundColor,
onCanvasBackgroundHexChange: handleCanvasBackgroundHexChange,
onToggleSidebarPanel: toggleCanvasSidebarPanel,
onToggleMinimap: toggleMinimap,
onMinimapPointerDown: handleMinimapPointerDown,
onSwitchTool: switchTool,
onOpenToolOptions: generationSurface.openToolbarOptionMenu,
onCloseToolOptions: generationSurface.closeToolbarOptionMenu,
children: generationComposerNode,
};
return (
<ImageCanvasActionsProvider
focusResource={focusCanvasResource}
refreshCanvas={refreshCanvas}
>
<ImageCanvasEditorShellView
editorRootRef={editorRootRef}
uploadInputRef={uploadInputRef}
uploadAccept={getEditorUploadAccept(uploadTarget)}
onUploadInputChange={handleUploadInputChange}
assetDragPreview={assetDragPreview}
sidebarProps={sidebarProps}
topbarProps={topbarProps}
stageProps={stageProps}
metadataProps={{
layer: metadataLayer,
onClose: () => setMetadataLayer(null),
}}
/>
<PlatformDangerConfirmDialog
open={Boolean(pendingGenerationDeleteDialog)}
title="删除生成中的占位图"
confirmLabel="确认删除"
onClose={() => setPendingGenerationDeleteDialog(null)}
onConfirm={confirmRemoveGeneratingDialog}
>
删除后会停止在画布中显示这个生成任务,已消耗的泥点不会返还。
</PlatformDangerConfirmDialog>
<ImageCanvasShortcutDialogView
open={isShortcutDialogOpen}
onClose={() => setIsShortcutDialogOpen(false)}
/>
{isRewardCodeOpen ? (
<PlatformProfileRewardCodeRedeemModal
value={rewardCodeInput}
isSubmitting={isSubmittingRewardCode}
error={rewardCodeError}
success={rewardCodeSuccess}
onChange={setRewardCodeInput}
onSubmit={submitRewardCode}
onClose={() => setIsRewardCodeOpen(false)}
/>
) : null}
{isRechargeOpen ? (
<PlatformProfileRechargeModal
center={rechargeCenter}
isLoading={isLoadingRechargeCenter}
error={rechargeError}
submittingProductId={submittingRechargeProductId}
nativePayment={nativeWechatPayment}
onClose={() => setIsRechargeOpen(false)}
onRetry={loadRechargeCenter}
onBuy={buyRechargeProduct}
onConfirmNativePayment={confirmNativeWechatPayment}
onCloseNativePayment={closeNativeWechatPayment}
/>
) : null}
{isWalletLedgerOpen ? (
<PlatformProfileWalletLedgerModal
ledger={walletLedger}
fallbackBalance={walletBalance ?? 0}
isLoading={isLoadingWalletLedger}
error={walletLedgerError}
onClose={() => setIsWalletLedgerOpen(false)}
onRetry={loadWalletLedger}
/>
) : null}
{rechargePaymentResult ? (
<PlatformRechargePaymentResultDialog
result={rechargePaymentResult}
onClose={() => setRechargePaymentResult(null)}
/>
) : null}
{wechatRechargeOrderConfirmationState ? (
<PlatformRechargePaymentConfirmationMask
orderId={wechatRechargeOrderConfirmationState.orderId}
/>
) : null}
</ImageCanvasActionsProvider>
);
}
export default ImageCanvasEditorView;