新增画布舞台交互 hook,承接选择、框选、拖拽、平移和小地图 pointer 状态机 更新历史恢复清理入口,撤销重做时统一重置舞台交互状态 补充舞台交互 hook 测试并更新前端拆分文档和 TRACKING 记录
1453 lines
47 KiB
TypeScript
1453 lines
47 KiB
TypeScript
import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
|
|
import {
|
|
type CSSProperties,
|
|
type DragEvent as ReactDragEvent,
|
|
type MouseEvent as ReactMouseEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
|
import { PlatformTextField } from '../common/PlatformTextField';
|
|
import { UnifiedModal } from '../common/UnifiedModal';
|
|
import { useAuthUi } from '../auth/AuthUiContext';
|
|
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
|
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
|
import {
|
|
getCanvasLayersByIds,
|
|
resolveContextTargetLayerIds,
|
|
} from './ImageCanvasLayerCommandModel';
|
|
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
|
|
import { ImageCanvasStageView } from './ImageCanvasStageView';
|
|
import {
|
|
ASSET_DRAG_MIME_TYPE,
|
|
TOOLBAR_HALF_WIDTH,
|
|
clamp,
|
|
createLayerFromAsset,
|
|
getDraggedAssetId,
|
|
hasDataTransferType,
|
|
isGeneratedLayer,
|
|
isLayerLinkedToAsset,
|
|
resolveContextMenuPosition,
|
|
serializeLayer,
|
|
} from './ImageCanvasEditorModel';
|
|
import {
|
|
ICON_COMPOSER_HORIZONTAL_CHROME_REM,
|
|
ICON_COMPOSER_MIN_WIDTH_REM,
|
|
ICON_DESCRIPTION_CARD_WIDTH_REM,
|
|
formatLayerImageType,
|
|
getGenerationFrameAriaLabel,
|
|
getGenerationFrameLabel,
|
|
getLayerKindLabel,
|
|
} from './ImageCanvasGenerationModel';
|
|
import type {
|
|
AssetPointerDragState,
|
|
CanvasContextMenuState,
|
|
CanvasLayer,
|
|
CanvasTool,
|
|
CanvasViewport,
|
|
EditorAsset,
|
|
ImageContextMenuState,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { useCanvasHistory } from './useCanvasHistory';
|
|
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
|
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
|
|
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
|
|
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
|
|
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
|
|
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
|
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
|
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
|
|
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
|
import {
|
|
DEFAULT_IMAGE_CANVAS_VIEWPORT,
|
|
useImageCanvasViewportControls,
|
|
} from './useImageCanvasViewportControls';
|
|
|
|
function isEditableTarget(event: KeyboardEvent) {
|
|
const target = event.target as HTMLElement | null;
|
|
if (!target) {
|
|
return false;
|
|
}
|
|
return (
|
|
target.tagName === 'INPUT' ||
|
|
target.tagName === 'TEXTAREA' ||
|
|
target.isContentEditable
|
|
);
|
|
}
|
|
|
|
export function ImageCanvasEditorView() {
|
|
const authUi = useAuthUi();
|
|
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 layerCounterRef = useRef(0);
|
|
const layersRef = useRef<CanvasLayer[]>([]);
|
|
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
|
const captureCanvasHistoryRef = useRef<() => void>(() => {});
|
|
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
|
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
|
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
const selectedLayerIdRef = useRef<string | null>(null);
|
|
const selectedLayerIdsRef = useRef<string[]>([]);
|
|
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => 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 [imageContextMenu, setImageContextMenu] =
|
|
useState<ImageContextMenuState | null>(null);
|
|
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
|
|
null,
|
|
);
|
|
const [uploadDropTarget, setUploadDropTarget] = useState<
|
|
'canvas' | 'assets' | null
|
|
>(null);
|
|
const captureViewportHistory = useCallback(() => {
|
|
captureCanvasHistoryRef.current();
|
|
}, []);
|
|
const {
|
|
viewport,
|
|
setViewport,
|
|
canvasSize,
|
|
minimapModel,
|
|
updateScaleFromCenter,
|
|
fitLayers,
|
|
resolveCanvasPoint,
|
|
getCanvasDropPoint,
|
|
getCanvasPointFromClient,
|
|
moveViewportFromMinimapPointer,
|
|
updateViewportFromMinimapDrag,
|
|
} = useImageCanvasViewportControls({
|
|
canvasViewportRef,
|
|
layers,
|
|
captureCanvasHistory: captureViewportHistory,
|
|
});
|
|
|
|
selectedLayerIdRef.current = selectedLayerId;
|
|
selectedLayerIdsRef.current = selectedLayerIds;
|
|
layersRef.current = layers;
|
|
viewportRef.current = viewport;
|
|
const assetsRef = useRef<EditorAsset[]>([]);
|
|
const addAssetLayerRef = useRef<
|
|
(asset: EditorAsset, screenCenter?: { x: number; y: number }) => void
|
|
>(() => {});
|
|
const moveAssetToFolderRef = useRef<
|
|
(assetId: string, folderId: string) => void
|
|
>(() => {});
|
|
authUiRef.current = authUi;
|
|
const openEditorLoginModal = useCallback(
|
|
(postLoginAction?: (() => void) | null) => {
|
|
authUiRef.current?.openLoginModal(postLoginAction);
|
|
},
|
|
[],
|
|
);
|
|
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 });
|
|
const removeCanvasLayersLinkedToAssets = useCallback(
|
|
(deletedAssets: EditorAsset[]) => {
|
|
if (!deletedAssets.length) {
|
|
return;
|
|
}
|
|
setLayers((currentLayers) =>
|
|
currentLayers.filter(
|
|
(layer) =>
|
|
!deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)),
|
|
),
|
|
);
|
|
setSelectedLayerIds((currentIds) =>
|
|
currentIds.filter((layerId) =>
|
|
layers.every(
|
|
(layer) =>
|
|
layer.id !== layerId ||
|
|
!deletedAssets.some((asset) =>
|
|
isLayerLinkedToAsset(layer, asset),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
setSelectedLayerId((currentId) => {
|
|
if (!currentId) {
|
|
return currentId;
|
|
}
|
|
const currentLayer = layers.find((layer) => layer.id === currentId);
|
|
return currentLayer &&
|
|
deletedAssets.some((asset) => isLayerLinkedToAsset(currentLayer, asset))
|
|
? null
|
|
: currentId;
|
|
});
|
|
},
|
|
[layers],
|
|
);
|
|
const {
|
|
assetFolders,
|
|
setAssetFolders,
|
|
assets,
|
|
setAssets,
|
|
groupedAssets,
|
|
allSelectableAssetsSelected,
|
|
renamingAsset,
|
|
setRenamingAsset,
|
|
renamingFolder,
|
|
setRenamingFolder,
|
|
creatingFolder,
|
|
setCreatingFolder,
|
|
newFolderName,
|
|
setNewFolderName,
|
|
activeUploadFolderId,
|
|
setActiveUploadFolderId,
|
|
isAssetSelectionMode,
|
|
setIsAssetSelectionMode,
|
|
selectedAssetIds,
|
|
setSelectedAssetIds,
|
|
assetMarquee,
|
|
assetPointerDrag,
|
|
setAssetPointerDrag,
|
|
assetMoveDropFolderId,
|
|
pinnedAssetMoveFolderId,
|
|
resolveAssetFolderId,
|
|
updateAssetMoveDropFolder,
|
|
startRenamingAsset,
|
|
commitAssetRename,
|
|
toggleAssetFolder,
|
|
commitNewAssetFolder,
|
|
deleteUploadedAsset,
|
|
startRenamingFolder,
|
|
commitFolderRename,
|
|
deleteAssetFolder,
|
|
toggleAssetSelected,
|
|
toggleAllAssetsSelected,
|
|
deleteSelectedAssets,
|
|
moveAssetToFolder,
|
|
closeAssetSelectionMode,
|
|
handleAssetMarqueePointerDown,
|
|
handleAssetMarqueePointerMove,
|
|
handleAssetMarqueePointerUp,
|
|
} = useImageCanvasAssetLibrary({
|
|
assetListRef,
|
|
openEditorLoginModal,
|
|
onDeleteAssets: removeCanvasLayersLinkedToAssets,
|
|
});
|
|
|
|
useEffect(() => {
|
|
assetsRef.current = assets;
|
|
}, [assets]);
|
|
|
|
const handleActivateCanvasGenerationDialog = useCallback(() => {
|
|
setSelectedLayerId(null);
|
|
setSelectedLayerIds([]);
|
|
setImageContextMenu(null);
|
|
}, []);
|
|
const {
|
|
generateDialog,
|
|
setGenerateDialog,
|
|
generateDialogRef,
|
|
inactiveGenerateDialogs,
|
|
setInactiveGenerateDialogs,
|
|
inactiveGenerateDialogsRef,
|
|
activeCanvasGenerationDialog,
|
|
canvasGenerationDialogs,
|
|
openCanvasGenerationDialog,
|
|
updateCanvasGenerationDialogById,
|
|
removeCanvasGenerationDialogById,
|
|
activateCanvasGenerationDialog,
|
|
removeCanvasGenerationDialogsByLayerId,
|
|
getGeneratingDialogPlaceholder,
|
|
} = useCanvasGenerationDialogs({
|
|
onActivate: handleActivateCanvasGenerationDialog,
|
|
});
|
|
const selectedLayer = useMemo(
|
|
() => layers.find((layer) => layer.id === selectedLayerId) ?? null,
|
|
[layers, selectedLayerId],
|
|
);
|
|
const selectedLayerCount = selectedLayerIds.length;
|
|
const hasMultipleSelectedLayers = selectedLayerCount > 1;
|
|
const activeGenerationLayer = useMemo(
|
|
() =>
|
|
activeCanvasGenerationDialog?.generatedLayerId
|
|
? (layers.find(
|
|
(layer) =>
|
|
layer.id === activeCanvasGenerationDialog.generatedLayerId,
|
|
) ?? null)
|
|
: null,
|
|
[activeCanvasGenerationDialog, layers],
|
|
);
|
|
const generationAnchor = activeCanvasGenerationDialog
|
|
? (activeGenerationLayer ??
|
|
activeCanvasGenerationDialog.placeholder ??
|
|
null)
|
|
: null;
|
|
const generationComposerStyle =
|
|
activeCanvasGenerationDialog?.status !== 'generating' &&
|
|
activeCanvasGenerationDialog?.composerOpen !== false &&
|
|
generationAnchor
|
|
? {
|
|
left:
|
|
viewport.x +
|
|
(generationAnchor.x + generationAnchor.width / 2) * viewport.scale,
|
|
top:
|
|
viewport.y +
|
|
(generationAnchor.y + generationAnchor.height) * viewport.scale +
|
|
10,
|
|
}
|
|
: null;
|
|
const selectedToolbarStyle = selectedLayer
|
|
? {
|
|
left: clamp(
|
|
viewport.x +
|
|
selectedLayer.x * viewport.scale +
|
|
(selectedLayer.width * viewport.scale) / 2,
|
|
TOOLBAR_HALF_WIDTH,
|
|
Math.max(TOOLBAR_HALF_WIDTH, canvasSize.width - TOOLBAR_HALF_WIDTH),
|
|
),
|
|
top: Math.max(10, viewport.y + selectedLayer.y * viewport.scale - 12),
|
|
}
|
|
: null;
|
|
const imageContextMenuLayer = imageContextMenu
|
|
? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null)
|
|
: null;
|
|
const getContextTargetLayerIds = useCallback(
|
|
(menu: CanvasContextMenuState | null = contextMenu) =>
|
|
resolveContextTargetLayerIds(menu, selectedLayerIdsRef.current),
|
|
[contextMenu],
|
|
);
|
|
const contextTargetIds = getContextTargetLayerIds(contextMenu);
|
|
const contextTargetLayers = getCanvasLayersByIds(layers, contextTargetIds);
|
|
const contextShouldShowLayer = contextTargetLayers.some(
|
|
(layer) => layer.hidden,
|
|
);
|
|
const contextShouldUnlockLayer = contextTargetLayers.some(
|
|
(layer) => layer.locked,
|
|
);
|
|
const canvasHistoryRefs = useMemo(
|
|
() => ({
|
|
layersRef,
|
|
viewportRef,
|
|
generateDialogRef,
|
|
inactiveGenerateDialogsRef,
|
|
selectedLayerIdRef,
|
|
selectedLayerIdsRef,
|
|
}),
|
|
[],
|
|
);
|
|
const canvasHistorySetters = useMemo(
|
|
() => ({
|
|
setLayers,
|
|
setViewport,
|
|
setGenerateDialog,
|
|
setInactiveGenerateDialogs,
|
|
setSelectedLayerId,
|
|
setSelectedLayerIds,
|
|
}),
|
|
[],
|
|
);
|
|
const canvasHistoryResetters = useMemo(
|
|
() => ({
|
|
setHoveredLayerId,
|
|
setMetadataLayer,
|
|
resetCanvasInteractionState: () =>
|
|
resetCanvasInteractionStateRef.current(),
|
|
}),
|
|
[],
|
|
);
|
|
const {
|
|
canUndo,
|
|
canRedo,
|
|
captureCanvasHistory,
|
|
undoCanvasChange,
|
|
redoCanvasChange,
|
|
} = useCanvasHistory({
|
|
refs: canvasHistoryRefs,
|
|
setters: canvasHistorySetters,
|
|
resetters: canvasHistoryResetters,
|
|
});
|
|
captureCanvasHistoryRef.current = captureCanvasHistory;
|
|
const selectSingleLayer = useCallback((layerId: string | null) => {
|
|
setSelectedLayerId(layerId);
|
|
setSelectedLayerIds(layerId ? [layerId] : []);
|
|
if (layerId) {
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'generate' ||
|
|
currentDialog?.mode === 'spec' ||
|
|
currentDialog?.mode === 'character' ||
|
|
currentDialog?.mode === 'icon'
|
|
? {
|
|
...currentDialog,
|
|
composerOpen: false,
|
|
}
|
|
: currentDialog,
|
|
);
|
|
}
|
|
}, []);
|
|
const projectPersistenceRefs = useMemo(
|
|
() => ({
|
|
layersRef,
|
|
viewportRef,
|
|
}),
|
|
[],
|
|
);
|
|
const projectPersistenceSetters = useMemo(
|
|
() => ({
|
|
setProjectTitle,
|
|
setProjectRenameValue,
|
|
setViewport,
|
|
setLayers,
|
|
selectSingleLayer,
|
|
setLayerCounter: (value: number) => {
|
|
layerCounterRef.current = value;
|
|
},
|
|
}),
|
|
[selectSingleLayer],
|
|
);
|
|
const { projectId, appendCanvasLayersWithResources } =
|
|
useImageCanvasProjectPersistence({
|
|
refs: projectPersistenceRefs,
|
|
setters: projectPersistenceSetters,
|
|
layers,
|
|
viewport,
|
|
openEditorLoginModal,
|
|
});
|
|
const {
|
|
assetExportStatus,
|
|
isExportingAssets,
|
|
exportCanvasAssets,
|
|
exportLayerImage,
|
|
} = useImageCanvasAssetExportWorkflow({
|
|
layers,
|
|
projectId,
|
|
projectTitle,
|
|
});
|
|
const generationWorkflow = useImageCanvasGenerationWorkflow({
|
|
layers,
|
|
canvasSize,
|
|
viewport,
|
|
layerCounterRef,
|
|
generateDialog,
|
|
setGenerateDialog,
|
|
openCanvasGenerationDialog,
|
|
updateCanvasGenerationDialogById,
|
|
removeCanvasGenerationDialogById,
|
|
removeCanvasGenerationDialogsByLayerId,
|
|
getGeneratingDialogPlaceholder,
|
|
appendCanvasLayersWithResources,
|
|
selectSingleLayer,
|
|
fitLayers,
|
|
setActiveTool,
|
|
setActiveSidebarPanel,
|
|
setMetadataLayer,
|
|
setImageContextMenu,
|
|
});
|
|
const {
|
|
quickEditPanel,
|
|
setQuickEditPanel,
|
|
quickEditSourceLayer,
|
|
quickEditSizeOptions,
|
|
quickEditModelOptions,
|
|
characterAnimationPanel,
|
|
setCharacterAnimationPanel,
|
|
characterAnimationSourceLayer,
|
|
characterAnimationPrice,
|
|
iconDescriptionValues,
|
|
isSpecMenuOpen,
|
|
setIsSpecMenuOpen,
|
|
isCharacterSpecMenuOpen,
|
|
setIsCharacterSpecMenuOpen,
|
|
isPickingCharacterSpecFromCanvas,
|
|
setIsPickingCharacterSpecFromCanvas,
|
|
isIconSpecMenuOpen,
|
|
setIsIconSpecMenuOpen,
|
|
isPickingIconSpecFromCanvas,
|
|
setIsPickingIconSpecFromCanvas,
|
|
openGenerateDialog,
|
|
openSpecDialog,
|
|
openCharacterAnimationPanel,
|
|
openCharacterGenerationDialog,
|
|
openIconGenerationDialog,
|
|
openEditDialog,
|
|
openQuickEditPanel,
|
|
pickCharacterSpecFromLayer,
|
|
pickIconSpecFromLayer,
|
|
submitIconSpritesheetGeneration,
|
|
submitQuickEdit,
|
|
submitImageGeneration,
|
|
updateSpecFormValue,
|
|
updateIconDescription,
|
|
addIconDescription,
|
|
updateCharacterAnimationDuration,
|
|
submitCharacterAnimation,
|
|
hideGeneratedLayerPanelAfterBlur,
|
|
closeGenerateComposer,
|
|
clearDeletedLayerGenerationState,
|
|
} = generationWorkflow;
|
|
const iconComposerStyle: CSSProperties | null =
|
|
activeCanvasGenerationDialog?.mode === 'icon' && generationComposerStyle
|
|
? {
|
|
...generationComposerStyle,
|
|
width: `${Math.max(
|
|
ICON_COMPOSER_MIN_WIDTH_REM,
|
|
ICON_COMPOSER_HORIZONTAL_CHROME_REM +
|
|
iconDescriptionValues.length * ICON_DESCRIPTION_CARD_WIDTH_REM,
|
|
).toFixed(1)}rem`,
|
|
}
|
|
: null;
|
|
const quickEditPanelStyle =
|
|
quickEditPanel && quickEditSourceLayer
|
|
? {
|
|
left: clamp(
|
|
viewport.x +
|
|
(quickEditSourceLayer.x + quickEditSourceLayer.width / 2) *
|
|
viewport.scale,
|
|
12,
|
|
Math.max(12, canvasSize.width - 12),
|
|
),
|
|
top: clamp(
|
|
viewport.y +
|
|
(quickEditSourceLayer.y + quickEditSourceLayer.height) *
|
|
viewport.scale +
|
|
12,
|
|
12,
|
|
Math.max(12, canvasSize.height - 360),
|
|
),
|
|
}
|
|
: null;
|
|
const characterAnimationPanelStyle =
|
|
characterAnimationPanel && characterAnimationSourceLayer
|
|
? {
|
|
left: clamp(
|
|
viewport.x +
|
|
(characterAnimationSourceLayer.x +
|
|
characterAnimationSourceLayer.width) *
|
|
viewport.scale +
|
|
12,
|
|
12,
|
|
Math.max(12, canvasSize.width - 364),
|
|
),
|
|
top: clamp(
|
|
viewport.y + characterAnimationSourceLayer.y * viewport.scale,
|
|
12,
|
|
Math.max(12, canvasSize.height - 520),
|
|
),
|
|
}
|
|
: null;
|
|
const {
|
|
canvasClipboard,
|
|
pasteCanvasClipboard,
|
|
copyContextLayers,
|
|
duplicateContextLayers,
|
|
moveContextLayers,
|
|
groupContextLayers,
|
|
ungroupContextLayers,
|
|
toggleContextLayerVisibility,
|
|
toggleContextLayerLock,
|
|
flipContextLayers,
|
|
deleteContextLayers,
|
|
exportContextLayer,
|
|
deleteLayerById,
|
|
deleteSelectedLayer,
|
|
groupSelectedLayers,
|
|
} = useImageCanvasLayerCommands({
|
|
layers,
|
|
contextMenu,
|
|
selectedLayerId,
|
|
selectedLayerIds,
|
|
setLayers,
|
|
setSelectedLayerId,
|
|
setSelectedLayerIds,
|
|
setHoveredLayerId,
|
|
setMetadataLayer,
|
|
setContextMenu,
|
|
setImageContextMenu,
|
|
setActiveTool,
|
|
captureCanvasHistory,
|
|
selectSingleLayer,
|
|
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
|
|
exportLayerImage,
|
|
});
|
|
const {
|
|
uploadInputRef,
|
|
setUploadTarget,
|
|
requestUpload,
|
|
handleUploadInputChange,
|
|
addUploadedFiles,
|
|
} = useImageCanvasUploadWorkflow({
|
|
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
|
openEditorLoginModal,
|
|
assetFolders,
|
|
activeUploadFolderId,
|
|
canvasSize,
|
|
viewport,
|
|
activeTool,
|
|
allocateUploadIndex: () => {
|
|
layerCounterRef.current += 1;
|
|
return layerCounterRef.current;
|
|
},
|
|
setAssetFolders,
|
|
setAssets,
|
|
setLayers,
|
|
setGenerateDialog,
|
|
setActiveSidebarPanel,
|
|
appendCanvasLayersWithResources,
|
|
selectSingleLayer,
|
|
});
|
|
|
|
const clearCanvasFocus = useCallback(() => {
|
|
selectSingleLayer(null);
|
|
hideGeneratedLayerPanelAfterBlur();
|
|
setImageContextMenu(null);
|
|
setContextMenu(null);
|
|
}, [hideGeneratedLayerPanelAfterBlur, selectSingleLayer]);
|
|
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,
|
|
setGenerateDialog,
|
|
isPickingCharacterSpecFromCanvas,
|
|
isPickingIconSpecFromCanvas,
|
|
clearCanvasFocus,
|
|
pickCharacterSpecFromLayer,
|
|
pickIconSpecFromLayer,
|
|
activateCanvasGenerationDialog,
|
|
updateCanvasGenerationDialogById,
|
|
moveViewportFromMinimapPointer,
|
|
updateViewportFromMinimapDrag,
|
|
minimapScale: minimapModel?.scale ?? 1,
|
|
onCloseImageContextMenu: () => setImageContextMenu(null),
|
|
});
|
|
resetCanvasInteractionStateRef.current = clearActiveInteraction;
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (
|
|
(event.ctrlKey || event.metaKey) &&
|
|
event.code === 'KeyZ' &&
|
|
!isEditableTarget(event)
|
|
) {
|
|
event.preventDefault();
|
|
if (event.shiftKey) {
|
|
redoCanvasChange();
|
|
} else {
|
|
undoCanvasChange();
|
|
}
|
|
return;
|
|
}
|
|
if (event.key === 'Shift') {
|
|
setShiftPressed(true);
|
|
}
|
|
if (
|
|
(event.key === 'Backspace' || event.key === 'Delete') &&
|
|
!event.repeat &&
|
|
!isEditableTarget(event)
|
|
) {
|
|
const currentDialog = generateDialogRef.current;
|
|
const currentSelectedLayerId = selectedLayerIdRef.current;
|
|
if (currentSelectedLayerId) {
|
|
event.preventDefault();
|
|
deleteLayerByIdRef.current(currentSelectedLayerId);
|
|
return;
|
|
}
|
|
if (
|
|
currentDialog?.placeholder &&
|
|
currentDialog.status !== 'generating' &&
|
|
(currentDialog.mode === 'generate' ||
|
|
currentDialog.mode === 'spec' ||
|
|
currentDialog.mode === 'character' ||
|
|
currentDialog.mode === 'icon')
|
|
) {
|
|
event.preventDefault();
|
|
setGenerateDialog(null);
|
|
setActiveTool('select');
|
|
setIsCharacterSpecMenuOpen(false);
|
|
setIsPickingCharacterSpecFromCanvas(false);
|
|
setIsIconSpecMenuOpen(false);
|
|
setIsPickingIconSpecFromCanvas(false);
|
|
return;
|
|
}
|
|
}
|
|
if (event.key === 'Escape') {
|
|
closeEditorChromePanels();
|
|
setIsSpecMenuOpen(false);
|
|
setImageContextMenu(null);
|
|
setContextMenu(null);
|
|
setQuickEditPanel((currentPanel) =>
|
|
currentPanel?.status === 'generating' ? currentPanel : null,
|
|
);
|
|
setIsCharacterSpecMenuOpen(false);
|
|
setIsPickingCharacterSpecFromCanvas(false);
|
|
setIsIconSpecMenuOpen(false);
|
|
setIsPickingIconSpecFromCanvas(false);
|
|
setGenerateDialog((currentDialog) => {
|
|
if (!currentDialog || currentDialog.status === 'generating') {
|
|
return currentDialog;
|
|
}
|
|
if (
|
|
currentDialog.mode === 'generate' ||
|
|
currentDialog.mode === 'spec'
|
|
) {
|
|
return {
|
|
...currentDialog,
|
|
composerOpen: false,
|
|
};
|
|
}
|
|
if (currentDialog.mode === 'character') {
|
|
return currentDialog;
|
|
}
|
|
if (currentDialog.mode === 'icon') {
|
|
return currentDialog;
|
|
}
|
|
return null;
|
|
});
|
|
return;
|
|
}
|
|
if (event.code !== 'Space' || event.repeat || isEditableTarget(event)) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
setIsSpacePanning(true);
|
|
};
|
|
const handleKeyUp = (event: KeyboardEvent) => {
|
|
if (event.key === 'Shift') {
|
|
setShiftPressed(false);
|
|
}
|
|
if (event.code !== 'Space') {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
setIsSpacePanning(false);
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
window.addEventListener('keyup', handleKeyUp);
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
window.removeEventListener('keyup', handleKeyUp);
|
|
};
|
|
}, [
|
|
closeEditorChromePanels,
|
|
redoCanvasChange,
|
|
setIsSpacePanning,
|
|
setShiftPressed,
|
|
undoCanvasChange,
|
|
]);
|
|
|
|
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 });
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const updatePointerDrag = (event: PointerEvent) => {
|
|
const currentDrag = assetPointerDragRef.current;
|
|
if (!currentDrag || currentDrag.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
const distance = Math.hypot(
|
|
event.clientX - currentDrag.startClientX,
|
|
event.clientY - currentDrag.startClientY,
|
|
);
|
|
const dropFolderId = resolveAssetFolderId(event.clientX, event.clientY);
|
|
const nextDrag: AssetPointerDragState = {
|
|
...currentDrag,
|
|
currentClientX: event.clientX,
|
|
currentClientY: event.clientY,
|
|
active: currentDrag.active || distance > 4,
|
|
dropFolderId,
|
|
};
|
|
assetPointerDragRef.current = nextDrag;
|
|
setAssetPointerDrag(nextDrag);
|
|
setUploadDropTarget(
|
|
resolveCanvasPoint(event.clientX, event.clientY) ? 'canvas' : null,
|
|
);
|
|
updateAssetMoveDropFolder(dropFolderId);
|
|
};
|
|
|
|
const finishPointerDrag = (event: PointerEvent) => {
|
|
const currentDrag = assetPointerDragRef.current;
|
|
if (!currentDrag || currentDrag.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
const canvasPoint = resolveCanvasPoint(event.clientX, event.clientY);
|
|
const dropFolderId =
|
|
resolveAssetFolderId(event.clientX, event.clientY) ??
|
|
currentDrag.dropFolderId;
|
|
const draggedAsset = assetsRef.current.find(
|
|
(asset) => asset.id === currentDrag.assetId,
|
|
);
|
|
assetPointerDragRef.current = null;
|
|
setAssetPointerDrag(null);
|
|
setUploadDropTarget(null);
|
|
updateAssetMoveDropFolder(null);
|
|
if (!currentDrag.active || !draggedAsset) {
|
|
return;
|
|
}
|
|
suppressAssetClickRef.current = true;
|
|
window.setTimeout(() => {
|
|
suppressAssetClickRef.current = false;
|
|
}, 0);
|
|
if (dropFolderId && dropFolderId !== draggedAsset.folderId) {
|
|
moveAssetToFolderRef.current(draggedAsset.id, dropFolderId);
|
|
return;
|
|
}
|
|
if (canvasPoint) {
|
|
addAssetLayerRef.current(draggedAsset, canvasPoint);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('pointermove', updatePointerDrag);
|
|
window.addEventListener('pointerup', finishPointerDrag);
|
|
window.addEventListener('pointercancel', finishPointerDrag);
|
|
return () => {
|
|
window.removeEventListener('pointermove', updatePointerDrag);
|
|
window.removeEventListener('pointerup', finishPointerDrag);
|
|
window.removeEventListener('pointercancel', finishPointerDrag);
|
|
};
|
|
}, []);
|
|
|
|
const addAssetLayer = (
|
|
asset: EditorAsset,
|
|
position?: { x: number; y: number },
|
|
) => {
|
|
setActiveUploadFolderId(asset.folderId);
|
|
layerCounterRef.current += 1;
|
|
const nextLayer = createLayerFromAsset(
|
|
asset,
|
|
layerCounterRef.current,
|
|
viewport,
|
|
{
|
|
x: position?.x ?? canvasSize.width / 2,
|
|
y: position?.y ?? canvasSize.height / 2,
|
|
},
|
|
);
|
|
captureCanvasHistory();
|
|
appendCanvasLayersWithResources([nextLayer]);
|
|
selectSingleLayer(nextLayer.id);
|
|
setHoveredLayerId(null);
|
|
};
|
|
addAssetLayerRef.current = addAssetLayer;
|
|
|
|
moveAssetToFolderRef.current = moveAssetToFolder;
|
|
|
|
deleteLayerByIdRef.current = deleteLayerById;
|
|
|
|
const handleCanvasDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
|
if (hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)) {
|
|
event.preventDefault();
|
|
setUploadDropTarget('canvas');
|
|
event.dataTransfer.dropEffect = 'copy';
|
|
return;
|
|
}
|
|
if (hasDataTransferType(event.dataTransfer, 'Files')) {
|
|
event.preventDefault();
|
|
setUploadDropTarget('canvas');
|
|
event.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
};
|
|
|
|
const handleCanvasDragLeave = (event: ReactDragEvent<HTMLDivElement>) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
setUploadDropTarget((currentTarget) =>
|
|
currentTarget === 'canvas' ? null : currentTarget,
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleCanvasDrop = (event: ReactDragEvent<HTMLDivElement>) => {
|
|
const draggedAssetId = getDraggedAssetId(event.dataTransfer);
|
|
if (draggedAssetId) {
|
|
const draggedAsset = assets.find((asset) => asset.id === draggedAssetId);
|
|
if (!draggedAsset) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
setUploadDropTarget(null);
|
|
updateAssetMoveDropFolder(null);
|
|
addAssetLayer(
|
|
draggedAsset,
|
|
getCanvasDropPoint(event.clientX, event.clientY),
|
|
);
|
|
return;
|
|
}
|
|
const files = event.dataTransfer.files;
|
|
if (!files.length) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
setUploadDropTarget(null);
|
|
updateAssetMoveDropFolder(null);
|
|
const canvasPoint = getCanvasDropPoint(event.clientX, event.clientY);
|
|
const defaultFolder =
|
|
assetFolders.find((folder) => folder.systemDefault) ?? assetFolders[0];
|
|
addUploadedFiles(files, {
|
|
folderId: defaultFolder?.id,
|
|
canvasPoint,
|
|
addToCanvas: true,
|
|
});
|
|
};
|
|
|
|
const handleCanvasContextMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const position = resolveContextMenuPosition(
|
|
event.clientX,
|
|
event.clientY,
|
|
'blank',
|
|
);
|
|
setImageContextMenu(null);
|
|
setContextMenu({
|
|
kind: 'blank',
|
|
...position,
|
|
canvasPoint: getCanvasPointFromClient(event.clientX, event.clientY),
|
|
});
|
|
};
|
|
|
|
const handleLayerContextMenu = (
|
|
event: ReactMouseEvent<HTMLButtonElement>,
|
|
layer: CanvasLayer,
|
|
) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (!selectedLayerIds.includes(layer.id)) {
|
|
selectSingleLayer(layer.id);
|
|
}
|
|
const position = resolveContextMenuPosition(
|
|
event.clientX,
|
|
event.clientY,
|
|
'layer',
|
|
);
|
|
setContextMenu({
|
|
kind: 'layer',
|
|
layerId: layer.id,
|
|
...position,
|
|
canvasPoint: getCanvasPointFromClient(event.clientX, event.clientY),
|
|
});
|
|
setImageContextMenu({
|
|
layerId: layer.id,
|
|
...position,
|
|
});
|
|
};
|
|
|
|
const switchTool = (tool: CanvasTool) => {
|
|
clearActiveInteraction();
|
|
if (tool === 'upload') {
|
|
requestUpload('asset');
|
|
return;
|
|
}
|
|
if (tool === 'generate') {
|
|
openGenerateDialog();
|
|
return;
|
|
}
|
|
if (tool === 'spec') {
|
|
setIsSpecMenuOpen((open) => !open);
|
|
setActiveTool('spec');
|
|
return;
|
|
}
|
|
if (tool === 'character') {
|
|
openCharacterGenerationDialog();
|
|
return;
|
|
}
|
|
if (tool === 'icon') {
|
|
openIconGenerationDialog();
|
|
return;
|
|
}
|
|
setActiveTool(tool);
|
|
};
|
|
|
|
return (
|
|
<section
|
|
ref={editorRootRef}
|
|
className="image-canvas-editor"
|
|
aria-label="图片画布编辑器"
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
>
|
|
<input
|
|
ref={uploadInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
aria-label="上传图片文件"
|
|
hidden
|
|
onChange={handleUploadInputChange}
|
|
/>
|
|
{assetPointerDrag?.active ? (
|
|
<div
|
|
className="image-canvas-editor__asset-drag-preview"
|
|
style={{
|
|
left: Number.isFinite(assetPointerDrag.currentClientX)
|
|
? assetPointerDrag.currentClientX
|
|
: 0,
|
|
top: Number.isFinite(assetPointerDrag.currentClientY)
|
|
? assetPointerDrag.currentClientY
|
|
: 0,
|
|
}}
|
|
aria-hidden="true"
|
|
>
|
|
{assets.find((asset) => asset.id === assetPointerDrag.assetId)
|
|
?.label ?? '素材'}
|
|
</div>
|
|
) : null}
|
|
<ImageCanvasSidebarView
|
|
activeSidebarPanel={activeSidebarPanel}
|
|
assetListRef={assetListRef}
|
|
assetPointerDragRef={assetPointerDragRef}
|
|
suppressAssetClickRef={suppressAssetClickRef}
|
|
assets={assets}
|
|
groupedAssets={groupedAssets}
|
|
assetFolders={assetFolders}
|
|
layers={layers}
|
|
selectedLayerId={selectedLayerId}
|
|
selectedLayerIds={selectedLayerIds}
|
|
isAssetSelectionMode={isAssetSelectionMode}
|
|
selectedAssetIds={selectedAssetIds}
|
|
assetMoveDropFolderId={assetMoveDropFolderId}
|
|
pinnedAssetMoveFolderId={pinnedAssetMoveFolderId}
|
|
creatingFolder={creatingFolder}
|
|
newFolderName={newFolderName}
|
|
renamingFolder={renamingFolder}
|
|
renamingAsset={renamingAsset}
|
|
allSelectableAssetsSelected={allSelectableAssetsSelected}
|
|
assetMarquee={assetMarquee}
|
|
setIsAssetSelectionMode={setIsAssetSelectionMode}
|
|
setCreatingFolder={setCreatingFolder}
|
|
setNewFolderName={setNewFolderName}
|
|
setRenamingFolder={setRenamingFolder}
|
|
setRenamingAsset={setRenamingAsset}
|
|
setActiveUploadFolderId={setActiveUploadFolderId}
|
|
setUploadDropTarget={setUploadDropTarget}
|
|
setAssetPointerDrag={setAssetPointerDrag}
|
|
setSelectedAssetIds={setSelectedAssetIds}
|
|
setImageContextMenu={setImageContextMenu}
|
|
setContextMenu={setContextMenu}
|
|
onAssetMarqueePointerDown={handleAssetMarqueePointerDown}
|
|
onAssetMarqueePointerMove={handleAssetMarqueePointerMove}
|
|
onAssetMarqueePointerUp={handleAssetMarqueePointerUp}
|
|
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
|
|
addUploadedFiles={addUploadedFiles}
|
|
requestUpload={requestUpload}
|
|
moveAssetToFolder={moveAssetToFolder}
|
|
commitNewAssetFolder={commitNewAssetFolder}
|
|
toggleAssetFolder={toggleAssetFolder}
|
|
startRenamingFolder={startRenamingFolder}
|
|
commitFolderRename={commitFolderRename}
|
|
deleteAssetFolder={deleteAssetFolder}
|
|
startRenamingAsset={startRenamingAsset}
|
|
commitAssetRename={commitAssetRename}
|
|
deleteUploadedAsset={deleteUploadedAsset}
|
|
toggleAssetSelected={toggleAssetSelected}
|
|
addAssetLayer={addAssetLayer}
|
|
toggleAllAssetsSelected={toggleAllAssetsSelected}
|
|
deleteSelectedAssets={deleteSelectedAssets}
|
|
closeAssetSelectionMode={closeAssetSelectionMode}
|
|
groupSelectedLayers={groupSelectedLayers}
|
|
selectSingleLayer={selectSingleLayer}
|
|
resolveContextMenuPosition={resolveContextMenuPosition}
|
|
getCanvasPointFromClient={getCanvasPointFromClient}
|
|
/>
|
|
|
|
<div className="image-canvas-editor__main">
|
|
<div className="image-canvas-editor__topbar">
|
|
<a
|
|
className="image-canvas-editor__project-back-button"
|
|
href="/project"
|
|
aria-label="返回项目页面"
|
|
title="返回项目"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
|
</a>
|
|
<div className="image-canvas-editor__title-block">
|
|
{isRenamingProject ? (
|
|
<form
|
|
className="image-canvas-editor__project-title-form"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
submitProjectRename(projectId);
|
|
}}
|
|
>
|
|
<PlatformTextField
|
|
aria-label="项目名称"
|
|
value={projectRenameValue}
|
|
autoFocus
|
|
disabled={isProjectRenameSaving}
|
|
className="image-canvas-editor__project-title-input"
|
|
onChange={(event) => {
|
|
setProjectRenameValue(event.target.value);
|
|
resetProjectRenameError();
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
cancelProjectRename();
|
|
}
|
|
}}
|
|
/>
|
|
<EditorIconButton
|
|
type="submit"
|
|
label="保存项目名称"
|
|
title="保存"
|
|
icon={Check}
|
|
disabled={isProjectRenameSaving}
|
|
/>
|
|
<EditorIconButton
|
|
label="取消修改项目名称"
|
|
title="取消"
|
|
icon={X}
|
|
disabled={isProjectRenameSaving}
|
|
onClick={cancelProjectRename}
|
|
/>
|
|
{projectRenameError ? (
|
|
<span
|
|
className="image-canvas-editor__project-title-error"
|
|
role="alert"
|
|
>
|
|
{projectRenameError}
|
|
</span>
|
|
) : null}
|
|
</form>
|
|
) : (
|
|
<div className="image-canvas-editor__project-title-row">
|
|
<button
|
|
type="button"
|
|
className="image-canvas-editor__project-title-button"
|
|
onDoubleClick={startProjectRename}
|
|
aria-label={`编辑项目名称${projectTitle}`}
|
|
>
|
|
<h1>{projectTitle}</h1>
|
|
</button>
|
|
<EditorIconButton
|
|
className="image-canvas-editor__project-rename-button"
|
|
label="编辑项目名称"
|
|
title="编辑项目名称"
|
|
icon={Pencil}
|
|
onClick={startProjectRename}
|
|
/>
|
|
</div>
|
|
)}
|
|
<span>画布</span>
|
|
</div>
|
|
<div className="image-canvas-editor__topbar-actions">
|
|
<EditorIconButton
|
|
label="下载画布素材"
|
|
title="下载画布素材"
|
|
icon={Download}
|
|
disabled={
|
|
isExportingAssets ||
|
|
!layers.some((layer) => layer.src.trim().length > 0)
|
|
}
|
|
onClick={() => void exportCanvasAssets()}
|
|
/>
|
|
{assetExportStatus ? (
|
|
<PlatformStatusMessage
|
|
tone={assetExportStatus.tone}
|
|
surface="platform"
|
|
size="xs"
|
|
role={assetExportStatus.tone === 'error' ? 'alert' : 'status'}
|
|
>
|
|
{assetExportStatus.message}
|
|
</PlatformStatusMessage>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<ImageCanvasStageView
|
|
canvasViewportRef={canvasViewportRef}
|
|
specToolWrapRef={specToolWrapRef}
|
|
isPanning={isPanning}
|
|
effectiveTool={effectiveTool}
|
|
canvasBackgroundColor={canvasBackgroundColor}
|
|
canvasBackgroundHexValue={canvasBackgroundHexValue}
|
|
viewport={viewport}
|
|
snapGuide={snapGuide}
|
|
layers={layers}
|
|
selectedLayer={selectedLayer}
|
|
selectedLayerIds={selectedLayerIds}
|
|
hoveredLayerId={hoveredLayerId}
|
|
canvasMarquee={canvasMarquee}
|
|
canvasGenerationDialogs={canvasGenerationDialogs}
|
|
generateDialog={generateDialog}
|
|
quickEditPanel={quickEditPanel}
|
|
generationComposerStyle={generationComposerStyle}
|
|
selectedToolbarStyle={selectedToolbarStyle}
|
|
uploadDropTarget={uploadDropTarget}
|
|
contextMenu={contextMenu}
|
|
canvasClipboard={canvasClipboard}
|
|
imageContextMenu={imageContextMenu}
|
|
imageContextMenuLayer={imageContextMenuLayer}
|
|
contextShouldShowLayer={contextShouldShowLayer}
|
|
contextShouldUnlockLayer={contextShouldUnlockLayer}
|
|
canUndo={canUndo}
|
|
canRedo={canRedo}
|
|
isZoomMenuOpen={isZoomMenuOpen}
|
|
isBackgroundSettingsOpen={isBackgroundSettingsOpen}
|
|
activeSidebarPanel={activeSidebarPanel}
|
|
isMinimapOpen={isMinimapOpen}
|
|
minimapModel={minimapModel}
|
|
onCanvasPointerDown={handleCanvasPointerDown}
|
|
onCanvasPointerMove={handlePointerMove}
|
|
onCanvasPointerUp={finishDrag}
|
|
onCanvasDragOver={handleCanvasDragOver}
|
|
onCanvasDragLeave={handleCanvasDragLeave}
|
|
onCanvasDrop={handleCanvasDrop}
|
|
onCanvasContextMenu={handleCanvasContextMenu}
|
|
onLayerPointerDown={handleLayerPointerDown}
|
|
onLayerClick={handleLayerClick}
|
|
onLayerContextMenu={handleLayerContextMenu}
|
|
onLayerMouseEnter={setHoveredLayerId}
|
|
onLayerMouseLeave={(layerId) =>
|
|
setHoveredLayerId((currentId) =>
|
|
currentId === layerId ? null : currentId,
|
|
)
|
|
}
|
|
onOpenLayerMetadata={(layer) => {
|
|
setMetadataLayer(layer);
|
|
selectSingleLayer(layer.id);
|
|
}}
|
|
onGenerationFramePointerDown={handleGenerationFramePointerDown}
|
|
onActivateGenerationDialog={activateCanvasGenerationDialog}
|
|
onDeleteSelectedLayer={deleteSelectedLayer}
|
|
onOpenQuickEditPanel={openQuickEditPanel}
|
|
onOpenEditDialog={openEditDialog}
|
|
onOpenCharacterAnimationPanel={openCharacterAnimationPanel}
|
|
onPasteCanvasClipboard={pasteCanvasClipboard}
|
|
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={undoCanvasChange}
|
|
onRedoCanvasChange={redoCanvasChange}
|
|
onToggleZoomMenu={toggleZoomMenu}
|
|
onCloseZoomMenu={closeZoomMenu}
|
|
onToggleBackgroundSettings={toggleBackgroundSettings}
|
|
onApplyCanvasBackgroundColor={applyCanvasBackgroundColor}
|
|
onCanvasBackgroundHexChange={handleCanvasBackgroundHexChange}
|
|
onToggleSidebarPanel={toggleSidebarPanel}
|
|
onToggleMinimap={toggleMinimap}
|
|
onMinimapPointerDown={handleMinimapPointerDown}
|
|
onSwitchTool={switchTool}
|
|
>
|
|
<ImageCanvasGenerationComposerView
|
|
specToolWrapRef={specToolWrapRef}
|
|
characterSpecButtonRef={characterSpecButtonRef}
|
|
iconSpecButtonRef={iconSpecButtonRef}
|
|
isSpecMenuOpen={isSpecMenuOpen}
|
|
isCharacterSpecMenuOpen={isCharacterSpecMenuOpen}
|
|
isIconSpecMenuOpen={isIconSpecMenuOpen}
|
|
isPickingCharacterSpecFromCanvas={isPickingCharacterSpecFromCanvas}
|
|
isPickingIconSpecFromCanvas={isPickingIconSpecFromCanvas}
|
|
generateDialog={generateDialog}
|
|
generationComposerStyle={generationComposerStyle}
|
|
iconComposerStyle={iconComposerStyle}
|
|
quickEditPanel={quickEditPanel}
|
|
quickEditSourceLayer={quickEditSourceLayer}
|
|
quickEditPanelStyle={quickEditPanelStyle}
|
|
quickEditSizeOptions={quickEditSizeOptions}
|
|
quickEditModelOptions={quickEditModelOptions}
|
|
characterAnimationPanel={characterAnimationPanel}
|
|
characterAnimationSourceLayer={characterAnimationSourceLayer}
|
|
characterAnimationPanelStyle={characterAnimationPanelStyle}
|
|
characterAnimationPrice={characterAnimationPrice}
|
|
setGenerateDialog={setGenerateDialog}
|
|
setQuickEditPanel={setQuickEditPanel}
|
|
setCharacterAnimationPanel={setCharacterAnimationPanel}
|
|
setIsCharacterSpecMenuOpen={setIsCharacterSpecMenuOpen}
|
|
setIsIconSpecMenuOpen={setIsIconSpecMenuOpen}
|
|
setIsPickingCharacterSpecFromCanvas={
|
|
setIsPickingCharacterSpecFromCanvas
|
|
}
|
|
setIsPickingIconSpecFromCanvas={setIsPickingIconSpecFromCanvas}
|
|
onOpenSpecDialog={openSpecDialog}
|
|
onRequestUpload={requestUpload}
|
|
onSubmitImageGeneration={(dialog) =>
|
|
void submitImageGeneration(dialog)
|
|
}
|
|
onSubmitIconSpritesheetGeneration={(dialog) =>
|
|
void submitIconSpritesheetGeneration(dialog)
|
|
}
|
|
onSubmitQuickEdit={() => void submitQuickEdit()}
|
|
onSubmitCharacterAnimation={() => void submitCharacterAnimation()}
|
|
onCloseGenerateComposer={() => {
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'generate'
|
|
? {
|
|
...currentDialog,
|
|
composerOpen: false,
|
|
}
|
|
: currentDialog,
|
|
);
|
|
setActiveTool('select');
|
|
}}
|
|
onUpdateSpecFormValue={updateSpecFormValue}
|
|
onUpdateIconDescription={updateIconDescription}
|
|
onAddIconDescription={addIconDescription}
|
|
onUpdateCharacterAnimationDuration={
|
|
updateCharacterAnimationDuration
|
|
}
|
|
/>
|
|
</ImageCanvasStageView>
|
|
</div>
|
|
|
|
<UnifiedModal
|
|
open={Boolean(metadataLayer)}
|
|
title={metadataLayer ? `${metadataLayer.title}图片信息` : '图片信息'}
|
|
size="sm"
|
|
closeLabel="关闭图片信息"
|
|
onClose={() => setMetadataLayer(null)}
|
|
panelClassName="image-canvas-editor__metadata-dialog"
|
|
bodyClassName="image-canvas-editor__metadata-body"
|
|
>
|
|
{metadataLayer ? (
|
|
<dl className="image-canvas-editor__metadata-grid">
|
|
<dt>图片类型</dt>
|
|
<dd>{formatLayerImageType(metadataLayer)}</dd>
|
|
<dt>生成输入</dt>
|
|
<dd className="image-canvas-editor__metadata-inputs">
|
|
{metadataLayer.generationInputs?.fields.length ||
|
|
metadataLayer.generationInputs?.references.length ? (
|
|
<>
|
|
{metadataLayer.generationInputs.fields.map((field) => (
|
|
<div
|
|
key={`${field.title}-${field.value}`}
|
|
className="image-canvas-editor__metadata-input-field"
|
|
>
|
|
<span className="image-canvas-editor__metadata-input-title">
|
|
{field.title}
|
|
</span>
|
|
<span>{field.value}</span>
|
|
</div>
|
|
))}
|
|
{metadataLayer.generationInputs.references.length ? (
|
|
<div className="image-canvas-editor__metadata-reference-list">
|
|
{metadataLayer.generationInputs.references.map(
|
|
(reference) => (
|
|
<div
|
|
key={`${reference.title}-${reference.label}-${reference.src}`}
|
|
className="image-canvas-editor__metadata-reference-card"
|
|
>
|
|
<img
|
|
src={reference.src}
|
|
alt=""
|
|
aria-hidden="true"
|
|
/>
|
|
<span className="image-canvas-editor__metadata-reference-copy">
|
|
<span className="image-canvas-editor__metadata-input-title">
|
|
{reference.title}
|
|
</span>
|
|
<span>{reference.label}</span>
|
|
</span>
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
'-'
|
|
)}
|
|
</dd>
|
|
<dt>Model</dt>
|
|
<dd>{metadataLayer.model ?? '-'}</dd>
|
|
<dt>Resolution</dt>
|
|
<dd>
|
|
{metadataLayer.originalWidth} x {metadataLayer.originalHeight} px
|
|
</dd>
|
|
<dt>Provider</dt>
|
|
<dd>{metadataLayer.provider ?? '-'}</dd>
|
|
<dt>Task</dt>
|
|
<dd>{metadataLayer.taskId ?? '-'}</dd>
|
|
<dt>Object</dt>
|
|
<dd>
|
|
{metadataLayer.objectKey ?? metadataLayer.assetObjectId ?? '-'}
|
|
</dd>
|
|
</dl>
|
|
) : null}
|
|
</UnifiedModal>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export default ImageCanvasEditorView;
|