e4e3cab559
主站规范生成支持多张参考图 修复普通生成参考图删除与提交状态不同步 优化触屏设备删除按钮可达性并补充回归测试 --------- 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>
2781 lines
91 KiB
TypeScript
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;
|