37a738e271
新增画布生成对象 dialog 管理 hook 补充生成对象注册表 hook 单测 调整 Lovart 式画布背景色板弹层 更新图片画布前端拆分跟踪文档
4063 lines
127 KiB
TypeScript
4063 lines
127 KiB
TypeScript
import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
|
|
import JSZip from 'jszip';
|
|
import {
|
|
type CSSProperties,
|
|
type DragEvent as ReactDragEvent,
|
|
type MouseEvent as ReactMouseEvent,
|
|
type PointerEvent as ReactPointerEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import { ApiClientError } from '../../services/apiClient';
|
|
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
|
|
import {
|
|
createEditorAsset,
|
|
createEditorAssetFolder,
|
|
deleteEditorAsset,
|
|
deleteEditorAssetFolder,
|
|
editEditorImage,
|
|
type EditorIconSpritesheetGenerationResult,
|
|
type EditorIconSpritesheetIconResult,
|
|
type EditorImageGenerationResult,
|
|
generateEditorCharacterAnimation,
|
|
generateEditorIconSpritesheet,
|
|
generateEditorImage,
|
|
loadEditorAssetLibrary,
|
|
renameEditorProject,
|
|
updateEditorAsset,
|
|
updateEditorAssetFolder,
|
|
} from '../../services/image-editor/editorProjectClient';
|
|
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 {
|
|
createMinimapModel,
|
|
fitViewportToLayers,
|
|
getCanvasDropPoint as resolveCanvasDropPoint,
|
|
getCanvasPointFromClient as resolveCanvasPointFromClient,
|
|
getWorldPointFromClient,
|
|
moveGenerationFrameFromDrag,
|
|
moveLayersFromDrag,
|
|
moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag,
|
|
moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer,
|
|
moveViewportFromPan,
|
|
scaleViewportFromScreenPoint,
|
|
scrollViewportVertically,
|
|
selectLayersInsideMarquee,
|
|
zoomViewportFromWheel,
|
|
} from './ImageCanvasInteractionModel';
|
|
import {
|
|
createCanvasLayerClipboard,
|
|
duplicateCanvasLayers,
|
|
flipCanvasLayers,
|
|
getCanvasLayersByIds,
|
|
groupCanvasLayers,
|
|
moveCanvasLayers,
|
|
removeCanvasLayers,
|
|
resolveContextTargetLayerIds,
|
|
toggleCanvasLayersLock,
|
|
toggleCanvasLayersVisibility,
|
|
ungroupCanvasLayers,
|
|
updateCanvasLayersByIds,
|
|
type CanvasLayerFlipAxis,
|
|
type CanvasLayerMoveMode,
|
|
} from './ImageCanvasLayerCommandModel';
|
|
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
|
|
import { ImageCanvasStageView } from './ImageCanvasStageView';
|
|
import {
|
|
ASSET_DRAG_MIME_TYPE,
|
|
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
|
DEFAULT_CANVAS_SIZE,
|
|
EDITOR_ASSET_FOLDERS,
|
|
TOOLBAR_HALF_WIDTH,
|
|
clamp,
|
|
createLayerFromAsset,
|
|
escapeCssIdentifier,
|
|
formatImageSizeValue,
|
|
getDraggedAssetId,
|
|
hasDataTransferType,
|
|
isGeneratedLayer,
|
|
isLayerLinkedToAsset,
|
|
normalizeAssetLibrary,
|
|
normalizeCanvasBackgroundHex,
|
|
resolveContextMenuPosition,
|
|
resolveLayerResolutionSize,
|
|
serializeLayer,
|
|
} from './ImageCanvasEditorModel';
|
|
import {
|
|
blobToUint8Array,
|
|
buildLayerExportMetadata,
|
|
formatExportDate,
|
|
getImageExtensionFromTypeOrSrc,
|
|
getLayerExportKey,
|
|
readLayerImageBlob,
|
|
sanitizeExportFilePart,
|
|
} from './ImageCanvasExportModel';
|
|
import {
|
|
CHARACTER_ANIMATION_DURATION_OPTIONS,
|
|
CHARACTER_ANIMATION_MODEL,
|
|
CHARACTER_FRAME_DISPLAY_SIZE,
|
|
CHARACTER_FRAME_ORIGINAL_SIZE,
|
|
DEFAULT_ICON_DESCRIPTIONS,
|
|
DEFAULT_IMAGE_MODEL,
|
|
DEFAULT_SPEC_FORM_VALUES,
|
|
ICON_COMPOSER_HORIZONTAL_CHROME_REM,
|
|
ICON_COMPOSER_MIN_WIDTH_REM,
|
|
ICON_DESCRIPTION_CARD_WIDTH_REM,
|
|
ICON_DESCRIPTION_LIMIT,
|
|
ICON_FRAME_DISPLAY_SIZE,
|
|
ICON_FRAME_ORIGINAL_SIZE,
|
|
SPEC_FRAME_DISPLAY_SIZE,
|
|
SPEC_FRAME_ORIGINAL_SIZE,
|
|
SPEC_GENERATION_SIZE,
|
|
SPEC_TYPE_LABEL,
|
|
buildCharacterGenerationInputs,
|
|
buildEditGenerationInputs,
|
|
buildIconGenerationInputs,
|
|
buildImageGenerationInputs,
|
|
buildQuickEditModelOptions,
|
|
buildQuickEditSizeOptions,
|
|
buildSpecGenerationInputs,
|
|
buildSpecPrompt,
|
|
calculateCharacterAnimationPrice,
|
|
createCanvasLayerReference,
|
|
formatLayerImageType,
|
|
getGenerationFrameAriaLabel,
|
|
getGenerationFrameLabel,
|
|
getLayerKindLabel,
|
|
isCanvasGenerationDialog,
|
|
resolveCharacterAnimationSourceImageSrc,
|
|
resolveImageGenerationErrorMessage,
|
|
} from './ImageCanvasGenerationModel';
|
|
import type {
|
|
AssetMarqueeState,
|
|
AssetPointerDragState,
|
|
CanvasAssetExportImage,
|
|
CanvasAssetExportMetadata,
|
|
CanvasClipboard,
|
|
CanvasContextMenuState,
|
|
CanvasGenerationDialogState,
|
|
CanvasGenerationInputs,
|
|
CanvasLayer,
|
|
CanvasMarqueeState,
|
|
CanvasTool,
|
|
CanvasViewport,
|
|
CharacterAnimationPanelState,
|
|
DragState,
|
|
EditorAsset,
|
|
EditorAssetFolder,
|
|
GenerateDialogState,
|
|
ImageContextMenuState,
|
|
QuickEditPanelState,
|
|
SidebarPanel,
|
|
SnapGuide,
|
|
SpecFormValues,
|
|
SpecGenerationType,
|
|
UploadTarget,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { useCanvasHistory } from './useCanvasHistory';
|
|
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
|
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
|
|
|
function isImageFile(file: File) {
|
|
return file.type.startsWith('image/');
|
|
}
|
|
|
|
function isEditableTarget(event: KeyboardEvent) {
|
|
const target = event.target as HTMLElement | null;
|
|
if (!target) {
|
|
return false;
|
|
}
|
|
return (
|
|
target.tagName === 'INPUT' ||
|
|
target.tagName === 'TEXTAREA' ||
|
|
target.isContentEditable
|
|
);
|
|
}
|
|
|
|
function getPointerButton(event: ReactPointerEvent<HTMLElement>) {
|
|
const nativeEvent = event.nativeEvent as PointerEvent;
|
|
const nativeButtons = Number(nativeEvent.buttons);
|
|
if (Number.isFinite(nativeButtons) && (nativeButtons & 4) === 4) {
|
|
return 1;
|
|
}
|
|
const syntheticButtons = Number(event.buttons);
|
|
if (Number.isFinite(syntheticButtons) && (syntheticButtons & 4) === 4) {
|
|
return 1;
|
|
}
|
|
const syntheticButton = Number(event.button);
|
|
if (Number.isFinite(syntheticButton)) {
|
|
return syntheticButton;
|
|
}
|
|
const nativeButton = Number(nativeEvent.button);
|
|
if (Number.isFinite(nativeButton)) {
|
|
return nativeButton;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function getPointerClient(event: ReactPointerEvent<HTMLElement>) {
|
|
const nativeEvent = event.nativeEvent as PointerEvent;
|
|
return {
|
|
x: Number.isFinite(event.clientX)
|
|
? event.clientX
|
|
: Number.isFinite(nativeEvent.clientX)
|
|
? nativeEvent.clientX
|
|
: 0,
|
|
y: Number.isFinite(event.clientY)
|
|
? event.clientY
|
|
: Number.isFinite(nativeEvent.clientY)
|
|
? nativeEvent.clientY
|
|
: 0,
|
|
};
|
|
}
|
|
|
|
function getPointerId(event: ReactPointerEvent<HTMLElement>) {
|
|
const nativeId = (event.nativeEvent as PointerEvent).pointerId;
|
|
if (Number.isFinite(event.pointerId)) {
|
|
return event.pointerId;
|
|
}
|
|
return Number.isFinite(nativeId) ? nativeId : -1;
|
|
}
|
|
|
|
function isEditorAuthError(error: unknown) {
|
|
return (
|
|
error instanceof ApiClientError &&
|
|
(error.status === 401 || error.status === 403)
|
|
);
|
|
}
|
|
|
|
export function ImageCanvasEditorView() {
|
|
const authUi = useAuthUi();
|
|
const editorRootRef = useRef<HTMLElement | null>(null);
|
|
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
const assetListRef = useRef<HTMLDivElement | null>(null);
|
|
const dragStateRef = useRef<DragState | null>(null);
|
|
const assetPointerDragRef = useRef<AssetPointerDragState | null>(null);
|
|
const authUiRef = useRef(authUi);
|
|
const isShiftPressedRef = useRef(false);
|
|
const layerCounterRef = useRef(0);
|
|
const layersRef = useRef<CanvasLayer[]>([]);
|
|
const viewportRef = useRef<CanvasViewport>({
|
|
x: -260,
|
|
y: 70,
|
|
scale: 0.82,
|
|
});
|
|
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 [projectTitle, setProjectTitle] = useState('未命名画布');
|
|
const [projectRenameValue, setProjectRenameValue] = useState('未命名画布');
|
|
const [isRenamingProject, setIsRenamingProject] = useState(false);
|
|
const [isProjectRenameSaving, setIsProjectRenameSaving] = useState(false);
|
|
const [projectRenameError, setProjectRenameError] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [assetExportStatus, setAssetExportStatus] = useState<{
|
|
tone: 'info' | 'success' | 'error';
|
|
message: string;
|
|
} | null>(null);
|
|
const [isExportingAssets, setIsExportingAssets] = useState(false);
|
|
const [activeSidebarPanel, setActiveSidebarPanel] =
|
|
useState<SidebarPanel | null>('assets');
|
|
const [viewport, setViewport] = useState<CanvasViewport>({
|
|
x: -260,
|
|
y: 70,
|
|
scale: 0.82,
|
|
});
|
|
const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE);
|
|
const [assetFolders, setAssetFolders] =
|
|
useState<EditorAssetFolder[]>(EDITOR_ASSET_FOLDERS);
|
|
const [assets, setAssets] = useState<EditorAsset[]>([]);
|
|
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
|
const [renamingAsset, setRenamingAsset] = useState<{
|
|
assetId: string;
|
|
value: string;
|
|
} | null>(null);
|
|
const [renamingFolder, setRenamingFolder] = useState<{
|
|
folderId: string;
|
|
value: string;
|
|
} | null>(null);
|
|
const [creatingFolder, setCreatingFolder] = useState(false);
|
|
const [newFolderName, setNewFolderName] = useState('');
|
|
const [activeUploadFolderId, setActiveUploadFolderId] = useState('project');
|
|
const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false);
|
|
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(
|
|
() => new Set(),
|
|
);
|
|
const [assetMarquee, setAssetMarquee] = useState<AssetMarqueeState | null>(
|
|
null,
|
|
);
|
|
const [assetPointerDrag, setAssetPointerDrag] =
|
|
useState<AssetPointerDragState | null>(null);
|
|
const [assetMoveDropFolderId, setAssetMoveDropFolderId] = useState<
|
|
string | null
|
|
>(null);
|
|
const [pinnedAssetMoveFolderId, setPinnedAssetMoveFolderId] = useState<
|
|
string | null
|
|
>(null);
|
|
const [canvasMarquee, setCanvasMarquee] = useState<CanvasMarqueeState | null>(
|
|
null,
|
|
);
|
|
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
|
|
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
|
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>(null);
|
|
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
|
|
const [isSpacePanning, setIsSpacePanning] = useState(false);
|
|
const [isPanning, setIsPanning] = useState(false);
|
|
const [snapGuide, setSnapGuide] = useState<SnapGuide | null>(null);
|
|
const [isZoomMenuOpen, setIsZoomMenuOpen] = useState(false);
|
|
const [isBackgroundSettingsOpen, setIsBackgroundSettingsOpen] =
|
|
useState(false);
|
|
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
|
const [isMinimapOpen, setIsMinimapOpen] = useState(true);
|
|
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState(
|
|
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
|
);
|
|
const [canvasBackgroundHexValue, setCanvasBackgroundHexValue] = useState(
|
|
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
|
);
|
|
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
|
|
const [uploadTarget, setUploadTarget] = useState<UploadTarget>('asset');
|
|
const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false);
|
|
const [
|
|
isPickingCharacterSpecFromCanvas,
|
|
setIsPickingCharacterSpecFromCanvas,
|
|
] = useState(false);
|
|
const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(false);
|
|
const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] =
|
|
useState(false);
|
|
const [imageContextMenu, setImageContextMenu] =
|
|
useState<ImageContextMenuState | null>(null);
|
|
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
|
|
null,
|
|
);
|
|
const [canvasClipboard, setCanvasClipboard] =
|
|
useState<CanvasClipboard | null>(null);
|
|
const [quickEditPanel, setQuickEditPanel] =
|
|
useState<QuickEditPanelState | null>(null);
|
|
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
|
useState<CharacterAnimationPanelState | null>(null);
|
|
const [uploadDropTarget, setUploadDropTarget] = useState<
|
|
'canvas' | 'assets' | null
|
|
>(null);
|
|
|
|
selectedLayerIdRef.current = selectedLayerId;
|
|
selectedLayerIdsRef.current = selectedLayerIds;
|
|
layersRef.current = layers;
|
|
viewportRef.current = viewport;
|
|
const assetsRef = useRef(assets);
|
|
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 applyCanvasBackgroundColor = useCallback((color: string) => {
|
|
const normalizedColor = normalizeCanvasBackgroundHex(color);
|
|
if (!normalizedColor) {
|
|
return false;
|
|
}
|
|
setCanvasBackgroundColor(normalizedColor);
|
|
setCanvasBackgroundHexValue(normalizedColor);
|
|
return true;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
assetsRef.current = assets;
|
|
}, [assets]);
|
|
|
|
const effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool;
|
|
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 iconDescriptionValues =
|
|
activeCanvasGenerationDialog?.mode === 'icon'
|
|
? (activeCanvasGenerationDialog.iconDescriptions ??
|
|
DEFAULT_ICON_DESCRIPTIONS)
|
|
: DEFAULT_ICON_DESCRIPTIONS;
|
|
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 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 characterAnimationSourceLayer = characterAnimationPanel
|
|
? (layers.find(
|
|
(layer) => layer.id === characterAnimationPanel.sourceLayerId,
|
|
) ?? null)
|
|
: null;
|
|
const quickEditSourceLayer = quickEditPanel
|
|
? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ??
|
|
null)
|
|
: 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 quickEditSizeOptions = quickEditPanel
|
|
? buildQuickEditSizeOptions(quickEditPanel.size)
|
|
: [];
|
|
const quickEditModelOptions = quickEditPanel
|
|
? buildQuickEditModelOptions(quickEditPanel.model)
|
|
: [];
|
|
const characterAnimationPrice = characterAnimationPanel
|
|
? calculateCharacterAnimationPrice(
|
|
characterAnimationPanel.resolution,
|
|
characterAnimationPanel.durationSeconds,
|
|
)
|
|
: 0;
|
|
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 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 clearHistoryDragState = useCallback(() => {
|
|
dragStateRef.current = null;
|
|
}, []);
|
|
const canvasHistoryResetters = useMemo(
|
|
() => ({
|
|
setHoveredLayerId,
|
|
setMetadataLayer,
|
|
setCanvasMarquee,
|
|
setSnapGuide,
|
|
setImageContextMenu,
|
|
setContextMenu,
|
|
setIsPanning,
|
|
clearDragState: clearHistoryDragState,
|
|
}),
|
|
[clearHistoryDragState],
|
|
);
|
|
const {
|
|
canUndo,
|
|
canRedo,
|
|
captureCanvasHistory,
|
|
undoCanvasChange,
|
|
redoCanvasChange,
|
|
} = useCanvasHistory({
|
|
refs: canvasHistoryRefs,
|
|
setters: canvasHistorySetters,
|
|
resetters: canvasHistoryResetters,
|
|
});
|
|
const groupedAssets = useMemo(
|
|
() =>
|
|
assetFolders.map((folder) => ({
|
|
...folder,
|
|
assets: assets.filter((asset) => asset.folderId === folder.id),
|
|
})),
|
|
[assetFolders, assets],
|
|
);
|
|
const selectableAssets = useMemo(
|
|
() => assets.filter((asset) => asset.sourceKind === 'uploaded'),
|
|
[assets],
|
|
);
|
|
const allSelectableAssetsSelected =
|
|
selectableAssets.length > 0 &&
|
|
selectableAssets.every((asset) => selectedAssetIds.has(asset.id));
|
|
|
|
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 hideGeneratedLayerPanelAfterBlur = useCallback(() => {
|
|
setGenerateDialog((currentDialog) =>
|
|
(currentDialog?.mode === 'generate' ||
|
|
currentDialog?.mode === 'spec' ||
|
|
currentDialog?.mode === 'character' ||
|
|
currentDialog?.mode === 'icon') &&
|
|
currentDialog.status !== 'generating'
|
|
? {
|
|
...currentDialog,
|
|
composerOpen: false,
|
|
}
|
|
: currentDialog,
|
|
);
|
|
}, []);
|
|
|
|
const clearCanvasFocus = useCallback(() => {
|
|
selectSingleLayer(null);
|
|
hideGeneratedLayerPanelAfterBlur();
|
|
setImageContextMenu(null);
|
|
setContextMenu(null);
|
|
}, [hideGeneratedLayerPanelAfterBlur, selectSingleLayer]);
|
|
|
|
const minimapModel = useMemo(
|
|
() => createMinimapModel({ layers, viewport, canvasSize }),
|
|
[canvasSize, layers, viewport],
|
|
);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
loadEditorAssetLibrary()
|
|
.then((library) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
const nextLibrary = normalizeAssetLibrary(library);
|
|
setAssetFolders(nextLibrary.folders);
|
|
setAssets(nextLibrary.assets);
|
|
const defaultFolder = nextLibrary.folders.find(
|
|
(folder) => folder.systemDefault,
|
|
);
|
|
setActiveUploadFolderId(
|
|
defaultFolder?.id ?? nextLibrary.folders[0]?.id ?? 'project',
|
|
);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (!cancelled && isEditorAuthError(error)) {
|
|
openEditorLoginModal();
|
|
}
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [openEditorLoginModal]);
|
|
|
|
useEffect(() => {
|
|
const viewportElement = canvasViewportRef.current;
|
|
if (!viewportElement) {
|
|
return undefined;
|
|
}
|
|
|
|
const updateCanvasSize = () => {
|
|
setCanvasSize({
|
|
width: viewportElement.clientWidth || DEFAULT_CANVAS_SIZE.width,
|
|
height: viewportElement.clientHeight || DEFAULT_CANVAS_SIZE.height,
|
|
});
|
|
};
|
|
|
|
updateCanvasSize();
|
|
|
|
if (typeof ResizeObserver === 'undefined') {
|
|
window.addEventListener('resize', updateCanvasSize);
|
|
return () => window.removeEventListener('resize', updateCanvasSize);
|
|
}
|
|
|
|
const observer = new ResizeObserver(updateCanvasSize);
|
|
observer.observe(viewportElement);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
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') {
|
|
isShiftPressedRef.current = 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') {
|
|
setActiveSidebarPanel(null);
|
|
setIsZoomMenuOpen(false);
|
|
setIsBackgroundSettingsOpen(false);
|
|
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') {
|
|
isShiftPressedRef.current = 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);
|
|
};
|
|
}, [redoCanvasChange, 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 fitLayers = useCallback(
|
|
(targetLayers: CanvasLayer[] = layers) => {
|
|
const nextViewport = fitViewportToLayers({
|
|
layers: targetLayers,
|
|
canvasSize,
|
|
});
|
|
if (!nextViewport) {
|
|
return;
|
|
}
|
|
|
|
captureCanvasHistory();
|
|
setViewport(nextViewport);
|
|
},
|
|
[captureCanvasHistory, canvasSize, layers],
|
|
);
|
|
|
|
const updateScaleFromCenter = (nextScale: number) => {
|
|
const viewportElement = canvasViewportRef.current;
|
|
if (!viewportElement) {
|
|
captureCanvasHistory();
|
|
setViewport((currentViewport) =>
|
|
scaleViewportFromScreenPoint({
|
|
viewport: currentViewport,
|
|
nextScale,
|
|
screenPoint: null,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const rect = viewportElement.getBoundingClientRect();
|
|
const centerX = rect.width > 0 ? rect.width / 2 : canvasSize.width / 2;
|
|
const centerY = rect.height > 0 ? rect.height / 2 : canvasSize.height / 2;
|
|
captureCanvasHistory();
|
|
setViewport((currentViewport) =>
|
|
scaleViewportFromScreenPoint({
|
|
viewport: currentViewport,
|
|
nextScale,
|
|
screenPoint: { x: centerX, y: centerY },
|
|
}),
|
|
);
|
|
};
|
|
|
|
const resolveCanvasPoint = (clientX: number, clientY: number) => {
|
|
const rect = canvasViewportRef.current?.getBoundingClientRect() ?? null;
|
|
return resolveCanvasPointFromClient({ clientX, clientY, rect });
|
|
};
|
|
|
|
const getCanvasDropPoint = (event: ReactDragEvent<HTMLDivElement>) =>
|
|
resolveCanvasDropPoint({
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
rect: canvasViewportRef.current?.getBoundingClientRect() ?? null,
|
|
canvasSize,
|
|
});
|
|
|
|
const getCanvasPointFromClient = (clientX: number, clientY: number) => {
|
|
return getWorldPointFromClient({
|
|
clientX,
|
|
clientY,
|
|
rect: canvasViewportRef.current?.getBoundingClientRect() ?? null,
|
|
viewport,
|
|
});
|
|
};
|
|
|
|
const duplicateLayersToPoint = (
|
|
sourceLayers: CanvasLayer[],
|
|
canvasPoint?: { x: number; y: number },
|
|
options: { renameCopies?: boolean } = {},
|
|
) =>
|
|
duplicateCanvasLayers({
|
|
sourceLayers,
|
|
allLayers: layersRef.current,
|
|
canvasPoint,
|
|
renameCopies: options.renameCopies !== false,
|
|
});
|
|
|
|
const pasteCanvasClipboard = (canvasPoint?: { x: number; y: number }) => {
|
|
if (!canvasClipboard?.layers.length) {
|
|
return;
|
|
}
|
|
const nextLayers = duplicateLayersToPoint(
|
|
canvasClipboard.layers,
|
|
canvasPoint,
|
|
{
|
|
renameCopies: canvasClipboard.mode !== 'cut',
|
|
},
|
|
);
|
|
if (!nextLayers.length) {
|
|
return;
|
|
}
|
|
captureCanvasHistory();
|
|
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
|
|
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
|
|
setSelectedLayerId(nextLayers[0]?.id ?? null);
|
|
setActiveTool('select');
|
|
setContextMenu(null);
|
|
};
|
|
|
|
const copyContextLayers = (options: { cut?: boolean } = {}) => {
|
|
const targetIds = getContextTargetLayerIds();
|
|
const clipboard = createCanvasLayerClipboard(
|
|
layers,
|
|
targetIds,
|
|
options.cut ? 'cut' : 'copy',
|
|
);
|
|
if (!clipboard) {
|
|
return;
|
|
}
|
|
setCanvasClipboard(clipboard);
|
|
if (options.cut) {
|
|
captureCanvasHistory();
|
|
setLayers((currentLayers) =>
|
|
removeCanvasLayers(currentLayers, targetIds),
|
|
);
|
|
selectSingleLayer(null);
|
|
setMetadataLayer((currentLayer) =>
|
|
currentLayer && targetIds.includes(currentLayer.id)
|
|
? null
|
|
: currentLayer,
|
|
);
|
|
}
|
|
setContextMenu(null);
|
|
};
|
|
|
|
const duplicateContextLayers = () => {
|
|
const targetIds = getContextTargetLayerIds();
|
|
const targetLayers = getCanvasLayersByIds(layers, targetIds);
|
|
const nextLayers = duplicateLayersToPoint(targetLayers);
|
|
if (!nextLayers.length) {
|
|
return;
|
|
}
|
|
captureCanvasHistory();
|
|
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
|
|
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
|
|
setSelectedLayerId(nextLayers[0]?.id ?? null);
|
|
setContextMenu(null);
|
|
};
|
|
|
|
const updateContextLayers = (
|
|
updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer,
|
|
) => {
|
|
const targetIds = getContextTargetLayerIds();
|
|
if (!targetIds.length) {
|
|
return;
|
|
}
|
|
captureCanvasHistory();
|
|
setLayers((currentLayers) =>
|
|
updateCanvasLayersByIds(currentLayers, targetIds, updater),
|
|
);
|
|
setContextMenu(null);
|
|
};
|
|
|
|
const moveContextLayers = (mode: CanvasLayerMoveMode) => {
|
|
const targetIds = getContextTargetLayerIds();
|
|
if (!targetIds.length) {
|
|
return;
|
|
}
|
|
captureCanvasHistory();
|
|
setLayers((currentLayers) =>
|
|
moveCanvasLayers(currentLayers, targetIds, mode),
|
|
);
|
|
setContextMenu(null);
|
|
};
|
|
|
|
const groupContextLayers = () => {
|
|
const targetIds = getContextTargetLayerIds();
|
|
if (!targetIds.length) {
|
|
return;
|
|
}
|
|
const groupId = `layer-group-${Date.now()}`;
|
|
captureCanvasHistory();
|
|
setLayers((currentLayers) =>
|
|
groupCanvasLayers(currentLayers, targetIds, groupId),
|
|
);
|
|
setContextMenu(null);
|
|
};
|
|
|
|
const ungroupContextLayers = () => {
|
|
const targetIds = getContextTargetLayerIds();
|
|
if (!targetIds.length) {
|
|
return;
|
|
}
|
|
captureCanvasHistory();
|
|
setLayers((currentLayers) => ungroupCanvasLayers(currentLayers, targetIds));
|
|
setContextMenu(null);
|
|
};
|
|
|
|
const toggleContextLayerVisibility = () => {
|
|
const targetIds = getContextTargetLayerIds();
|
|
if (!targetIds.length) {
|
|
return;
|
|
}
|
|
captureCanvasHistory();
|
|
setLayers((currentLayers) =>
|
|
toggleCanvasLayersVisibility(currentLayers, targetIds),
|
|
);
|
|
setContextMenu(null);
|
|
};
|
|
|
|
const toggleContextLayerLock = () => {
|
|
const targetIds = getContextTargetLayerIds();
|
|
if (!targetIds.length) {
|
|
return;
|
|
}
|
|
captureCanvasHistory();
|
|
setLayers((currentLayers) =>
|
|
toggleCanvasLayersLock(currentLayers, targetIds),
|
|
);
|
|
setContextMenu(null);
|
|
};
|
|
|
|
const flipContextLayers = (axis: CanvasLayerFlipAxis) => {
|
|
const targetIds = getContextTargetLayerIds();
|
|
if (!targetIds.length) {
|
|
return;
|
|
}
|
|
captureCanvasHistory();
|
|
setLayers((currentLayers) =>
|
|
flipCanvasLayers(currentLayers, targetIds, axis),
|
|
);
|
|
setContextMenu(null);
|
|
};
|
|
|
|
const deleteContextLayers = () => {
|
|
const targetIds = getContextTargetLayerIds();
|
|
if (!targetIds.length) {
|
|
return;
|
|
}
|
|
captureCanvasHistory();
|
|
setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds));
|
|
selectSingleLayer(null);
|
|
setHoveredLayerId(null);
|
|
setMetadataLayer((currentLayer) =>
|
|
currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer,
|
|
);
|
|
setContextMenu(null);
|
|
};
|
|
|
|
const exportContextLayer = () => {
|
|
const targetIds = getContextTargetLayerIds();
|
|
const targetLayer = layers.find((layer) => targetIds.includes(layer.id));
|
|
if (!targetLayer) {
|
|
return;
|
|
}
|
|
const link = document.createElement('a');
|
|
link.href = targetLayer.src;
|
|
link.download = `${sanitizeExportFilePart(targetLayer.title, 'canvas-layer')}.png`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
setContextMenu(null);
|
|
};
|
|
|
|
const resolveAssetFolderId = (clientX: number, clientY: number) => {
|
|
const listElement = assetListRef.current;
|
|
if (!listElement) {
|
|
return null;
|
|
}
|
|
const listRect = listElement.getBoundingClientRect();
|
|
if (
|
|
clientX < listRect.left ||
|
|
clientX > listRect.right ||
|
|
clientY < listRect.top ||
|
|
clientY > listRect.bottom
|
|
) {
|
|
return null;
|
|
}
|
|
const folderElements = [
|
|
...listElement.querySelectorAll<HTMLElement>('[data-asset-folder-id]'),
|
|
];
|
|
const matchedFolder = folderElements.find((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return (
|
|
clientX >= rect.left &&
|
|
clientX <= rect.right &&
|
|
clientY >= rect.top &&
|
|
clientY <= rect.bottom
|
|
);
|
|
});
|
|
return matchedFolder?.dataset.assetFolderId ?? null;
|
|
};
|
|
|
|
const updateAssetMoveDropFolder = (folderId: string | null) => {
|
|
setAssetMoveDropFolderId(folderId);
|
|
if (!folderId) {
|
|
setPinnedAssetMoveFolderId(null);
|
|
return;
|
|
}
|
|
const listElement = assetListRef.current;
|
|
const header = listElement?.querySelector<HTMLElement>(
|
|
`[data-asset-folder-header-id="${escapeCssIdentifier(folderId)}"]`,
|
|
);
|
|
const listRect = listElement?.getBoundingClientRect();
|
|
const headerRect = header?.getBoundingClientRect();
|
|
setPinnedAssetMoveFolderId(
|
|
listRect &&
|
|
headerRect &&
|
|
(headerRect.bottom < listRect.top || headerRect.top > listRect.bottom)
|
|
? folderId
|
|
: null,
|
|
);
|
|
};
|
|
|
|
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;
|
|
|
|
const exportCanvasAssets = async () => {
|
|
if (isExportingAssets) {
|
|
return;
|
|
}
|
|
const exportableLayers = layers
|
|
.filter((layer) => layer.src.trim().length > 0)
|
|
.sort((left, right) => left.zIndex - right.zIndex);
|
|
if (!exportableLayers.length) {
|
|
setAssetExportStatus({
|
|
tone: 'info',
|
|
message: '当前画布没有可导出的素材',
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsExportingAssets(true);
|
|
setAssetExportStatus(null);
|
|
|
|
try {
|
|
const exportedAt = new Date();
|
|
const projectName = sanitizeExportFilePart(projectTitle, '未命名画布');
|
|
const rootFolderName = `${projectName}-画布素材`;
|
|
const zip = new JSZip();
|
|
const rootFolder = zip.folder(rootFolderName) ?? zip;
|
|
const imagesFolder = rootFolder.folder('images') ?? rootFolder;
|
|
const imageByKey = new Map<string, CanvasAssetExportImage>();
|
|
const usedFileNames = new Map<string, number>();
|
|
|
|
for (const layer of exportableLayers) {
|
|
const key = getLayerExportKey(layer);
|
|
if (imageByKey.has(key)) {
|
|
continue;
|
|
}
|
|
const index = imageByKey.size + 1;
|
|
const safeTitle = sanitizeExportFilePart(layer.title, '画布素材');
|
|
const baseFileName = `${String(index).padStart(3, '0')}-${safeTitle}`;
|
|
const duplicateCount = usedFileNames.get(baseFileName) ?? 0;
|
|
usedFileNames.set(baseFileName, duplicateCount + 1);
|
|
const indexedFileName =
|
|
duplicateCount > 0
|
|
? `${baseFileName}-${duplicateCount + 1}`
|
|
: baseFileName;
|
|
|
|
try {
|
|
const blob = await readLayerImageBlob(layer);
|
|
const extension = getImageExtensionFromTypeOrSrc(
|
|
blob.type,
|
|
layer.src,
|
|
);
|
|
const file = `images/${indexedFileName}.${extension}`;
|
|
imageByKey.set(key, {
|
|
key,
|
|
file,
|
|
layer,
|
|
blob,
|
|
});
|
|
imagesFolder.file(
|
|
`${indexedFileName}.${extension}`,
|
|
await blobToUint8Array(blob),
|
|
);
|
|
} catch (error) {
|
|
imageByKey.set(key, {
|
|
key,
|
|
file: `images/${indexedFileName}.png`,
|
|
layer,
|
|
error: error instanceof Error ? error.message : '图片读取失败',
|
|
});
|
|
}
|
|
}
|
|
|
|
const failedImages = [...imageByKey.values()].filter(
|
|
(image) => image.error,
|
|
);
|
|
const successfulImages = [...imageByKey.values()].filter(
|
|
(image) => image.blob,
|
|
);
|
|
if (!successfulImages.length) {
|
|
setAssetExportStatus({
|
|
tone: 'error',
|
|
message: '素材导出失败',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const metadata: CanvasAssetExportMetadata = {
|
|
projectId,
|
|
projectTitle,
|
|
exportedAt: exportedAt.toISOString(),
|
|
layers: exportableLayers.map((layer) => {
|
|
const image = imageByKey.get(getLayerExportKey(layer));
|
|
return buildLayerExportMetadata(
|
|
layer,
|
|
image?.blob ? image.file : null,
|
|
image?.error,
|
|
);
|
|
}),
|
|
failedImages: failedImages.map((image) => ({
|
|
key: image.key,
|
|
title: image.layer.title,
|
|
src: image.layer.src,
|
|
error: image.error ?? '图片读取失败',
|
|
})),
|
|
};
|
|
const manifest = [
|
|
`项目:${projectTitle}`,
|
|
`导出时间:${metadata.exportedAt}`,
|
|
`素材数量:${successfulImages.length}`,
|
|
`图层数量:${exportableLayers.length}`,
|
|
failedImages.length ? `失败素材数量:${failedImages.length}` : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
|
|
rootFolder.file('metadata.json', JSON.stringify(metadata, null, 2));
|
|
rootFolder.file('manifest.txt', manifest);
|
|
|
|
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
|
if (
|
|
typeof URL.createObjectURL !== 'function' ||
|
|
typeof URL.revokeObjectURL !== 'function'
|
|
) {
|
|
setAssetExportStatus({
|
|
tone: 'error',
|
|
message: '当前浏览器不支持素材下载',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const downloadUrl = URL.createObjectURL(zipBlob);
|
|
const link = document.createElement('a');
|
|
link.href = downloadUrl;
|
|
link.download = `${rootFolderName}-${formatExportDate(exportedAt)}.zip`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
URL.revokeObjectURL(downloadUrl);
|
|
setAssetExportStatus({
|
|
tone: failedImages.length ? 'error' : 'success',
|
|
message: failedImages.length ? '部分素材未能导出' : '画布素材已导出',
|
|
});
|
|
} catch {
|
|
setAssetExportStatus({
|
|
tone: 'error',
|
|
message: '素材导出失败',
|
|
});
|
|
} finally {
|
|
setIsExportingAssets(false);
|
|
}
|
|
};
|
|
|
|
const startProjectRename = () => {
|
|
setProjectRenameValue(projectTitle);
|
|
setProjectRenameError(null);
|
|
setIsRenamingProject(true);
|
|
};
|
|
|
|
const cancelProjectRename = () => {
|
|
setProjectRenameValue(projectTitle);
|
|
setProjectRenameError(null);
|
|
setIsRenamingProject(false);
|
|
};
|
|
|
|
const submitProjectRename = () => {
|
|
const nextTitle = projectRenameValue.trim();
|
|
if (!nextTitle) {
|
|
setProjectRenameError('项目名称不能为空');
|
|
return;
|
|
}
|
|
if (!projectId || nextTitle === projectTitle) {
|
|
setProjectRenameValue(projectTitle);
|
|
setProjectRenameError(null);
|
|
setIsRenamingProject(false);
|
|
return;
|
|
}
|
|
setIsProjectRenameSaving(true);
|
|
setProjectRenameError(null);
|
|
renameEditorProject(projectId, nextTitle)
|
|
.then((project) => {
|
|
const savedTitle = project.title?.trim() || nextTitle;
|
|
setProjectTitle(savedTitle);
|
|
setProjectRenameValue(savedTitle);
|
|
setIsRenamingProject(false);
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (isEditorAuthError(error)) {
|
|
openEditorLoginModal();
|
|
}
|
|
setProjectRenameError(
|
|
error instanceof Error ? error.message : '重命名项目失败',
|
|
);
|
|
})
|
|
.finally(() => setIsProjectRenameSaving(false));
|
|
};
|
|
|
|
const startRenamingAsset = (asset: EditorAsset) => {
|
|
setRenamingAsset({
|
|
assetId: asset.id,
|
|
value: asset.label,
|
|
});
|
|
};
|
|
|
|
const commitAssetRename = (asset: EditorAsset) => {
|
|
const nextLabel = renamingAsset?.value.trim();
|
|
if (!nextLabel) {
|
|
setRenamingAsset(null);
|
|
return;
|
|
}
|
|
setAssets((currentAssets) =>
|
|
currentAssets.map((currentAsset) =>
|
|
currentAsset.id === asset.id
|
|
? {
|
|
...currentAsset,
|
|
label: nextLabel,
|
|
}
|
|
: currentAsset,
|
|
),
|
|
);
|
|
if (asset.persisted) {
|
|
updateEditorAsset(asset.id, { label: nextLabel }).catch(() => {});
|
|
}
|
|
setRenamingAsset(null);
|
|
};
|
|
|
|
const toggleAssetFolder = (folderId: string) => {
|
|
const nextFolder = assetFolders.find((folder) => folder.id === folderId);
|
|
const nextCollapsed = !(nextFolder?.collapsed ?? false);
|
|
setAssetFolders((currentFolders) =>
|
|
currentFolders.map((folder) =>
|
|
folder.id === folderId
|
|
? {
|
|
...folder,
|
|
collapsed: !folder.collapsed,
|
|
}
|
|
: folder,
|
|
),
|
|
);
|
|
if (nextFolder?.persisted) {
|
|
updateEditorAssetFolder(folderId, { collapsed: nextCollapsed }).catch(
|
|
() => {},
|
|
);
|
|
}
|
|
};
|
|
|
|
const commitNewAssetFolder = async () => {
|
|
const label = newFolderName.trim();
|
|
if (!label) {
|
|
setCreatingFolder(false);
|
|
setNewFolderName('');
|
|
return;
|
|
}
|
|
const folderId = `folder-${Date.now()}`;
|
|
setAssetFolders((currentFolders) => [
|
|
...currentFolders,
|
|
{
|
|
id: folderId,
|
|
label,
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
persisted: false,
|
|
},
|
|
]);
|
|
setActiveUploadFolderId(folderId);
|
|
setCreatingFolder(false);
|
|
setNewFolderName('');
|
|
try {
|
|
const folder = await createEditorAssetFolder(
|
|
label,
|
|
assetFolders.length + 100,
|
|
);
|
|
setAssetFolders((currentFolders) =>
|
|
currentFolders.map((currentFolder) =>
|
|
currentFolder.id === folderId
|
|
? {
|
|
id: folder.folderId,
|
|
label: folder.label,
|
|
collapsed: folder.collapsed,
|
|
systemDefault: folder.systemDefault,
|
|
persisted: true,
|
|
}
|
|
: currentFolder,
|
|
),
|
|
);
|
|
setAssets((currentAssets) =>
|
|
currentAssets.map((asset) =>
|
|
asset.folderId === folderId
|
|
? {
|
|
...asset,
|
|
folderId: folder.folderId,
|
|
}
|
|
: asset,
|
|
),
|
|
);
|
|
setActiveUploadFolderId(folder.folderId);
|
|
} catch {
|
|
// 本地临时文件夹仍可继续使用;下次刷新以后端为准。
|
|
}
|
|
};
|
|
|
|
const deleteUploadedAsset = (asset: EditorAsset) => {
|
|
if (asset.sourceKind !== 'uploaded') {
|
|
return;
|
|
}
|
|
setAssets((currentAssets) =>
|
|
currentAssets.filter((currentAsset) => currentAsset.id !== asset.id),
|
|
);
|
|
setLayers((currentLayers) =>
|
|
currentLayers.filter((layer) => !isLayerLinkedToAsset(layer, asset)),
|
|
);
|
|
setSelectedLayerIds((currentIds) =>
|
|
currentIds.filter((layerId) =>
|
|
layers.every(
|
|
(layer) =>
|
|
layer.id !== layerId || !isLayerLinkedToAsset(layer, asset),
|
|
),
|
|
),
|
|
);
|
|
setSelectedLayerId((currentId) => {
|
|
if (!currentId) {
|
|
return currentId;
|
|
}
|
|
const currentLayer = layers.find((layer) => layer.id === currentId);
|
|
return currentLayer && isLayerLinkedToAsset(currentLayer, asset)
|
|
? null
|
|
: currentId;
|
|
});
|
|
setRenamingAsset((currentRename) =>
|
|
currentRename?.assetId === asset.id ? null : currentRename,
|
|
);
|
|
if (asset.persisted) {
|
|
deleteEditorAsset(asset.id).catch(() => {});
|
|
}
|
|
};
|
|
|
|
const startRenamingFolder = (folder: EditorAssetFolder) => {
|
|
setRenamingFolder({
|
|
folderId: folder.id,
|
|
value: folder.label,
|
|
});
|
|
};
|
|
|
|
const commitFolderRename = (folder: EditorAssetFolder) => {
|
|
const nextLabel = renamingFolder?.value.trim();
|
|
if (!nextLabel) {
|
|
setRenamingFolder(null);
|
|
return;
|
|
}
|
|
setAssetFolders((currentFolders) =>
|
|
currentFolders.map((currentFolder) =>
|
|
currentFolder.id === folder.id
|
|
? {
|
|
...currentFolder,
|
|
label: nextLabel,
|
|
}
|
|
: currentFolder,
|
|
),
|
|
);
|
|
if (folder.persisted) {
|
|
updateEditorAssetFolder(folder.id, { label: nextLabel }).catch(() => {});
|
|
}
|
|
setRenamingFolder(null);
|
|
};
|
|
|
|
const deleteAssetFolder = (folder: EditorAssetFolder) => {
|
|
if (folder.systemDefault) {
|
|
return;
|
|
}
|
|
const defaultFolder =
|
|
assetFolders.find((currentFolder) => currentFolder.systemDefault) ??
|
|
assetFolders[0];
|
|
if (!defaultFolder) {
|
|
return;
|
|
}
|
|
setAssetFolders((currentFolders) =>
|
|
currentFolders.filter((currentFolder) => currentFolder.id !== folder.id),
|
|
);
|
|
setAssets((currentAssets) =>
|
|
currentAssets.map((asset) =>
|
|
asset.folderId === folder.id
|
|
? {
|
|
...asset,
|
|
folderId: defaultFolder.id,
|
|
}
|
|
: asset,
|
|
),
|
|
);
|
|
if (folder.persisted) {
|
|
deleteEditorAssetFolder(folder.id)
|
|
.then((library) => {
|
|
const nextLibrary = normalizeAssetLibrary(library);
|
|
setAssetFolders(nextLibrary.folders);
|
|
setAssets(nextLibrary.assets);
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
};
|
|
|
|
const toggleAssetSelected = (assetId: string) => {
|
|
setSelectedAssetIds((currentIds) => {
|
|
const nextIds = new Set(currentIds);
|
|
if (nextIds.has(assetId)) {
|
|
nextIds.delete(assetId);
|
|
} else {
|
|
nextIds.add(assetId);
|
|
}
|
|
return nextIds;
|
|
});
|
|
};
|
|
|
|
const toggleAllAssetsSelected = () => {
|
|
setSelectedAssetIds(
|
|
allSelectableAssetsSelected
|
|
? new Set()
|
|
: new Set(selectableAssets.map((asset) => asset.id)),
|
|
);
|
|
};
|
|
|
|
const deleteSelectedAssets = () => {
|
|
const ids = [...selectedAssetIds];
|
|
const deletedAssets = assets.filter((asset) =>
|
|
selectedAssetIds.has(asset.id),
|
|
);
|
|
setAssets((currentAssets) =>
|
|
currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)),
|
|
);
|
|
setLayers((currentLayers) =>
|
|
currentLayers.filter(
|
|
(layer) =>
|
|
!deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)),
|
|
),
|
|
);
|
|
setSelectedAssetIds(new Set());
|
|
ids.forEach((assetId) => {
|
|
void deleteEditorAsset(assetId);
|
|
});
|
|
};
|
|
|
|
const moveAssetToFolder = (assetId: string, folderId: string) => {
|
|
const asset = assets.find((currentAsset) => currentAsset.id === assetId);
|
|
if (!asset || asset.folderId === folderId) {
|
|
return;
|
|
}
|
|
setAssets((currentAssets) =>
|
|
currentAssets.map((currentAsset) =>
|
|
currentAsset.id === assetId
|
|
? {
|
|
...currentAsset,
|
|
folderId,
|
|
}
|
|
: currentAsset,
|
|
),
|
|
);
|
|
if (asset.persisted) {
|
|
updateEditorAsset(asset.id, { folderId }).catch(() => {});
|
|
}
|
|
};
|
|
|
|
moveAssetToFolderRef.current = moveAssetToFolder;
|
|
|
|
const closeAssetSelectionMode = () => {
|
|
setIsAssetSelectionMode(false);
|
|
setSelectedAssetIds(new Set());
|
|
setAssetMarquee(null);
|
|
};
|
|
|
|
const updateAssetSelectionFromMarquee = (selectionRect: {
|
|
left: number;
|
|
right: number;
|
|
top: number;
|
|
bottom: number;
|
|
}) => {
|
|
const nextSelectedIds = new Set<string>();
|
|
assetListRef.current
|
|
?.querySelectorAll<HTMLElement>('[data-asset-id]')
|
|
.forEach((element) => {
|
|
const assetId = element.dataset.assetId;
|
|
if (!assetId) {
|
|
return;
|
|
}
|
|
const asset = assets.find(
|
|
(currentAsset) => currentAsset.id === assetId,
|
|
);
|
|
if (!asset || asset.sourceKind !== 'uploaded') {
|
|
return;
|
|
}
|
|
const rect = element.getBoundingClientRect();
|
|
const intersects =
|
|
rect.left <= selectionRect.right &&
|
|
rect.right >= selectionRect.left &&
|
|
rect.top <= selectionRect.bottom &&
|
|
rect.bottom >= selectionRect.top;
|
|
if (intersects) {
|
|
nextSelectedIds.add(assetId);
|
|
}
|
|
});
|
|
setSelectedAssetIds(nextSelectedIds);
|
|
};
|
|
|
|
const handleAssetMarqueePointerDown = (
|
|
event: ReactPointerEvent<HTMLDivElement>,
|
|
) => {
|
|
if (!isAssetSelectionMode || event.button !== 0) {
|
|
return;
|
|
}
|
|
const target = event.target as HTMLElement;
|
|
if (target.closest('button, input, textarea, select, [data-asset-id]')) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
assetListRef.current?.setPointerCapture?.(event.pointerId);
|
|
const rect = assetListRef.current?.getBoundingClientRect();
|
|
const startX = event.clientX - (rect?.left ?? 0);
|
|
const startY = event.clientY - (rect?.top ?? 0);
|
|
setAssetMarquee({
|
|
pointerId: event.pointerId,
|
|
startX,
|
|
startY,
|
|
currentX: startX,
|
|
currentY: startY,
|
|
});
|
|
setSelectedAssetIds(new Set());
|
|
};
|
|
|
|
const handleAssetMarqueePointerMove = (
|
|
event: ReactPointerEvent<HTMLDivElement>,
|
|
) => {
|
|
if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const containerRect = assetListRef.current?.getBoundingClientRect();
|
|
const currentX = event.clientX - (containerRect?.left ?? 0);
|
|
const currentY = event.clientY - (containerRect?.top ?? 0);
|
|
const startClientX = (containerRect?.left ?? 0) + assetMarquee.startX;
|
|
const startClientY = (containerRect?.top ?? 0) + assetMarquee.startY;
|
|
setAssetMarquee((currentMarquee) =>
|
|
currentMarquee
|
|
? {
|
|
...currentMarquee,
|
|
currentX,
|
|
currentY,
|
|
}
|
|
: null,
|
|
);
|
|
updateAssetSelectionFromMarquee({
|
|
left: Math.min(startClientX, event.clientX),
|
|
right: Math.max(startClientX, event.clientX),
|
|
top: Math.min(startClientY, event.clientY),
|
|
bottom: Math.max(startClientY, event.clientY),
|
|
});
|
|
};
|
|
|
|
const handleAssetMarqueePointerUp = (
|
|
event: ReactPointerEvent<HTMLDivElement>,
|
|
) => {
|
|
if (!assetMarquee || assetMarquee.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
if (assetListRef.current?.hasPointerCapture?.(event.pointerId)) {
|
|
assetListRef.current.releasePointerCapture?.(event.pointerId);
|
|
}
|
|
setAssetMarquee(null);
|
|
};
|
|
|
|
const readImageFileAsDataUrl = (file: File) =>
|
|
new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
if (typeof reader.result === 'string') {
|
|
resolve(reader.result);
|
|
return;
|
|
}
|
|
reject(new Error('图片读取失败'));
|
|
};
|
|
reader.onerror = () => reject(reader.error ?? new Error('图片读取失败'));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
const setCharacterGenerationIdle = (dialog: GenerateDialogState) => ({
|
|
...dialog,
|
|
status: dialog.status === 'failed' ? 'idle' : dialog.status,
|
|
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
|
|
});
|
|
|
|
const addCharacterSpecReferenceFiles = async (files: FileList | File[]) => {
|
|
const imageFile = Array.from(files).find(isImageFile);
|
|
if (!imageFile) {
|
|
window.alert('请选择图片文件');
|
|
return;
|
|
}
|
|
|
|
const imageSrc = await readImageFileAsDataUrl(imageFile);
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'character'
|
|
? {
|
|
...setCharacterGenerationIdle(currentDialog),
|
|
characterSpecReference: {
|
|
id: `upload-character-spec-${Date.now()}`,
|
|
label: imageFile.name || '角色形象规范',
|
|
src: imageSrc,
|
|
},
|
|
}
|
|
: currentDialog,
|
|
);
|
|
};
|
|
|
|
const addCharacterReferenceFiles = async (files: FileList | File[]) => {
|
|
const imageFiles = Array.from(files).filter(isImageFile);
|
|
if (!imageFiles.length) {
|
|
window.alert('请选择图片文件');
|
|
return;
|
|
}
|
|
|
|
const references = await Promise.all(
|
|
imageFiles.map(async (file, index) => ({
|
|
id: `upload-character-reference-${Date.now()}-${index}`,
|
|
label: file.name || `参考图${index + 1}`,
|
|
src: await readImageFileAsDataUrl(file),
|
|
})),
|
|
);
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'character'
|
|
? {
|
|
...setCharacterGenerationIdle(currentDialog),
|
|
characterReferences: [
|
|
...(currentDialog.characterReferences ?? []),
|
|
...references,
|
|
],
|
|
}
|
|
: currentDialog,
|
|
);
|
|
};
|
|
|
|
const pickCharacterSpecFromLayer = (layer: CanvasLayer) => {
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'character'
|
|
? {
|
|
...setCharacterGenerationIdle(currentDialog),
|
|
characterSpecReference: createCanvasLayerReference(layer),
|
|
composerOpen: true,
|
|
}
|
|
: currentDialog,
|
|
);
|
|
setIsPickingCharacterSpecFromCanvas(false);
|
|
setIsCharacterSpecMenuOpen(false);
|
|
setImageContextMenu(null);
|
|
};
|
|
|
|
const setIconGenerationIdle = (dialog: GenerateDialogState) => ({
|
|
...dialog,
|
|
status: dialog.status === 'failed' ? 'idle' : dialog.status,
|
|
errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage,
|
|
});
|
|
|
|
const addIconSpecReferenceFiles = async (files: FileList | File[]) => {
|
|
const imageFile = Array.from(files).find(isImageFile);
|
|
if (!imageFile) {
|
|
window.alert('请选择图片文件');
|
|
return;
|
|
}
|
|
|
|
const imageSrc = await readImageFileAsDataUrl(imageFile);
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'icon'
|
|
? {
|
|
...setIconGenerationIdle(currentDialog),
|
|
iconSpecReference: {
|
|
id: `upload-icon-spec-${Date.now()}`,
|
|
label: imageFile.name || '图标素材规范',
|
|
src: imageSrc,
|
|
},
|
|
}
|
|
: currentDialog,
|
|
);
|
|
};
|
|
|
|
const pickIconSpecFromLayer = (layer: CanvasLayer) => {
|
|
if (layer.assetKind !== 'icon-spec') {
|
|
return;
|
|
}
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'icon'
|
|
? {
|
|
...setIconGenerationIdle(currentDialog),
|
|
iconSpecReference: createCanvasLayerReference(layer),
|
|
composerOpen: true,
|
|
}
|
|
: currentDialog,
|
|
);
|
|
setIsPickingIconSpecFromCanvas(false);
|
|
setIsIconSpecMenuOpen(false);
|
|
setImageContextMenu(null);
|
|
};
|
|
|
|
const addUploadedLayer = async (
|
|
file: File,
|
|
options: {
|
|
folderId?: string;
|
|
canvasPoint?: { x: number; y: number };
|
|
uploadIndex?: number;
|
|
addToCanvas?: boolean;
|
|
} = {},
|
|
) => {
|
|
if (!file.type.startsWith('image/')) {
|
|
window.alert('请选择图片文件');
|
|
return;
|
|
}
|
|
|
|
const fallbackWidth = 420;
|
|
const fallbackHeight = 315;
|
|
const uploadFolderId = assetFolders.some(
|
|
(folder) => folder.id === (options.folderId ?? activeUploadFolderId),
|
|
)
|
|
? (options.folderId ?? activeUploadFolderId)
|
|
: 'project';
|
|
const uploadIndex = options.uploadIndex ?? layerCounterRef.current + 1;
|
|
layerCounterRef.current = Math.max(layerCounterRef.current, uploadIndex);
|
|
const uploadedAsset: EditorAsset = {
|
|
id: `upload-${uploadIndex}`,
|
|
label: file.name || '上传图片',
|
|
src: '',
|
|
width: fallbackWidth,
|
|
height: fallbackHeight,
|
|
folderId: uploadFolderId,
|
|
sourceKind: 'uploaded',
|
|
sourceType: 'uploaded',
|
|
persisted: false,
|
|
uploadStatus: 'uploading',
|
|
uploadProgress: 8,
|
|
uploadMessage: '准备上传',
|
|
};
|
|
setAssets((currentAssets) => [...currentAssets, uploadedAsset]);
|
|
setAssetFolders((currentFolders) =>
|
|
currentFolders.map((folder) =>
|
|
folder.id === uploadFolderId
|
|
? {
|
|
...folder,
|
|
collapsed: false,
|
|
}
|
|
: folder,
|
|
),
|
|
);
|
|
|
|
let imageSrc = '';
|
|
try {
|
|
imageSrc = await readImageFileAsDataUrl(file);
|
|
setAssets((currentAssets) =>
|
|
currentAssets.map((asset) =>
|
|
asset.id === uploadedAsset.id
|
|
? {
|
|
...asset,
|
|
src: imageSrc,
|
|
uploadProgress: 42,
|
|
uploadMessage: '读取图片',
|
|
}
|
|
: asset,
|
|
),
|
|
);
|
|
} catch {
|
|
setAssets((currentAssets) =>
|
|
currentAssets.map((asset) =>
|
|
asset.id === uploadedAsset.id
|
|
? {
|
|
...asset,
|
|
uploadStatus: 'failed',
|
|
uploadProgress: 100,
|
|
uploadMessage: '读取失败',
|
|
}
|
|
: asset,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
const screenPoint = options.canvasPoint ?? {
|
|
x: canvasSize.width / 2,
|
|
y: canvasSize.height / 2,
|
|
};
|
|
const fallbackScreenPoint = {
|
|
x: canvasSize.width > 0 ? canvasSize.width / 2 : 640,
|
|
y: canvasSize.height > 0 ? canvasSize.height / 2 : 360,
|
|
};
|
|
const normalizedScreenPoint = {
|
|
x: Number.isFinite(screenPoint.x) ? screenPoint.x : fallbackScreenPoint.x,
|
|
y: Number.isFinite(screenPoint.y) ? screenPoint.y : fallbackScreenPoint.y,
|
|
};
|
|
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
|
|
const worldCenterX = (normalizedScreenPoint.x - viewport.x) / safeScale;
|
|
const worldCenterY = (normalizedScreenPoint.y - viewport.y) / safeScale;
|
|
const nextLayer: CanvasLayer = {
|
|
id: `layer-upload-${uploadIndex}`,
|
|
resourceId: `local-resource-upload-${uploadIndex}`,
|
|
title: file.name || '上传图片',
|
|
src: imageSrc,
|
|
x: worldCenterX - fallbackWidth / 2,
|
|
y: worldCenterY - fallbackHeight / 2,
|
|
width: fallbackWidth,
|
|
height: fallbackHeight,
|
|
originalWidth: fallbackWidth,
|
|
originalHeight: fallbackHeight,
|
|
zIndex: uploadIndex + 10,
|
|
sourceType: 'uploaded',
|
|
sourceAssetId: `upload-${uploadIndex}`,
|
|
};
|
|
|
|
if (options.addToCanvas) {
|
|
appendCanvasLayersWithResources([nextLayer]);
|
|
}
|
|
if (options.addToCanvas) {
|
|
selectSingleLayer(nextLayer.id);
|
|
setActiveSidebarPanel('layers');
|
|
}
|
|
|
|
setAssets((currentAssets) =>
|
|
currentAssets.map((asset) =>
|
|
asset.id === uploadedAsset.id
|
|
? {
|
|
...asset,
|
|
uploadProgress: 68,
|
|
uploadMessage: '上传中',
|
|
}
|
|
: asset,
|
|
),
|
|
);
|
|
createEditorAsset({
|
|
folderId: uploadFolderId,
|
|
label: uploadedAsset.label,
|
|
imageSrc,
|
|
width: fallbackWidth,
|
|
height: fallbackHeight,
|
|
sourceType: 'uploaded',
|
|
})
|
|
.then((asset) => {
|
|
setAssets((currentAssets) =>
|
|
currentAssets.map((currentAsset) =>
|
|
currentAsset.id === uploadedAsset.id
|
|
? {
|
|
...currentAsset,
|
|
id: asset.assetId,
|
|
folderId: asset.folderId,
|
|
label: asset.label,
|
|
src: asset.imageSrc,
|
|
width: asset.width,
|
|
height: asset.height,
|
|
objectKey: asset.objectKey ?? undefined,
|
|
assetObjectId: asset.assetObjectId ?? undefined,
|
|
persisted: true,
|
|
uploadStatus: undefined,
|
|
uploadProgress: undefined,
|
|
uploadMessage: undefined,
|
|
}
|
|
: currentAsset,
|
|
),
|
|
);
|
|
if (options.addToCanvas) {
|
|
setLayers((currentLayers) =>
|
|
currentLayers.map((currentLayer) =>
|
|
currentLayer.id === nextLayer.id
|
|
? {
|
|
...currentLayer,
|
|
sourceAssetId: asset.assetId,
|
|
objectKey: asset.objectKey ?? currentLayer.objectKey,
|
|
assetObjectId:
|
|
asset.assetObjectId ?? currentLayer.assetObjectId,
|
|
}
|
|
: currentLayer,
|
|
),
|
|
);
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
const isAuthError = isEditorAuthError(error);
|
|
if (isAuthError) {
|
|
openEditorLoginModal();
|
|
}
|
|
setAssets((currentAssets) =>
|
|
currentAssets.map((asset) =>
|
|
asset.id === uploadedAsset.id
|
|
? {
|
|
...asset,
|
|
uploadStatus: 'failed',
|
|
uploadProgress: 100,
|
|
uploadMessage: isAuthError ? '请先登录' : '上传失败',
|
|
}
|
|
: asset,
|
|
),
|
|
);
|
|
});
|
|
|
|
if (imageSrc) {
|
|
const uploadedImage = new Image();
|
|
uploadedImage.onload = () => {
|
|
const originalWidth = uploadedImage.naturalWidth || fallbackWidth;
|
|
const originalHeight = uploadedImage.naturalHeight || fallbackHeight;
|
|
const { width, height } = resolveLayerResolutionSize(
|
|
originalWidth,
|
|
originalHeight,
|
|
{ width: fallbackWidth, height: fallbackHeight },
|
|
);
|
|
if (options.addToCanvas) {
|
|
setLayers((currentLayers) =>
|
|
currentLayers.map((layer) =>
|
|
layer.id === nextLayer.id
|
|
? {
|
|
...layer,
|
|
width,
|
|
height,
|
|
originalWidth,
|
|
originalHeight,
|
|
x: worldCenterX - width / 2,
|
|
y: worldCenterY - height / 2,
|
|
}
|
|
: layer,
|
|
),
|
|
);
|
|
}
|
|
setAssets((currentAssets) =>
|
|
currentAssets.map((asset) =>
|
|
asset.id === uploadedAsset.id
|
|
? {
|
|
...asset,
|
|
width: originalWidth,
|
|
height: originalHeight,
|
|
}
|
|
: asset,
|
|
),
|
|
);
|
|
};
|
|
uploadedImage.src = imageSrc;
|
|
}
|
|
};
|
|
|
|
const addUploadedFiles = (
|
|
files: FileList | File[],
|
|
options: {
|
|
folderId?: string;
|
|
canvasPoint?: { x: number; y: number };
|
|
addToCanvas?: boolean;
|
|
} = {},
|
|
) => {
|
|
const imageFiles = Array.from(files);
|
|
const currentAuthUi = authUiRef.current;
|
|
if (currentAuthUi && !currentAuthUi.canAccessProtectedData) {
|
|
openEditorLoginModal(() => {
|
|
addUploadedFiles(imageFiles, options);
|
|
});
|
|
return;
|
|
}
|
|
imageFiles.forEach((file, index) => {
|
|
layerCounterRef.current += 1;
|
|
const uploadIndex = layerCounterRef.current;
|
|
void addUploadedLayer(file, {
|
|
...options,
|
|
addToCanvas: options.addToCanvas ?? false,
|
|
uploadIndex,
|
|
canvasPoint: options.canvasPoint
|
|
? {
|
|
x: options.canvasPoint.x + index * 28,
|
|
y: options.canvasPoint.y + index * 28,
|
|
}
|
|
: undefined,
|
|
});
|
|
});
|
|
};
|
|
|
|
const deleteLayerById = (targetLayerId: string | null) => {
|
|
if (!targetLayerId) {
|
|
return;
|
|
}
|
|
setImageContextMenu(null);
|
|
setContextMenu(null);
|
|
captureCanvasHistory();
|
|
setLayers((currentLayers) => {
|
|
const nextLayers = currentLayers.filter(
|
|
(layer) => layer.id !== targetLayerId,
|
|
);
|
|
const nextSelectedLayer = nextLayers
|
|
.slice()
|
|
.sort((left, right) => right.zIndex - left.zIndex)[0];
|
|
selectSingleLayer(nextSelectedLayer?.id ?? null);
|
|
return nextLayers;
|
|
});
|
|
setHoveredLayerId(null);
|
|
setMetadataLayer((currentLayer) =>
|
|
currentLayer?.id === targetLayerId ? null : currentLayer,
|
|
);
|
|
setQuickEditPanel((currentPanel) =>
|
|
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
|
|
);
|
|
setCharacterAnimationPanel((currentPanel) =>
|
|
currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel,
|
|
);
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'edit' &&
|
|
currentDialog.sourceLayerId === targetLayerId
|
|
? null
|
|
: currentDialog,
|
|
);
|
|
removeCanvasGenerationDialogsByLayerId(targetLayerId);
|
|
};
|
|
|
|
deleteLayerByIdRef.current = deleteLayerById;
|
|
|
|
const deleteSelectedLayer = () => {
|
|
const targetIds = selectedLayerIds.length
|
|
? selectedLayerIds
|
|
: selectedLayerId
|
|
? [selectedLayerId]
|
|
: [];
|
|
if (targetIds.length <= 1) {
|
|
deleteLayerById(targetIds[0] ?? null);
|
|
return;
|
|
}
|
|
captureCanvasHistory();
|
|
setImageContextMenu(null);
|
|
setContextMenu(null);
|
|
setLayers((currentLayers) => {
|
|
const nextLayers = currentLayers.filter(
|
|
(layer) => !targetIds.includes(layer.id),
|
|
);
|
|
const nextSelectedLayer = nextLayers
|
|
.slice()
|
|
.sort((left, right) => right.zIndex - left.zIndex)[0];
|
|
selectSingleLayer(nextSelectedLayer?.id ?? null);
|
|
return nextLayers;
|
|
});
|
|
setHoveredLayerId(null);
|
|
setMetadataLayer((currentLayer) =>
|
|
currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer,
|
|
);
|
|
setQuickEditPanel((currentPanel) =>
|
|
currentPanel && targetIds.includes(currentPanel.sourceLayerId)
|
|
? null
|
|
: currentPanel,
|
|
);
|
|
setCharacterAnimationPanel((currentPanel) =>
|
|
currentPanel && targetIds.includes(currentPanel.sourceLayerId)
|
|
? null
|
|
: currentPanel,
|
|
);
|
|
targetIds.forEach(removeCanvasGenerationDialogsByLayerId);
|
|
};
|
|
|
|
const openGenerateDialog = () => {
|
|
const placeholderWidth = 420;
|
|
const placeholderHeight = 420;
|
|
const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale;
|
|
const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale;
|
|
openCanvasGenerationDialog({
|
|
mode: 'generate',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
placeholder: {
|
|
x: worldCenterX - placeholderWidth / 2,
|
|
y: worldCenterY - placeholderHeight / 2,
|
|
width: placeholderWidth,
|
|
height: placeholderHeight,
|
|
originalWidth: 2048,
|
|
originalHeight: 2048,
|
|
},
|
|
});
|
|
setActiveTool('generate');
|
|
selectSingleLayer(null);
|
|
setQuickEditPanel(null);
|
|
};
|
|
|
|
const openSpecDialog = (specType: SpecGenerationType) => {
|
|
const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale;
|
|
const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale;
|
|
openCanvasGenerationDialog({
|
|
mode: 'spec',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
specType,
|
|
specValues: { ...DEFAULT_SPEC_FORM_VALUES[specType] },
|
|
placeholder: {
|
|
x: worldCenterX - SPEC_FRAME_DISPLAY_SIZE.width / 2,
|
|
y: worldCenterY - SPEC_FRAME_DISPLAY_SIZE.height / 2,
|
|
width: SPEC_FRAME_DISPLAY_SIZE.width,
|
|
height: SPEC_FRAME_DISPLAY_SIZE.height,
|
|
originalWidth: SPEC_FRAME_ORIGINAL_SIZE.width,
|
|
originalHeight: SPEC_FRAME_ORIGINAL_SIZE.height,
|
|
},
|
|
});
|
|
setIsSpecMenuOpen(false);
|
|
setActiveTool('generate');
|
|
selectSingleLayer(null);
|
|
setQuickEditPanel(null);
|
|
};
|
|
|
|
const openCharacterAnimationPanel = (layer: CanvasLayer) => {
|
|
if (layer.assetKind !== 'character') {
|
|
return;
|
|
}
|
|
setImageContextMenu(null);
|
|
setQuickEditPanel(null);
|
|
setCharacterAnimationPanel({
|
|
sourceLayerId: layer.id,
|
|
promptText: '',
|
|
resolution: '480p',
|
|
ratio: 'same',
|
|
frameCount: 32,
|
|
durationSeconds: 4,
|
|
status: 'idle',
|
|
});
|
|
selectSingleLayer(layer.id);
|
|
};
|
|
|
|
const openCharacterGenerationDialog = () => {
|
|
const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale;
|
|
const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale;
|
|
setIsSpecMenuOpen(false);
|
|
setIsPickingCharacterSpecFromCanvas(false);
|
|
openCanvasGenerationDialog({
|
|
mode: 'character',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
characterSpecReference: null,
|
|
characterReferences: [],
|
|
placeholder: {
|
|
x: worldCenterX - CHARACTER_FRAME_DISPLAY_SIZE.width / 2,
|
|
y: worldCenterY - CHARACTER_FRAME_DISPLAY_SIZE.height / 2,
|
|
width: CHARACTER_FRAME_DISPLAY_SIZE.width,
|
|
height: CHARACTER_FRAME_DISPLAY_SIZE.height,
|
|
originalWidth: CHARACTER_FRAME_ORIGINAL_SIZE.width,
|
|
originalHeight: CHARACTER_FRAME_ORIGINAL_SIZE.height,
|
|
},
|
|
});
|
|
setActiveTool('character');
|
|
selectSingleLayer(null);
|
|
setQuickEditPanel(null);
|
|
};
|
|
|
|
const openIconGenerationDialog = () => {
|
|
const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale;
|
|
const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale;
|
|
setIsSpecMenuOpen(false);
|
|
setIsPickingCharacterSpecFromCanvas(false);
|
|
setIsPickingIconSpecFromCanvas(false);
|
|
openCanvasGenerationDialog({
|
|
mode: 'icon',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
iconSpecReference: null,
|
|
iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS],
|
|
placeholder: {
|
|
x: worldCenterX - ICON_FRAME_DISPLAY_SIZE.width / 2,
|
|
y: worldCenterY - ICON_FRAME_DISPLAY_SIZE.height / 2,
|
|
width: ICON_FRAME_DISPLAY_SIZE.width,
|
|
height: ICON_FRAME_DISPLAY_SIZE.height,
|
|
originalWidth: ICON_FRAME_ORIGINAL_SIZE.width,
|
|
originalHeight: ICON_FRAME_ORIGINAL_SIZE.height,
|
|
},
|
|
});
|
|
setActiveTool('icon');
|
|
selectSingleLayer(null);
|
|
setQuickEditPanel(null);
|
|
setCharacterAnimationPanel(null);
|
|
};
|
|
|
|
const openEditDialog = (sourceLayer: CanvasLayer) => {
|
|
setMetadataLayer(null);
|
|
setImageContextMenu(null);
|
|
setQuickEditPanel(null);
|
|
setGenerateDialog({
|
|
mode: 'edit',
|
|
prompt: sourceLayer.prompt
|
|
? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节`
|
|
: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
sourceLayerId: sourceLayer.id,
|
|
});
|
|
setActiveTool('generate');
|
|
};
|
|
|
|
const openQuickEditPanel = (sourceLayer: CanvasLayer) => {
|
|
setImageContextMenu(null);
|
|
setMetadataLayer(null);
|
|
setGenerateDialog(null);
|
|
setCharacterAnimationPanel(null);
|
|
setQuickEditPanel({
|
|
sourceLayerId: sourceLayer.id,
|
|
prompt: '',
|
|
size: formatImageSizeValue(
|
|
sourceLayer.originalWidth,
|
|
sourceLayer.originalHeight,
|
|
),
|
|
model: sourceLayer.model?.trim() || DEFAULT_IMAGE_MODEL,
|
|
status: 'idle',
|
|
});
|
|
selectSingleLayer(sourceLayer.id);
|
|
setActiveTool('generate');
|
|
};
|
|
|
|
const addGeneratedResultLayer = (
|
|
generated: EditorImageGenerationResult,
|
|
options: {
|
|
sourceLayer?: CanvasLayer;
|
|
frame?: GenerateDialogState['placeholder'];
|
|
assetKind?: CanvasLayer['assetKind'];
|
|
title?: string;
|
|
dialogId?: string;
|
|
generationInputs?: CanvasGenerationInputs;
|
|
} = {},
|
|
) => {
|
|
layerCounterRef.current += 1;
|
|
const generatedIndex = layerCounterRef.current;
|
|
const originalWidth = generated.width || 1024;
|
|
const originalHeight = generated.height || 1024;
|
|
const { width, height } = resolveLayerResolutionSize(
|
|
originalWidth,
|
|
originalHeight,
|
|
{ width: 1024, height: 1024 },
|
|
);
|
|
const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale;
|
|
const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale;
|
|
const frameX =
|
|
options.frame && options.frame.width > 0
|
|
? options.frame.x + options.frame.width / 2 - width / 2
|
|
: undefined;
|
|
const frameY =
|
|
options.frame && options.frame.height > 0
|
|
? options.frame.y + options.frame.height / 2 - height / 2
|
|
: undefined;
|
|
const nextLayer: CanvasLayer = {
|
|
id: options.sourceLayer
|
|
? `layer-edit-${generatedIndex}`
|
|
: `layer-generated-${generatedIndex}`,
|
|
resourceId: options.sourceLayer
|
|
? `local-resource-edit-${generatedIndex}`
|
|
: `local-resource-generated-${generatedIndex}`,
|
|
title: options.sourceLayer
|
|
? `${options.sourceLayer.title} 修改结果`
|
|
: (options.title ?? `生成图片 ${generatedIndex}`),
|
|
src: generated.imageSrc,
|
|
x: options.sourceLayer
|
|
? options.sourceLayer.x + options.sourceLayer.width + 32
|
|
: (frameX ?? worldCenterX - width / 2),
|
|
y: options.sourceLayer
|
|
? options.sourceLayer.y
|
|
: (frameY ?? worldCenterY - height / 2),
|
|
width,
|
|
height,
|
|
originalWidth,
|
|
originalHeight,
|
|
zIndex: generatedIndex + 10,
|
|
sourceType: generated.sourceType,
|
|
assetKind: options.assetKind,
|
|
prompt: generated.prompt,
|
|
actualPrompt: generated.actualPrompt ?? generated.prompt,
|
|
model: generated.model,
|
|
provider: generated.provider,
|
|
taskId: generated.taskId,
|
|
objectKey: generated.objectKey,
|
|
assetObjectId: generated.assetObjectId,
|
|
sourceResourceId: options.sourceLayer?.resourceId,
|
|
generationInputs: options.generationInputs,
|
|
};
|
|
|
|
appendCanvasLayersWithResources([nextLayer]);
|
|
selectSingleLayer(nextLayer.id);
|
|
setActiveSidebarPanel('layers');
|
|
if (options.sourceLayer) {
|
|
setGenerateDialog(null);
|
|
setActiveTool('select');
|
|
} else if (options.dialogId) {
|
|
updateCanvasGenerationDialogById(options.dialogId, (currentDialog) =>
|
|
currentDialog.mode === 'character' || currentDialog.mode === 'icon'
|
|
? null
|
|
: {
|
|
...currentDialog,
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generatedLayerId: nextLayer.id,
|
|
placeholder: undefined,
|
|
errorMessage: undefined,
|
|
},
|
|
);
|
|
}
|
|
if (options.sourceLayer) {
|
|
fitLayers([options.sourceLayer, nextLayer]);
|
|
}
|
|
};
|
|
|
|
const addQuickEditResultLayer = (
|
|
generated: EditorImageGenerationResult,
|
|
sourceLayer: CanvasLayer,
|
|
generationInputs: CanvasGenerationInputs,
|
|
) => {
|
|
layerCounterRef.current += 1;
|
|
const generatedIndex = layerCounterRef.current;
|
|
const originalWidth = generated.width || sourceLayer.originalWidth || 1024;
|
|
const originalHeight =
|
|
generated.height || sourceLayer.originalHeight || 1024;
|
|
const { width, height } = resolveLayerResolutionSize(
|
|
originalWidth,
|
|
originalHeight,
|
|
{
|
|
width: sourceLayer.width,
|
|
height: sourceLayer.height,
|
|
},
|
|
);
|
|
const nextLayer: CanvasLayer = {
|
|
id: `layer-quick-edit-${generatedIndex}`,
|
|
resourceId: `local-resource-quick-edit-${generatedIndex}`,
|
|
title: `${sourceLayer.title} 快速编辑`,
|
|
src: generated.imageSrc,
|
|
x: sourceLayer.x + sourceLayer.width + 32,
|
|
y: sourceLayer.y,
|
|
width,
|
|
height,
|
|
originalWidth,
|
|
originalHeight,
|
|
zIndex: generatedIndex + 10,
|
|
sourceType: generated.sourceType,
|
|
prompt: generated.prompt,
|
|
actualPrompt: generated.actualPrompt ?? generated.prompt,
|
|
model: generated.model,
|
|
provider: generated.provider,
|
|
taskId: generated.taskId,
|
|
objectKey: generated.objectKey,
|
|
assetObjectId: generated.assetObjectId,
|
|
sourceResourceId: sourceLayer.resourceId,
|
|
groupId: sourceLayer.groupId,
|
|
assetKind: sourceLayer.assetKind,
|
|
generationInputs,
|
|
};
|
|
|
|
appendCanvasLayersWithResources([nextLayer]);
|
|
selectSingleLayer(nextLayer.id);
|
|
setActiveSidebarPanel('layers');
|
|
setQuickEditPanel(null);
|
|
setActiveTool('select');
|
|
fitLayers([sourceLayer, nextLayer]);
|
|
};
|
|
|
|
const addIconSpritesheetResultLayers = (
|
|
generated: EditorIconSpritesheetGenerationResult,
|
|
iconResults: EditorIconSpritesheetIconResult[],
|
|
generationInputs: CanvasGenerationInputs,
|
|
frame?: GenerateDialogState['placeholder'],
|
|
dialogId?: string,
|
|
) => {
|
|
const startX =
|
|
frame?.x ??
|
|
(canvasSize.width / 2 - viewport.x) / viewport.scale -
|
|
ICON_FRAME_DISPLAY_SIZE.width / 2;
|
|
const startY =
|
|
frame?.y ??
|
|
(canvasSize.height / 2 - viewport.y) / viewport.scale -
|
|
ICON_FRAME_DISPLAY_SIZE.height / 2;
|
|
const spacing = 24;
|
|
const maxRowWidth = 560;
|
|
let cursorX = startX;
|
|
let cursorY = startY;
|
|
let rowHeight = 0;
|
|
const nextLayers: CanvasLayer[] = [];
|
|
|
|
iconResults.forEach((icon) => {
|
|
const originalWidth = icon.width || 128;
|
|
const originalHeight = icon.height || 128;
|
|
const { width, height } = resolveLayerResolutionSize(
|
|
originalWidth,
|
|
originalHeight,
|
|
{ width: 128, height: 128 },
|
|
);
|
|
if (cursorX > startX && cursorX + width - startX > maxRowWidth) {
|
|
cursorX = startX;
|
|
cursorY += rowHeight + spacing;
|
|
rowHeight = 0;
|
|
}
|
|
|
|
layerCounterRef.current += 1;
|
|
const generatedIndex = layerCounterRef.current;
|
|
nextLayers.push({
|
|
id: `layer-icon-${generatedIndex}`,
|
|
resourceId: `local-resource-icon-${generatedIndex}`,
|
|
title: icon.name,
|
|
src: icon.imageSrc,
|
|
x: cursorX,
|
|
y: cursorY,
|
|
width,
|
|
height,
|
|
originalWidth,
|
|
originalHeight,
|
|
zIndex: generatedIndex + 10,
|
|
sourceType: 'generated',
|
|
prompt: generated.prompt,
|
|
actualPrompt: generated.actualPrompt ?? generated.prompt,
|
|
model: generated.model,
|
|
provider: generated.provider,
|
|
taskId: generated.taskId,
|
|
assetKind: 'icon',
|
|
generationInputs,
|
|
});
|
|
|
|
cursorX += width + spacing;
|
|
rowHeight = Math.max(rowHeight, height);
|
|
});
|
|
|
|
if (!nextLayers.length) {
|
|
return;
|
|
}
|
|
appendCanvasLayersWithResources(nextLayers);
|
|
selectSingleLayer(nextLayers[0]?.id ?? null);
|
|
setActiveSidebarPanel('layers');
|
|
if (dialogId) {
|
|
removeCanvasGenerationDialogById(dialogId);
|
|
}
|
|
setActiveTool('select');
|
|
};
|
|
|
|
const updateIconDescription = (index: number, value: string) => {
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'icon'
|
|
? {
|
|
...setIconGenerationIdle(currentDialog),
|
|
iconDescriptions: (
|
|
currentDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS
|
|
).map((description, descriptionIndex) =>
|
|
descriptionIndex === index ? value : description,
|
|
),
|
|
}
|
|
: currentDialog,
|
|
);
|
|
};
|
|
|
|
const addIconDescription = () => {
|
|
setGenerateDialog((currentDialog) => {
|
|
if (currentDialog?.mode !== 'icon') {
|
|
return currentDialog;
|
|
}
|
|
const descriptions =
|
|
currentDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS;
|
|
if (descriptions.length >= ICON_DESCRIPTION_LIMIT) {
|
|
return currentDialog;
|
|
}
|
|
return {
|
|
...setIconGenerationIdle(currentDialog),
|
|
iconDescriptions: [...descriptions, ''],
|
|
};
|
|
});
|
|
};
|
|
|
|
const submitIconSpritesheetGeneration = async (
|
|
dialog: GenerateDialogState,
|
|
) => {
|
|
if (dialog.mode !== 'icon') {
|
|
return;
|
|
}
|
|
const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null;
|
|
const setSubmittingIconDialog = (
|
|
nextDialog: CanvasGenerationDialogState,
|
|
) => {
|
|
updateCanvasGenerationDialogById(nextDialog.id, () => nextDialog);
|
|
};
|
|
const iconDescriptions = (
|
|
dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS
|
|
)
|
|
.map((description) => description.trim())
|
|
.filter(Boolean);
|
|
if (!dialog.iconSpecReference) {
|
|
if (canvasDialog) {
|
|
setSubmittingIconDialog({
|
|
...canvasDialog,
|
|
status: 'failed',
|
|
composerOpen: true,
|
|
errorMessage: '请选择图标素材规范',
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (!iconDescriptions.length) {
|
|
if (canvasDialog) {
|
|
setSubmittingIconDialog({
|
|
...canvasDialog,
|
|
status: 'failed',
|
|
composerOpen: true,
|
|
errorMessage: '请填写素材描述',
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!canvasDialog) {
|
|
return;
|
|
}
|
|
|
|
setSubmittingIconDialog({
|
|
...canvasDialog,
|
|
iconDescriptions,
|
|
status: 'generating',
|
|
composerOpen: false,
|
|
errorMessage: undefined,
|
|
});
|
|
|
|
try {
|
|
const generated = await generateEditorIconSpritesheet({
|
|
referenceImageSrc: dialog.iconSpecReference.src,
|
|
iconDescriptions,
|
|
});
|
|
addIconSpritesheetResultLayers(
|
|
generated,
|
|
generated.iconImageSrcs,
|
|
buildIconGenerationInputs(iconDescriptions, dialog.iconSpecReference),
|
|
getGeneratingDialogPlaceholder(dialog),
|
|
canvasDialog.id,
|
|
);
|
|
} catch (error) {
|
|
setSubmittingIconDialog({
|
|
...canvasDialog,
|
|
iconDescriptions,
|
|
status: 'failed',
|
|
composerOpen: true,
|
|
errorMessage: resolveImageGenerationErrorMessage(error),
|
|
});
|
|
}
|
|
};
|
|
|
|
const submitQuickEdit = async () => {
|
|
if (!quickEditPanel || !quickEditSourceLayer) {
|
|
return;
|
|
}
|
|
|
|
const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片';
|
|
setQuickEditPanel({
|
|
...quickEditPanel,
|
|
prompt: normalizedPrompt,
|
|
status: 'generating',
|
|
errorMessage: undefined,
|
|
});
|
|
|
|
try {
|
|
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
|
|
quickEditSourceLayer.src,
|
|
);
|
|
const generated = await generateEditorImage({
|
|
prompt: normalizedPrompt,
|
|
size: quickEditPanel.size,
|
|
kind: 'quick-edit',
|
|
model: quickEditPanel.model,
|
|
referenceImageSrcs: [referenceImageSrc],
|
|
});
|
|
addQuickEditResultLayer(
|
|
generated,
|
|
quickEditSourceLayer,
|
|
buildEditGenerationInputs(
|
|
'快速编辑提示词',
|
|
normalizedPrompt,
|
|
quickEditSourceLayer,
|
|
),
|
|
);
|
|
} catch (error) {
|
|
setQuickEditPanel({
|
|
...quickEditPanel,
|
|
prompt: normalizedPrompt,
|
|
status: 'failed',
|
|
errorMessage: resolveImageGenerationErrorMessage(error),
|
|
});
|
|
}
|
|
};
|
|
|
|
const submitImageGeneration = async (dialog: GenerateDialogState) => {
|
|
const normalizedPrompt =
|
|
dialog.prompt.trim() ||
|
|
(dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片');
|
|
const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null;
|
|
if (canvasDialog) {
|
|
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({
|
|
...currentDialog,
|
|
prompt: normalizedPrompt,
|
|
status: 'generating',
|
|
composerOpen: false,
|
|
}));
|
|
} else {
|
|
setGenerateDialog({
|
|
...dialog,
|
|
prompt: normalizedPrompt,
|
|
status: 'generating',
|
|
composerOpen: dialog.mode === 'edit',
|
|
});
|
|
}
|
|
|
|
try {
|
|
if (dialog.mode === 'edit') {
|
|
const sourceLayer = layers.find(
|
|
(layer) => layer.id === dialog.sourceLayerId,
|
|
);
|
|
if (!sourceLayer) {
|
|
throw new Error('未找到要修改的图片');
|
|
}
|
|
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
|
|
sourceLayer.src,
|
|
);
|
|
const generated = await editEditorImage({
|
|
prompt: normalizedPrompt,
|
|
sourceImageSrc: referenceImageSrc,
|
|
});
|
|
addGeneratedResultLayer(generated, {
|
|
sourceLayer,
|
|
generationInputs: buildEditGenerationInputs(
|
|
'修改要求',
|
|
normalizedPrompt,
|
|
sourceLayer,
|
|
),
|
|
});
|
|
} else if (dialog.mode === 'spec') {
|
|
const specType = dialog.specType ?? 'custom';
|
|
const specValues =
|
|
dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType];
|
|
const specPrompt = buildSpecPrompt(specType, specValues);
|
|
const generated = await generateEditorImage({
|
|
prompt: specPrompt,
|
|
size: SPEC_GENERATION_SIZE,
|
|
model: DEFAULT_IMAGE_MODEL,
|
|
kind: 'spec',
|
|
});
|
|
addGeneratedResultLayer(generated, {
|
|
frame: getGeneratingDialogPlaceholder(dialog),
|
|
assetKind: specType === 'icon' ? 'icon-spec' : 'spec',
|
|
title: `${SPEC_TYPE_LABEL[specType]} ${layerCounterRef.current + 1}`,
|
|
dialogId: canvasDialog?.id,
|
|
generationInputs: buildSpecGenerationInputs(specType, specValues),
|
|
});
|
|
} else if (dialog.mode === 'character') {
|
|
const referenceImageSrcs = [
|
|
dialog.characterSpecReference?.src,
|
|
...(dialog.characterReferences ?? []).map(
|
|
(reference) => reference.src,
|
|
),
|
|
].filter((src): src is string => Boolean(src));
|
|
const generated = await generateEditorImage({
|
|
prompt: normalizedPrompt,
|
|
kind: 'character',
|
|
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
|
|
});
|
|
addGeneratedResultLayer(generated, {
|
|
frame: getGeneratingDialogPlaceholder(dialog),
|
|
assetKind: 'character',
|
|
title: `角色形象 ${layerCounterRef.current + 1}`,
|
|
dialogId: canvasDialog?.id,
|
|
generationInputs: buildCharacterGenerationInputs(
|
|
normalizedPrompt,
|
|
dialog.characterSpecReference,
|
|
dialog.characterReferences,
|
|
),
|
|
});
|
|
} else {
|
|
const generated = await generateEditorImage({
|
|
prompt: normalizedPrompt,
|
|
});
|
|
addGeneratedResultLayer(generated, {
|
|
frame: getGeneratingDialogPlaceholder(dialog),
|
|
dialogId: canvasDialog?.id,
|
|
generationInputs: buildImageGenerationInputs(normalizedPrompt),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (canvasDialog) {
|
|
updateCanvasGenerationDialogById(canvasDialog.id, () => ({
|
|
...canvasDialog,
|
|
prompt: normalizedPrompt,
|
|
status: 'failed',
|
|
composerOpen: true,
|
|
errorMessage: resolveImageGenerationErrorMessage(error),
|
|
}));
|
|
} else {
|
|
setGenerateDialog({
|
|
...dialog,
|
|
prompt: normalizedPrompt,
|
|
status: 'failed',
|
|
composerOpen: true,
|
|
errorMessage: resolveImageGenerationErrorMessage(error),
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleNativeWheel = useCallback((event: WheelEvent) => {
|
|
event.preventDefault();
|
|
const viewportElement = canvasViewportRef.current;
|
|
if (!viewportElement) {
|
|
return;
|
|
}
|
|
|
|
if (!event.ctrlKey && !event.metaKey) {
|
|
setViewport((currentViewport) =>
|
|
scrollViewportVertically(currentViewport, event.deltaY),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const rect = viewportElement.getBoundingClientRect();
|
|
const screenPoint = {
|
|
x: event.clientX - rect.left,
|
|
y: event.clientY - rect.top,
|
|
};
|
|
setViewport((currentViewport) =>
|
|
zoomViewportFromWheel({
|
|
viewport: currentViewport,
|
|
deltaY: event.deltaY,
|
|
screenPoint,
|
|
}),
|
|
);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const viewportElement = canvasViewportRef.current;
|
|
if (!viewportElement) {
|
|
return undefined;
|
|
}
|
|
viewportElement.addEventListener('wheel', handleNativeWheel, {
|
|
passive: false,
|
|
});
|
|
return () => {
|
|
viewportElement.removeEventListener('wheel', handleNativeWheel);
|
|
};
|
|
}, [handleNativeWheel]);
|
|
|
|
const startPan = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
const pointer = getPointerClient(event);
|
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
|
setIsPanning(true);
|
|
dragStateRef.current = {
|
|
kind: 'pan',
|
|
pointerId: getPointerId(event),
|
|
startClientX: pointer.x,
|
|
startClientY: pointer.y,
|
|
startViewport: viewport,
|
|
};
|
|
};
|
|
|
|
const handleCanvasPointerDown = (
|
|
event: ReactPointerEvent<HTMLDivElement>,
|
|
) => {
|
|
const button = getPointerButton(event);
|
|
if (button !== 0 || effectiveTool === 'hand') {
|
|
startPan(event);
|
|
return;
|
|
}
|
|
|
|
if (button !== 0) {
|
|
return;
|
|
}
|
|
const target = event.target as HTMLElement;
|
|
if (
|
|
effectiveTool === 'select' &&
|
|
(event.target === event.currentTarget ||
|
|
target.classList.contains('image-canvas-editor__world'))
|
|
) {
|
|
event.preventDefault();
|
|
const rect = canvasViewportRef.current?.getBoundingClientRect();
|
|
const startX = event.clientX - (rect?.left ?? 0);
|
|
const startY = event.clientY - (rect?.top ?? 0);
|
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
|
setCanvasMarquee({
|
|
pointerId: event.pointerId,
|
|
startX,
|
|
startY,
|
|
currentX: startX,
|
|
currentY: startY,
|
|
});
|
|
clearCanvasFocus();
|
|
return;
|
|
}
|
|
clearCanvasFocus();
|
|
};
|
|
|
|
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));
|
|
return;
|
|
}
|
|
const files = event.dataTransfer.files;
|
|
if (!files.length) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
setUploadDropTarget(null);
|
|
updateAssetMoveDropFolder(null);
|
|
const canvasPoint = getCanvasDropPoint(event);
|
|
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 handleLayerPointerDown = (
|
|
event: ReactPointerEvent<HTMLButtonElement>,
|
|
layer: CanvasLayer,
|
|
) => {
|
|
const button = getPointerButton(event);
|
|
if (button === 1 || effectiveTool === 'hand') {
|
|
event.stopPropagation();
|
|
startPan(event as unknown as ReactPointerEvent<HTMLDivElement>);
|
|
return;
|
|
}
|
|
if (button !== 0) {
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
if (
|
|
isPickingCharacterSpecFromCanvas &&
|
|
generateDialog?.mode === 'character'
|
|
) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
pickCharacterSpecFromLayer(layer);
|
|
return;
|
|
}
|
|
if (isPickingIconSpecFromCanvas && generateDialog?.mode === 'icon') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
pickIconSpecFromLayer(layer);
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const pointer = getPointerClient(event);
|
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
|
const isMultiSelectGesture = event.shiftKey || isShiftPressedRef.current;
|
|
const nextSelectedIds = isMultiSelectGesture
|
|
? selectedLayerIds.includes(layer.id)
|
|
? selectedLayerIds.length > 1
|
|
? selectedLayerIds.filter((layerId) => layerId !== layer.id)
|
|
: [layer.id]
|
|
: [...selectedLayerIds, layer.id]
|
|
: [layer.id];
|
|
setSelectedLayerId(layer.id);
|
|
setSelectedLayerIds(nextSelectedIds);
|
|
setGenerateDialog((currentDialog) => {
|
|
if (
|
|
currentDialog?.mode !== 'generate' &&
|
|
currentDialog?.mode !== 'spec' &&
|
|
currentDialog?.mode !== 'character' &&
|
|
currentDialog?.mode !== 'icon'
|
|
) {
|
|
return currentDialog;
|
|
}
|
|
if (currentDialog.generatedLayerId === layer.id) {
|
|
return {
|
|
...currentDialog,
|
|
composerOpen: true,
|
|
};
|
|
}
|
|
return {
|
|
...currentDialog,
|
|
composerOpen: false,
|
|
};
|
|
});
|
|
const dragLayerIds = nextSelectedIds.includes(layer.id)
|
|
? nextSelectedIds
|
|
: [layer.id];
|
|
const startLayers = layers
|
|
.filter((currentLayer) => dragLayerIds.includes(currentLayer.id))
|
|
.map((currentLayer) => ({
|
|
id: currentLayer.id,
|
|
x: currentLayer.x,
|
|
y: currentLayer.y,
|
|
}));
|
|
dragStateRef.current = {
|
|
kind: 'layer',
|
|
pointerId: getPointerId(event),
|
|
layerId: layer.id,
|
|
layerIds: dragLayerIds,
|
|
startClientX: pointer.x,
|
|
startClientY: pointer.y,
|
|
startLayerX: layer.x,
|
|
startLayerY: layer.y,
|
|
startLayers,
|
|
startScale: viewport.scale,
|
|
};
|
|
};
|
|
|
|
const handleLayerClick = (
|
|
event: ReactMouseEvent<HTMLButtonElement>,
|
|
layer: CanvasLayer,
|
|
) => {
|
|
// 测试环境和辅助技术可能只触发 click;
|
|
// 用 click 兜底选中,真实拖拽仍由 pointerDown 负责。
|
|
event.stopPropagation();
|
|
if (isPickingCharacterSpecFromCanvas) {
|
|
return;
|
|
}
|
|
if (isPickingIconSpecFromCanvas) {
|
|
return;
|
|
}
|
|
if (event.shiftKey || isShiftPressedRef.current) {
|
|
return;
|
|
}
|
|
selectSingleLayer(layer.id);
|
|
setImageContextMenu(null);
|
|
};
|
|
|
|
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 handleCanvasBackgroundHexChange = (nextValue: string) => {
|
|
setCanvasBackgroundHexValue(nextValue);
|
|
const normalizedColor = normalizeCanvasBackgroundHex(nextValue);
|
|
if (normalizedColor) {
|
|
setCanvasBackgroundColor(normalizedColor);
|
|
setCanvasBackgroundHexValue(normalizedColor);
|
|
}
|
|
};
|
|
|
|
const handleGenerationFramePointerDown = (
|
|
event: ReactPointerEvent<HTMLDivElement>,
|
|
dialog: CanvasGenerationDialogState,
|
|
) => {
|
|
if (!dialog.placeholder) {
|
|
return;
|
|
}
|
|
const button = getPointerButton(event);
|
|
if (button === 1 || effectiveTool === 'hand') {
|
|
event.stopPropagation();
|
|
startPan(event as unknown as ReactPointerEvent<HTMLDivElement>);
|
|
return;
|
|
}
|
|
if (button !== 0) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const pointer = getPointerClient(event);
|
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
|
activateCanvasGenerationDialog(dialog);
|
|
dragStateRef.current = {
|
|
kind: 'generation-frame',
|
|
dialogId: dialog.id,
|
|
pointerId: getPointerId(event),
|
|
startClientX: pointer.x,
|
|
startClientY: pointer.y,
|
|
startFrameX: dialog.placeholder.x,
|
|
startFrameY: dialog.placeholder.y,
|
|
startScale: viewport.scale,
|
|
};
|
|
};
|
|
|
|
const moveViewportFromMinimapPointer = (clientX: number, clientY: number) => {
|
|
if (!minimapModel) {
|
|
return;
|
|
}
|
|
const minimapElement = document.querySelector(
|
|
'.image-canvas-editor__minimap',
|
|
) as HTMLElement | null;
|
|
const rect = minimapElement?.getBoundingClientRect();
|
|
if (!rect) {
|
|
return;
|
|
}
|
|
setViewport((currentViewport) =>
|
|
resolveViewportFromMinimapPointer({
|
|
viewport: currentViewport,
|
|
canvasSize,
|
|
minimapModel,
|
|
pointer: {
|
|
x: clientX - rect.left,
|
|
y: clientY - rect.top,
|
|
},
|
|
}),
|
|
);
|
|
};
|
|
|
|
const updateViewportFromMinimapDrag = (
|
|
dragState: Extract<DragState, { kind: 'minimap' }>,
|
|
clientX: number,
|
|
clientY: number,
|
|
) => {
|
|
setViewport(
|
|
resolveViewportFromMinimapDrag(dragState, {
|
|
x: clientX,
|
|
y: clientY,
|
|
}),
|
|
);
|
|
};
|
|
|
|
const handleMinimapPointerDown = (
|
|
event: ReactPointerEvent<HTMLButtonElement>,
|
|
) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const pointer = getPointerClient(event);
|
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
|
dragStateRef.current = {
|
|
kind: 'minimap',
|
|
pointerId: getPointerId(event),
|
|
startClientX: pointer.x,
|
|
startClientY: pointer.y,
|
|
startViewport: { ...viewport },
|
|
minimapScale: minimapModel?.scale ?? 1,
|
|
moved: false,
|
|
};
|
|
};
|
|
|
|
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) {
|
|
event.preventDefault();
|
|
const rect = canvasViewportRef.current?.getBoundingClientRect();
|
|
const currentX = event.clientX - (rect?.left ?? 0);
|
|
const currentY = event.clientY - (rect?.top ?? 0);
|
|
setCanvasMarquee((currentMarquee) =>
|
|
currentMarquee
|
|
? {
|
|
...currentMarquee,
|
|
currentX,
|
|
currentY,
|
|
}
|
|
: null,
|
|
);
|
|
const selectedIds = selectLayersInsideMarquee({
|
|
marquee: canvasMarquee,
|
|
currentPoint: { x: currentX, y: currentY },
|
|
layers,
|
|
viewport,
|
|
});
|
|
setSelectedLayerIds(selectedIds);
|
|
setSelectedLayerId(selectedIds[0] ?? null);
|
|
return;
|
|
}
|
|
|
|
const dragState = dragStateRef.current;
|
|
const pointerId = getPointerId(event);
|
|
if (
|
|
!dragState ||
|
|
(dragState.pointerId >= 0 &&
|
|
pointerId >= 0 &&
|
|
dragState.pointerId !== pointerId)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (dragState.kind === 'pan') {
|
|
const pointer = getPointerClient(event);
|
|
setViewport(moveViewportFromPan(dragState, pointer));
|
|
return;
|
|
}
|
|
|
|
if (dragState.kind === 'generation-frame') {
|
|
const pointer = getPointerClient(event);
|
|
const nextFramePoint = moveGenerationFrameFromDrag(dragState, pointer);
|
|
updateCanvasGenerationDialogById(dragState.dialogId, (currentDialog) =>
|
|
currentDialog.placeholder
|
|
? {
|
|
...currentDialog,
|
|
placeholder: {
|
|
...currentDialog.placeholder,
|
|
x: nextFramePoint.x,
|
|
y: nextFramePoint.y,
|
|
},
|
|
}
|
|
: currentDialog,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (dragState.kind === 'minimap') {
|
|
const pointer = getPointerClient(event);
|
|
const deltaX = pointer.x - dragState.startClientX;
|
|
const deltaY = pointer.y - dragState.startClientY;
|
|
if (!dragState.moved && Math.hypot(deltaX, deltaY) >= 2) {
|
|
dragState.moved = true;
|
|
}
|
|
if (dragState.moved) {
|
|
updateViewportFromMinimapDrag(dragState, pointer.x, pointer.y);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const pointer = getPointerClient(event);
|
|
const movedLayers = moveLayersFromDrag({ dragState, layers, pointer });
|
|
if (!movedLayers) {
|
|
return;
|
|
}
|
|
setSnapGuide(movedLayers.snapGuide);
|
|
setLayers(movedLayers.layers);
|
|
};
|
|
|
|
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) {
|
|
event.preventDefault();
|
|
setCanvasMarquee(null);
|
|
if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) {
|
|
canvasViewportRef.current.releasePointerCapture?.(event.pointerId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const dragState = dragStateRef.current;
|
|
const pointerId = getPointerId(event);
|
|
if (
|
|
dragState &&
|
|
(dragState.pointerId < 0 ||
|
|
pointerId < 0 ||
|
|
dragState.pointerId === pointerId)
|
|
) {
|
|
if (dragState.kind === 'minimap' && !dragState.moved) {
|
|
const pointer = getPointerClient(event);
|
|
moveViewportFromMinimapPointer(pointer.x, pointer.y);
|
|
}
|
|
dragStateRef.current = null;
|
|
setIsPanning(false);
|
|
setSnapGuide(null);
|
|
if (canvasViewportRef.current?.hasPointerCapture?.(event.pointerId)) {
|
|
canvasViewportRef.current.releasePointerCapture?.(event.pointerId);
|
|
}
|
|
}
|
|
};
|
|
|
|
const switchTool = (tool: CanvasTool) => {
|
|
dragStateRef.current = null;
|
|
setIsPanning(false);
|
|
setSnapGuide(null);
|
|
if (tool === 'upload') {
|
|
setUploadTarget('asset');
|
|
uploadInputRef.current?.click();
|
|
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);
|
|
};
|
|
|
|
const toggleSidebarPanel = (panel: SidebarPanel) => {
|
|
setActiveSidebarPanel((currentPanel) =>
|
|
currentPanel === panel ? null : panel,
|
|
);
|
|
};
|
|
|
|
const groupSelectedLayers = () => {
|
|
const targetIds = selectedLayerIds.length
|
|
? selectedLayerIds
|
|
: selectedLayerId
|
|
? [selectedLayerId]
|
|
: [];
|
|
if (!targetIds.length) {
|
|
return;
|
|
}
|
|
const groupId = `layer-group-${Date.now()}`;
|
|
captureCanvasHistory();
|
|
setLayers((currentLayers) =>
|
|
currentLayers.map((layer) =>
|
|
targetIds.includes(layer.id)
|
|
? {
|
|
...layer,
|
|
groupId,
|
|
}
|
|
: layer,
|
|
),
|
|
);
|
|
};
|
|
|
|
const updateSpecFormValue = (key: keyof SpecFormValues, value: string) => {
|
|
setGenerateDialog((currentDialog) => {
|
|
if (currentDialog?.mode !== 'spec') {
|
|
return currentDialog;
|
|
}
|
|
const specType = currentDialog.specType ?? 'custom';
|
|
return {
|
|
...currentDialog,
|
|
specValues: {
|
|
...DEFAULT_SPEC_FORM_VALUES[specType],
|
|
...currentDialog.specValues,
|
|
[key]: value,
|
|
},
|
|
status:
|
|
currentDialog.status === 'failed' ? 'idle' : currentDialog.status,
|
|
errorMessage:
|
|
currentDialog.status === 'failed'
|
|
? undefined
|
|
: currentDialog.errorMessage,
|
|
};
|
|
});
|
|
};
|
|
|
|
const updateCharacterAnimationDuration = (frameCountValue: string) => {
|
|
const option = CHARACTER_ANIMATION_DURATION_OPTIONS.find(
|
|
(item) => String(item.frameCount) === frameCountValue,
|
|
);
|
|
if (!option) {
|
|
return;
|
|
}
|
|
setCharacterAnimationPanel((currentPanel) =>
|
|
currentPanel
|
|
? {
|
|
...currentPanel,
|
|
frameCount: option.frameCount,
|
|
durationSeconds: option.durationSeconds,
|
|
status:
|
|
currentPanel.status === 'failed' ? 'idle' : currentPanel.status,
|
|
errorMessage:
|
|
currentPanel.status === 'failed'
|
|
? undefined
|
|
: currentPanel.errorMessage,
|
|
}
|
|
: currentPanel,
|
|
);
|
|
};
|
|
|
|
const submitCharacterAnimation = async () => {
|
|
if (!characterAnimationPanel || !characterAnimationSourceLayer) {
|
|
return;
|
|
}
|
|
const promptText = characterAnimationPanel.promptText.trim();
|
|
const nextPanel = {
|
|
...characterAnimationPanel,
|
|
promptText,
|
|
status: 'generating' as const,
|
|
errorMessage: undefined,
|
|
result: undefined,
|
|
};
|
|
setCharacterAnimationPanel(nextPanel);
|
|
|
|
try {
|
|
const result = await generateEditorCharacterAnimation({
|
|
sourceLayerId: characterAnimationSourceLayer.id,
|
|
sourceImageSrc: resolveCharacterAnimationSourceImageSrc(
|
|
characterAnimationSourceLayer,
|
|
),
|
|
sourceWidth: characterAnimationSourceLayer.originalWidth,
|
|
sourceHeight: characterAnimationSourceLayer.originalHeight,
|
|
promptText,
|
|
resolution: nextPanel.resolution,
|
|
ratio: nextPanel.ratio,
|
|
frameCount: nextPanel.frameCount,
|
|
durationSeconds: nextPanel.durationSeconds,
|
|
priceMudPoints: calculateCharacterAnimationPrice(
|
|
nextPanel.resolution,
|
|
nextPanel.durationSeconds,
|
|
),
|
|
model: CHARACTER_ANIMATION_MODEL,
|
|
});
|
|
setCharacterAnimationPanel((currentPanel) =>
|
|
currentPanel
|
|
? {
|
|
...currentPanel,
|
|
status: 'completed',
|
|
result,
|
|
}
|
|
: currentPanel,
|
|
);
|
|
} catch (error) {
|
|
setCharacterAnimationPanel((currentPanel) =>
|
|
currentPanel
|
|
? {
|
|
...currentPanel,
|
|
status: 'failed',
|
|
errorMessage:
|
|
error instanceof Error && error.message.trim()
|
|
? error.message
|
|
: '生成角色动画失败',
|
|
}
|
|
: currentPanel,
|
|
);
|
|
}
|
|
};
|
|
|
|
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={(event) => {
|
|
const files = event.currentTarget.files;
|
|
if (files?.length) {
|
|
if (uploadTarget === 'character-spec') {
|
|
void addCharacterSpecReferenceFiles(files);
|
|
} else if (uploadTarget === 'character-reference') {
|
|
void addCharacterReferenceFiles(files);
|
|
} else if (uploadTarget === 'icon-spec') {
|
|
void addIconSpecReferenceFiles(files);
|
|
} else {
|
|
addUploadedFiles(files, { addToCanvas: activeTool === 'upload' });
|
|
}
|
|
}
|
|
setUploadTarget('asset');
|
|
event.currentTarget.value = '';
|
|
}}
|
|
/>
|
|
{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}
|
|
uploadInputRef={uploadInputRef}
|
|
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}
|
|
setUploadTarget={setUploadTarget}
|
|
setUploadDropTarget={setUploadDropTarget}
|
|
setAssetPointerDrag={setAssetPointerDrag}
|
|
setSelectedAssetIds={setSelectedAssetIds}
|
|
setImageContextMenu={setImageContextMenu}
|
|
setContextMenu={setContextMenu}
|
|
onAssetMarqueePointerDown={handleAssetMarqueePointerDown}
|
|
onAssetMarqueePointerMove={handleAssetMarqueePointerMove}
|
|
onAssetMarqueePointerUp={handleAssetMarqueePointerUp}
|
|
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
|
|
addUploadedFiles={addUploadedFiles}
|
|
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();
|
|
}}
|
|
>
|
|
<PlatformTextField
|
|
aria-label="项目名称"
|
|
value={projectRenameValue}
|
|
autoFocus
|
|
disabled={isProjectRenameSaving}
|
|
className="image-canvas-editor__project-title-input"
|
|
onChange={(event) => {
|
|
setProjectRenameValue(event.target.value);
|
|
setProjectRenameError(null);
|
|
}}
|
|
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={() => setIsZoomMenuOpen((open) => !open)}
|
|
onCloseZoomMenu={() => setIsZoomMenuOpen(false)}
|
|
onToggleBackgroundSettings={() =>
|
|
setIsBackgroundSettingsOpen((isOpen) => !isOpen)
|
|
}
|
|
onApplyCanvasBackgroundColor={applyCanvasBackgroundColor}
|
|
onCanvasBackgroundHexChange={handleCanvasBackgroundHexChange}
|
|
onToggleSidebarPanel={toggleSidebarPanel}
|
|
onToggleMinimap={() => setIsMinimapOpen((open) => !open)}
|
|
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={(target) => {
|
|
setUploadTarget(target);
|
|
uploadInputRef.current?.click();
|
|
}}
|
|
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;
|