抽出素材和图层左侧整合面板为 ImageCanvasSidebarView 保留上传、登录、拖到画布和持久化状态机在主视图 更新前端拆分计划和 TRACKING 验证记录
6450 lines
221 KiB
TypeScript
6450 lines
221 KiB
TypeScript
import {
|
||
Braces,
|
||
Check,
|
||
ChevronDown,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
ClipboardList,
|
||
Copy,
|
||
Crop,
|
||
Download,
|
||
Folder,
|
||
Hand,
|
||
ImageIcon,
|
||
ImagePlus,
|
||
Info,
|
||
Layers,
|
||
Map as MapIcon,
|
||
MousePointer2,
|
||
Pencil,
|
||
Redo2,
|
||
RotateCcw,
|
||
Shapes,
|
||
SlidersHorizontal,
|
||
Sparkles,
|
||
Trash2,
|
||
Type,
|
||
Undo2,
|
||
WandSparkles,
|
||
X,
|
||
} from 'lucide-react';
|
||
import JSZip from 'jszip';
|
||
import {
|
||
type CSSProperties,
|
||
type DragEvent as ReactDragEvent,
|
||
type PointerEvent as ReactPointerEvent,
|
||
type ReactNode,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type WheelEvent as ReactWheelEvent,
|
||
} from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import { ApiClientError } from '../../services/apiClient';
|
||
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
|
||
import {
|
||
createEditorAsset,
|
||
createEditorAssetFolder,
|
||
createEditorProjectResource,
|
||
deleteEditorAsset,
|
||
deleteEditorAssetFolder,
|
||
editEditorImage,
|
||
type EditorIconSpritesheetGenerationResult,
|
||
type EditorIconSpritesheetIconResult,
|
||
type EditorImageGenerationResult,
|
||
generateEditorCharacterAnimation,
|
||
generateEditorIconSpritesheet,
|
||
generateEditorImage,
|
||
loadEditorAssetLibrary,
|
||
loadEditorProject,
|
||
loadOrCreateRecentEditorProject,
|
||
renameEditorProject,
|
||
saveEditorProjectLayout,
|
||
updateEditorAsset,
|
||
updateEditorAssetFolder,
|
||
} from '../../services/image-editor/editorProjectClient';
|
||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||
import {
|
||
PlatformFloatingMenu,
|
||
PlatformFloatingMenuItem,
|
||
} from '../common/PlatformFloatingMenu';
|
||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||
import {
|
||
PlatformSelectField,
|
||
PlatformTextField,
|
||
} from '../common/PlatformTextField';
|
||
import { UnifiedModal } from '../common/UnifiedModal';
|
||
import { useAuthUi } from '../auth/AuthUiContext';
|
||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
|
||
import {
|
||
ASSET_DRAG_MIME_TYPE,
|
||
CANVAS_BACKGROUND_OPTIONS,
|
||
CANVAS_WORLD_SIZE,
|
||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||
DEFAULT_CANVAS_SIZE,
|
||
EDITOR_ASSET_FOLDERS,
|
||
FIT_VIEW_PADDING,
|
||
MAX_HISTORY_STEPS,
|
||
MAX_SCALE,
|
||
MIN_SCALE,
|
||
MINIMAP_DRAG_SENSITIVITY,
|
||
MINIMAP_PADDING,
|
||
MINIMAP_SIZE,
|
||
TOOLBAR_HALF_WIDTH,
|
||
clamp,
|
||
createLayerFromAsset,
|
||
escapeCssIdentifier,
|
||
formatImageSizeValue,
|
||
formatPercent,
|
||
getDraggedAssetId,
|
||
getLayerBounds,
|
||
hasDataTransferType,
|
||
hydrateLayer,
|
||
isGeneratedLayer,
|
||
isLayerLinkedToAsset,
|
||
normalizeAssetLibrary,
|
||
normalizeCanvasBackgroundHex,
|
||
resolveContextMenuPosition,
|
||
resolveLayerResolutionSize,
|
||
resolveSnappedLayerPosition,
|
||
serializeLayer,
|
||
} from './ImageCanvasEditorModel';
|
||
import {
|
||
blobToUint8Array,
|
||
buildLayerExportMetadata,
|
||
formatExportDate,
|
||
getImageExtensionFromTypeOrSrc,
|
||
getLayerExportKey,
|
||
readLayerImageBlob,
|
||
sanitizeExportFilePart,
|
||
} from './ImageCanvasExportModel';
|
||
import {
|
||
CHARACTER_ANIMATION_ACTION_PROMPTS,
|
||
CHARACTER_ANIMATION_DURATION_OPTIONS,
|
||
CHARACTER_ANIMATION_MODEL,
|
||
CHARACTER_ANIMATION_RATIO_OPTIONS,
|
||
CHARACTER_FRAME_DISPLAY_SIZE,
|
||
CHARACTER_FRAME_ORIGINAL_SIZE,
|
||
CHARACTER_SPEC_VIEW_OPTIONS,
|
||
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_COST,
|
||
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,
|
||
CanvasHistorySnapshot,
|
||
CanvasLayer,
|
||
CanvasMarqueeState,
|
||
CanvasTool,
|
||
CanvasViewport,
|
||
CharacterAnimationPanelState,
|
||
DragState,
|
||
EditorAsset,
|
||
EditorAssetFolder,
|
||
GenerateDialogState,
|
||
ImageContextMenuState,
|
||
QuickEditPanelState,
|
||
SidebarPanel,
|
||
SnapGuide,
|
||
SpecFormValues,
|
||
SpecGenerationType,
|
||
UploadTarget,
|
||
} from './ImageCanvasEditorTypes';
|
||
|
||
function triggerPlaceholderAction(label: string) {
|
||
window.alert(`${label}功能建设中`);
|
||
}
|
||
|
||
function buildPortalMenuStyle(
|
||
anchor: HTMLElement | null,
|
||
placement: 'above' | 'below',
|
||
): CSSProperties {
|
||
const rect = anchor?.getBoundingClientRect();
|
||
if (!rect) {
|
||
return {
|
||
position: 'fixed',
|
||
left: 0,
|
||
top: 0,
|
||
right: 'auto',
|
||
bottom: 'auto',
|
||
zIndex: 70,
|
||
};
|
||
}
|
||
|
||
return {
|
||
position: 'fixed',
|
||
left: Math.round(rect.left),
|
||
top:
|
||
placement === 'above'
|
||
? Math.round(rect.top)
|
||
: Math.round(rect.bottom + 8),
|
||
right: 'auto',
|
||
bottom: 'auto',
|
||
zIndex: 70,
|
||
transform:
|
||
placement === 'above' ? 'translateY(calc(-100% - 0.45rem))' : undefined,
|
||
};
|
||
}
|
||
|
||
function renderEditorPortal(node: ReactNode) {
|
||
if (typeof document === 'undefined') {
|
||
return node;
|
||
}
|
||
return createPortal(node, document.body);
|
||
}
|
||
|
||
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 generationDialogCounterRef = useRef(0);
|
||
const saveTimerRef = useRef<number | null>(null);
|
||
const undoStackRef = useRef<CanvasHistorySnapshot[]>([]);
|
||
const redoStackRef = useRef<CanvasHistorySnapshot[]>([]);
|
||
const layersRef = useRef<CanvasLayer[]>([]);
|
||
const viewportRef = useRef<CanvasViewport>({
|
||
x: -260,
|
||
y: 70,
|
||
scale: 0.82,
|
||
});
|
||
const projectIdRef = useRef<string | null>(null);
|
||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||
const characterSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
|
||
const pendingProjectResourceLayersRef = useRef<
|
||
Array<{
|
||
layer: CanvasLayer;
|
||
options: { onCreated?: (resourceId: string) => void };
|
||
}>
|
||
>([]);
|
||
const selectedLayerIdRef = useRef<string | null>(null);
|
||
const selectedLayerIdsRef = useRef<string[]>([]);
|
||
const generateDialogRef = useRef<GenerateDialogState | null>(null);
|
||
const inactiveGenerateDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
|
||
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
|
||
() => {},
|
||
);
|
||
const suppressAssetClickRef = useRef(false);
|
||
const [projectId, setProjectId] = useState<string | null>(null);
|
||
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 [isProjectReady, setIsProjectReady] = useState(false);
|
||
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 [generateDialog, setGenerateDialog] =
|
||
useState<GenerateDialogState | null>(null);
|
||
const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState<
|
||
CanvasGenerationDialogState[]
|
||
>([]);
|
||
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 [historyVersion, setHistoryVersion] = useState(0);
|
||
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;
|
||
generateDialogRef.current = generateDialog;
|
||
inactiveGenerateDialogsRef.current = inactiveGenerateDialogs;
|
||
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 activeCanvasGenerationDialog = isCanvasGenerationDialog(generateDialog)
|
||
? generateDialog
|
||
: null;
|
||
const canvasGenerationDialogs = useMemo(
|
||
() =>
|
||
activeCanvasGenerationDialog
|
||
? [...inactiveGenerateDialogs, activeCanvasGenerationDialog]
|
||
: inactiveGenerateDialogs,
|
||
[activeCanvasGenerationDialog, inactiveGenerateDialogs],
|
||
);
|
||
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) => {
|
||
if (menu?.kind !== 'layer') {
|
||
return [];
|
||
}
|
||
return selectedLayerIdsRef.current.includes(menu.layerId)
|
||
? selectedLayerIdsRef.current
|
||
: [menu.layerId];
|
||
},
|
||
[contextMenu],
|
||
);
|
||
const contextTargetIds = getContextTargetLayerIds(contextMenu);
|
||
const contextTargetLayers = layers.filter((layer) =>
|
||
contextTargetIds.includes(layer.id),
|
||
);
|
||
const contextShouldShowLayer = contextTargetLayers.some(
|
||
(layer) => layer.hidden,
|
||
);
|
||
const contextShouldUnlockLayer = contextTargetLayers.some(
|
||
(layer) => layer.locked,
|
||
);
|
||
const canUndo = undoStackRef.current.length > 0;
|
||
const canRedo = redoStackRef.current.length > 0;
|
||
void historyVersion;
|
||
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 createGenerationDialogId = () => {
|
||
generationDialogCounterRef.current += 1;
|
||
return `generation-dialog-${generationDialogCounterRef.current}`;
|
||
};
|
||
|
||
const archiveActiveCanvasGenerationDialog = () => {
|
||
const currentDialog = generateDialogRef.current;
|
||
if (!isCanvasGenerationDialog(currentDialog)) {
|
||
return;
|
||
}
|
||
setInactiveGenerateDialogs((currentDialogs) =>
|
||
currentDialogs.some((dialog) => dialog.id === currentDialog.id)
|
||
? currentDialogs
|
||
: [
|
||
...currentDialogs,
|
||
{
|
||
...currentDialog,
|
||
composerOpen: false,
|
||
},
|
||
],
|
||
);
|
||
};
|
||
|
||
const openCanvasGenerationDialog = (
|
||
dialog: Omit<CanvasGenerationDialogState, 'id'>,
|
||
) => {
|
||
archiveActiveCanvasGenerationDialog();
|
||
setGenerateDialog({
|
||
...dialog,
|
||
id: createGenerationDialogId(),
|
||
});
|
||
};
|
||
|
||
const updateCanvasGenerationDialogById = (
|
||
dialogId: string,
|
||
updater: (
|
||
dialog: CanvasGenerationDialogState,
|
||
) => CanvasGenerationDialogState | null,
|
||
) => {
|
||
setGenerateDialog((currentDialog) =>
|
||
isCanvasGenerationDialog(currentDialog) &&
|
||
currentDialog.id === dialogId
|
||
? updater(currentDialog)
|
||
: currentDialog,
|
||
);
|
||
setInactiveGenerateDialogs((currentDialogs) =>
|
||
currentDialogs.flatMap((dialog) => {
|
||
if (dialog.id !== dialogId) {
|
||
return [dialog];
|
||
}
|
||
const nextDialog = updater(dialog);
|
||
return nextDialog ? [nextDialog] : [];
|
||
}),
|
||
);
|
||
};
|
||
|
||
const removeCanvasGenerationDialogById = (dialogId: string) => {
|
||
updateCanvasGenerationDialogById(dialogId, () => null);
|
||
};
|
||
|
||
const activateCanvasGenerationDialog = (
|
||
targetDialog: CanvasGenerationDialogState,
|
||
) => {
|
||
setInactiveGenerateDialogs((currentDialogs) => {
|
||
const nextDialogs = currentDialogs.filter(
|
||
(dialog) => dialog.id !== targetDialog.id,
|
||
);
|
||
const currentDialog = generateDialogRef.current;
|
||
if (
|
||
isCanvasGenerationDialog(currentDialog) &&
|
||
currentDialog.id !== targetDialog.id
|
||
) {
|
||
nextDialogs.push({
|
||
...currentDialog,
|
||
composerOpen: false,
|
||
});
|
||
}
|
||
return nextDialogs;
|
||
});
|
||
setGenerateDialog({
|
||
...targetDialog,
|
||
composerOpen: true,
|
||
});
|
||
setSelectedLayerId(null);
|
||
setSelectedLayerIds([]);
|
||
setImageContextMenu(null);
|
||
};
|
||
|
||
const removeCanvasGenerationDialogsByLayerId = (targetLayerId: string) => {
|
||
const keepDialog = (dialog: CanvasGenerationDialogState) =>
|
||
dialog.sourceLayerId !== targetLayerId &&
|
||
dialog.generatedLayerId !== targetLayerId;
|
||
setGenerateDialog((currentDialog) =>
|
||
isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog)
|
||
? null
|
||
: currentDialog,
|
||
);
|
||
setInactiveGenerateDialogs((currentDialogs) =>
|
||
currentDialogs.filter(keepDialog),
|
||
);
|
||
};
|
||
|
||
const getCanvasHistorySnapshot = useCallback(
|
||
(): CanvasHistorySnapshot => ({
|
||
layers: layersRef.current.map((layer) => ({ ...layer })),
|
||
viewport: { ...viewportRef.current },
|
||
generateDialog: generateDialogRef.current
|
||
? {
|
||
...generateDialogRef.current,
|
||
placeholder: generateDialogRef.current.placeholder
|
||
? { ...generateDialogRef.current.placeholder }
|
||
: undefined,
|
||
}
|
||
: null,
|
||
inactiveGenerateDialogs: inactiveGenerateDialogsRef.current.map(
|
||
(dialog) => ({
|
||
...dialog,
|
||
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
|
||
}),
|
||
),
|
||
selectedLayerId: selectedLayerIdRef.current,
|
||
selectedLayerIds: [...selectedLayerIdsRef.current],
|
||
}),
|
||
[],
|
||
);
|
||
|
||
const restoreCanvasHistorySnapshot = useCallback(
|
||
(snapshot: CanvasHistorySnapshot) => {
|
||
setLayers(snapshot.layers.map((layer) => ({ ...layer })));
|
||
setViewport({ ...snapshot.viewport });
|
||
setGenerateDialog(
|
||
snapshot.generateDialog
|
||
? {
|
||
...snapshot.generateDialog,
|
||
placeholder: snapshot.generateDialog.placeholder
|
||
? { ...snapshot.generateDialog.placeholder }
|
||
: undefined,
|
||
}
|
||
: null,
|
||
);
|
||
setInactiveGenerateDialogs(
|
||
snapshot.inactiveGenerateDialogs.map((dialog) => ({
|
||
...dialog,
|
||
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
|
||
})),
|
||
);
|
||
setSelectedLayerId(snapshot.selectedLayerId);
|
||
setSelectedLayerIds([...snapshot.selectedLayerIds]);
|
||
setHoveredLayerId(null);
|
||
setMetadataLayer(null);
|
||
setCanvasMarquee(null);
|
||
setSnapGuide(null);
|
||
setImageContextMenu(null);
|
||
setContextMenu(null);
|
||
setIsPanning(false);
|
||
dragStateRef.current = null;
|
||
},
|
||
[],
|
||
);
|
||
|
||
const captureCanvasHistory = useCallback(
|
||
(options: { clearRedo?: boolean } = {}) => {
|
||
undoStackRef.current = [
|
||
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
|
||
getCanvasHistorySnapshot(),
|
||
];
|
||
if (options.clearRedo !== false) {
|
||
redoStackRef.current = [];
|
||
}
|
||
setHistoryVersion((version) => version + 1);
|
||
},
|
||
[getCanvasHistorySnapshot],
|
||
);
|
||
|
||
const undoCanvasChange = useCallback(() => {
|
||
const previousSnapshot = undoStackRef.current.at(-1);
|
||
if (!previousSnapshot) {
|
||
return;
|
||
}
|
||
undoStackRef.current = undoStackRef.current.slice(0, -1);
|
||
redoStackRef.current = [
|
||
...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
|
||
getCanvasHistorySnapshot(),
|
||
];
|
||
restoreCanvasHistorySnapshot(previousSnapshot);
|
||
setHistoryVersion((version) => version + 1);
|
||
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
|
||
|
||
const redoCanvasChange = useCallback(() => {
|
||
const nextSnapshot = redoStackRef.current.at(-1);
|
||
if (!nextSnapshot) {
|
||
return;
|
||
}
|
||
redoStackRef.current = redoStackRef.current.slice(0, -1);
|
||
undoStackRef.current = [
|
||
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
|
||
getCanvasHistorySnapshot(),
|
||
];
|
||
restoreCanvasHistorySnapshot(nextSnapshot);
|
||
setHistoryVersion((version) => version + 1);
|
||
}, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]);
|
||
|
||
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 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 getGeneratingDialogPlaceholder = useCallback(
|
||
(dialog: GenerateDialogState) => {
|
||
const currentDialog = generateDialogRef.current;
|
||
if (dialog.id) {
|
||
const latestDialog = [
|
||
...(isCanvasGenerationDialog(currentDialog) ? [currentDialog] : []),
|
||
...inactiveGenerateDialogsRef.current,
|
||
].find((candidateDialog) => candidateDialog.id === dialog.id);
|
||
if (latestDialog?.status === 'generating') {
|
||
return latestDialog.placeholder ?? dialog.placeholder;
|
||
}
|
||
}
|
||
if (
|
||
currentDialog?.mode === dialog.mode &&
|
||
(!dialog.id || currentDialog.id === dialog.id) &&
|
||
currentDialog.status === 'generating'
|
||
) {
|
||
return currentDialog.placeholder ?? dialog.placeholder;
|
||
}
|
||
return dialog.placeholder;
|
||
},
|
||
[],
|
||
);
|
||
|
||
const createProjectResourceForLayer = useCallback(
|
||
(
|
||
layer: CanvasLayer,
|
||
options: { onCreated?: (resourceId: string) => void } = {},
|
||
) => {
|
||
const readyProjectId = projectIdRef.current;
|
||
if (!readyProjectId) {
|
||
pendingProjectResourceLayersRef.current.push({ layer, options });
|
||
return;
|
||
}
|
||
createEditorProjectResource(readyProjectId, {
|
||
imageSrc: layer.src,
|
||
objectKey: layer.objectKey,
|
||
assetObjectId: layer.assetObjectId,
|
||
width: layer.originalWidth,
|
||
height: layer.originalHeight,
|
||
sourceType: layer.sourceType,
|
||
prompt: layer.prompt,
|
||
actualPrompt: layer.actualPrompt,
|
||
model: layer.model,
|
||
provider: layer.provider,
|
||
taskId: layer.taskId,
|
||
sourceResourceId: layer.sourceResourceId,
|
||
})
|
||
.then((resource) => {
|
||
options.onCreated?.(resource.resourceId);
|
||
setLayers((currentLayers) =>
|
||
currentLayers.map((currentLayer) =>
|
||
currentLayer.id === layer.id
|
||
? {
|
||
...currentLayer,
|
||
resourceId: resource.resourceId,
|
||
}
|
||
: currentLayer,
|
||
),
|
||
);
|
||
})
|
||
.catch((error: unknown) => {
|
||
if (isEditorAuthError(error)) {
|
||
openEditorLoginModal();
|
||
}
|
||
});
|
||
},
|
||
[openEditorLoginModal],
|
||
);
|
||
|
||
const minimapModel = useMemo(() => {
|
||
const layerBounds = getLayerBounds(layers);
|
||
if (!layerBounds) {
|
||
return null;
|
||
}
|
||
|
||
const visibleBounds = {
|
||
minX: (0 - viewport.x) / viewport.scale,
|
||
minY: (0 - viewport.y) / viewport.scale,
|
||
maxX: (canvasSize.width - viewport.x) / viewport.scale,
|
||
maxY: (canvasSize.height - viewport.y) / viewport.scale,
|
||
};
|
||
const bounds = {
|
||
minX: Math.min(layerBounds.minX, visibleBounds.minX),
|
||
minY: Math.min(layerBounds.minY, visibleBounds.minY),
|
||
maxX: Math.max(layerBounds.maxX, visibleBounds.maxX),
|
||
maxY: Math.max(layerBounds.maxY, visibleBounds.maxY),
|
||
};
|
||
const boundsWidth = Math.max(1, bounds.maxX - bounds.minX);
|
||
const boundsHeight = Math.max(1, bounds.maxY - bounds.minY);
|
||
const scale = Math.min(
|
||
(MINIMAP_SIZE.width - MINIMAP_PADDING * 2) / boundsWidth,
|
||
(MINIMAP_SIZE.height - MINIMAP_PADDING * 2) / boundsHeight,
|
||
);
|
||
const projectRect = (rect: {
|
||
minX: number;
|
||
minY: number;
|
||
maxX: number;
|
||
maxY: number;
|
||
}) => ({
|
||
left: MINIMAP_PADDING + (rect.minX - bounds.minX) * scale,
|
||
top: MINIMAP_PADDING + (rect.minY - bounds.minY) * scale,
|
||
width: Math.max(2, (rect.maxX - rect.minX) * scale),
|
||
height: Math.max(2, (rect.maxY - rect.minY) * scale),
|
||
});
|
||
|
||
return {
|
||
bounds,
|
||
scale,
|
||
layers: layers.map((layer) => ({
|
||
id: layer.id,
|
||
title: layer.title,
|
||
rect: projectRect({
|
||
minX: layer.x,
|
||
minY: layer.y,
|
||
maxX: layer.x + layer.width,
|
||
maxY: layer.y + layer.height,
|
||
}),
|
||
})),
|
||
viewport: projectRect(visibleBounds),
|
||
};
|
||
}, [canvasSize.height, canvasSize.width, layers, viewport]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const projectIdFromQuery =
|
||
typeof window === 'undefined'
|
||
? null
|
||
: new URLSearchParams(window.location.search)
|
||
.get('projectid')
|
||
?.trim() || null;
|
||
const loadProject = projectIdFromQuery
|
||
? loadEditorProject(projectIdFromQuery)
|
||
: loadOrCreateRecentEditorProject();
|
||
|
||
loadProject
|
||
.then((project) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
projectIdRef.current = project.projectId;
|
||
setProjectId(project.projectId);
|
||
const nextProjectTitle = project.title?.trim() || '未命名画布';
|
||
setProjectTitle(nextProjectTitle);
|
||
setProjectRenameValue(nextProjectTitle);
|
||
const pendingLayers = pendingProjectResourceLayersRef.current.splice(0);
|
||
pendingLayers.forEach(({ layer, options }) => {
|
||
createProjectResourceForLayer(layer, options);
|
||
});
|
||
setViewport(project.viewport);
|
||
const resourcesById = new Map(
|
||
project.resources.map((resource) => [
|
||
resource.resourceId,
|
||
{ imageSrc: resource.imageSrc },
|
||
]),
|
||
);
|
||
const hydratedLayers = project.layers
|
||
.map((layer) => hydrateLayer(layer, resourcesById))
|
||
.filter((layer): layer is CanvasLayer => Boolean(layer));
|
||
if (hydratedLayers.length > 0) {
|
||
layerCounterRef.current = hydratedLayers.length;
|
||
setLayers(hydratedLayers);
|
||
selectSingleLayer(hydratedLayers[0]?.id ?? null);
|
||
}
|
||
setIsProjectReady(true);
|
||
})
|
||
.catch((error: unknown) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
setIsProjectReady(false);
|
||
if (isEditorAuthError(error)) {
|
||
openEditorLoginModal(() => {
|
||
window.location.reload();
|
||
});
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [createProjectResourceForLayer, openEditorLoginModal, selectSingleLayer]);
|
||
|
||
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);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!projectId || !isProjectReady) {
|
||
return undefined;
|
||
}
|
||
if (saveTimerRef.current) {
|
||
window.clearTimeout(saveTimerRef.current);
|
||
}
|
||
|
||
saveTimerRef.current = window.setTimeout(() => {
|
||
saveEditorProjectLayout(projectId, {
|
||
viewport,
|
||
layers: layers.map(serializeLayer),
|
||
}).catch((error: unknown) => {
|
||
if (isEditorAuthError(error)) {
|
||
openEditorLoginModal();
|
||
}
|
||
});
|
||
}, 450);
|
||
|
||
return () => {
|
||
if (saveTimerRef.current) {
|
||
window.clearTimeout(saveTimerRef.current);
|
||
}
|
||
};
|
||
}, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]);
|
||
|
||
const fitLayers = useCallback(
|
||
(targetLayers: CanvasLayer[] = layers) => {
|
||
if (targetLayers.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const bounds = getLayerBounds(targetLayers);
|
||
if (!bounds) {
|
||
return;
|
||
}
|
||
const boundsWidth = Math.max(1, bounds.maxX - bounds.minX);
|
||
const boundsHeight = Math.max(1, bounds.maxY - bounds.minY);
|
||
const availableWidth = Math.max(
|
||
1,
|
||
canvasSize.width - FIT_VIEW_PADDING * 2,
|
||
);
|
||
const availableHeight = Math.max(
|
||
1,
|
||
canvasSize.height - FIT_VIEW_PADDING * 2,
|
||
);
|
||
const scale = clamp(
|
||
Math.min(
|
||
1,
|
||
availableWidth / boundsWidth,
|
||
availableHeight / boundsHeight,
|
||
),
|
||
MIN_SCALE,
|
||
MAX_SCALE,
|
||
);
|
||
|
||
captureCanvasHistory();
|
||
setViewport({
|
||
x: canvasSize.width / 2 - (bounds.minX + boundsWidth / 2) * scale,
|
||
y: canvasSize.height / 2 - (bounds.minY + boundsHeight / 2) * scale,
|
||
scale,
|
||
});
|
||
},
|
||
[captureCanvasHistory, canvasSize.height, canvasSize.width, layers],
|
||
);
|
||
|
||
const updateScaleFromCenter = (nextScale: number) => {
|
||
const viewportElement = canvasViewportRef.current;
|
||
if (!viewportElement) {
|
||
captureCanvasHistory();
|
||
setViewport((currentViewport) => ({
|
||
...currentViewport,
|
||
scale: clamp(nextScale, MIN_SCALE, MAX_SCALE),
|
||
}));
|
||
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) => {
|
||
const scale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
|
||
const worldX = (centerX - currentViewport.x) / currentViewport.scale;
|
||
const worldY = (centerY - currentViewport.y) / currentViewport.scale;
|
||
return {
|
||
x: centerX - worldX * scale,
|
||
y: centerY - worldY * scale,
|
||
scale,
|
||
};
|
||
});
|
||
};
|
||
|
||
const resolveCanvasPoint = (clientX: number, clientY: number) => {
|
||
const rect = canvasViewportRef.current?.getBoundingClientRect();
|
||
if (!rect) {
|
||
return null;
|
||
}
|
||
if (
|
||
clientX < rect.left ||
|
||
clientX > rect.right ||
|
||
clientY < rect.top ||
|
||
clientY > rect.bottom
|
||
) {
|
||
return null;
|
||
}
|
||
return {
|
||
x: clientX - rect.left,
|
||
y: clientY - rect.top,
|
||
};
|
||
};
|
||
|
||
const getCanvasDropPoint = (event: ReactDragEvent<HTMLDivElement>) =>
|
||
resolveCanvasPoint(event.clientX, event.clientY) ?? {
|
||
x: Number.isFinite(canvasSize.width) ? canvasSize.width / 2 : 0,
|
||
y: Number.isFinite(canvasSize.height) ? canvasSize.height / 2 : 0,
|
||
};
|
||
|
||
const getCanvasPointFromClient = (clientX: number, clientY: number) => {
|
||
const rect = canvasViewportRef.current?.getBoundingClientRect();
|
||
const screenX = clientX - (rect?.left ?? 0);
|
||
const screenY = clientY - (rect?.top ?? 0);
|
||
return {
|
||
x: (screenX - viewport.x) / viewport.scale,
|
||
y: (screenY - viewport.y) / viewport.scale,
|
||
};
|
||
};
|
||
|
||
const duplicateLayersToPoint = (
|
||
sourceLayers: CanvasLayer[],
|
||
canvasPoint?: { x: number; y: number },
|
||
options: { renameCopies?: boolean } = {},
|
||
) => {
|
||
if (!sourceLayers.length) {
|
||
return [];
|
||
}
|
||
const minX = Math.min(...sourceLayers.map((layer) => layer.x));
|
||
const minY = Math.min(...sourceLayers.map((layer) => layer.y));
|
||
const maxZIndex = layersRef.current.reduce(
|
||
(maxZ, layer) => Math.max(maxZ, layer.zIndex),
|
||
0,
|
||
);
|
||
const stamp = Date.now();
|
||
return sourceLayers.map((layer, index) => ({
|
||
...layer,
|
||
id: `layer-copy-${stamp}-${index}`,
|
||
resourceId: `local-resource-copy-${stamp}-${index}`,
|
||
title: options.renameCopies === false ? layer.title : `${layer.title} 副本`,
|
||
x: canvasPoint ? canvasPoint.x + (layer.x - minX) : layer.x + 32,
|
||
y: canvasPoint ? canvasPoint.y + (layer.y - minY) : layer.y + 32,
|
||
zIndex: maxZIndex + index + 1,
|
||
groupId: null,
|
||
}));
|
||
};
|
||
|
||
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 targetLayers = layers.filter((layer) => targetIds.includes(layer.id));
|
||
if (!targetLayers.length) {
|
||
return;
|
||
}
|
||
setCanvasClipboard({
|
||
layers: targetLayers.map((layer) => ({ ...layer })),
|
||
mode: options.cut ? 'cut' : 'copy',
|
||
});
|
||
if (options.cut) {
|
||
captureCanvasHistory();
|
||
setLayers((currentLayers) =>
|
||
currentLayers.filter((layer) => !targetIds.includes(layer.id)),
|
||
);
|
||
selectSingleLayer(null);
|
||
setMetadataLayer((currentLayer) =>
|
||
currentLayer && targetIds.includes(currentLayer.id)
|
||
? null
|
||
: currentLayer,
|
||
);
|
||
}
|
||
setContextMenu(null);
|
||
};
|
||
|
||
const duplicateContextLayers = () => {
|
||
const targetIds = getContextTargetLayerIds();
|
||
const targetLayers = layers.filter((layer) => targetIds.includes(layer.id));
|
||
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) =>
|
||
currentLayers.map((layer) =>
|
||
targetIds.includes(layer.id) ? updater(layer, targetIds) : layer,
|
||
),
|
||
);
|
||
setContextMenu(null);
|
||
};
|
||
|
||
const moveContextLayers = (mode: 'up' | 'down' | 'top' | 'bottom') => {
|
||
const targetIds = getContextTargetLayerIds();
|
||
if (!targetIds.length) {
|
||
return;
|
||
}
|
||
const maxZIndex = layers.reduce(
|
||
(maxZ, layer) => Math.max(maxZ, layer.zIndex),
|
||
0,
|
||
);
|
||
const minZIndex = layers.reduce(
|
||
(minZ, layer) => Math.min(minZ, layer.zIndex),
|
||
0,
|
||
);
|
||
let offsetIndex = 0;
|
||
updateContextLayers((layer) => {
|
||
if (mode === 'up') {
|
||
return { ...layer, zIndex: layer.zIndex + 1 };
|
||
}
|
||
if (mode === 'down') {
|
||
return { ...layer, zIndex: layer.zIndex - 1 };
|
||
}
|
||
offsetIndex += 1;
|
||
if (mode === 'top') {
|
||
return { ...layer, zIndex: maxZIndex + offsetIndex };
|
||
}
|
||
return { ...layer, zIndex: minZIndex - (targetIds.length - offsetIndex + 1) };
|
||
});
|
||
};
|
||
|
||
const groupContextLayers = () => {
|
||
const targetIds = getContextTargetLayerIds();
|
||
if (!targetIds.length) {
|
||
return;
|
||
}
|
||
const groupId = `layer-group-${Date.now()}`;
|
||
updateContextLayers((layer) => ({
|
||
...layer,
|
||
groupId,
|
||
}));
|
||
};
|
||
|
||
const ungroupContextLayers = () => {
|
||
updateContextLayers((layer) => ({
|
||
...layer,
|
||
groupId: null,
|
||
}));
|
||
};
|
||
|
||
const toggleContextLayerVisibility = () => {
|
||
const targetIds = getContextTargetLayerIds();
|
||
const shouldHide = layers
|
||
.filter((layer) => targetIds.includes(layer.id))
|
||
.some((layer) => !layer.hidden);
|
||
updateContextLayers((layer) => ({
|
||
...layer,
|
||
hidden: shouldHide,
|
||
}));
|
||
};
|
||
|
||
const toggleContextLayerLock = () => {
|
||
const targetIds = getContextTargetLayerIds();
|
||
const shouldLock = layers
|
||
.filter((layer) => targetIds.includes(layer.id))
|
||
.some((layer) => !layer.locked);
|
||
updateContextLayers((layer) => ({
|
||
...layer,
|
||
locked: shouldLock,
|
||
}));
|
||
};
|
||
|
||
const flipContextLayers = (axis: 'x' | 'y') => {
|
||
updateContextLayers((layer) =>
|
||
axis === 'x'
|
||
? {
|
||
...layer,
|
||
flipX: !layer.flipX,
|
||
}
|
||
: {
|
||
...layer,
|
||
flipY: !layer.flipY,
|
||
},
|
||
);
|
||
};
|
||
|
||
const deleteContextLayers = () => {
|
||
const targetIds = getContextTargetLayerIds();
|
||
if (!targetIds.length) {
|
||
return;
|
||
}
|
||
captureCanvasHistory();
|
||
setLayers((currentLayers) =>
|
||
currentLayers.filter((layer) => !targetIds.includes(layer.id)),
|
||
);
|
||
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();
|
||
setLayers((currentLayers) => [...currentLayers, nextLayer]);
|
||
selectSingleLayer(nextLayer.id);
|
||
setHoveredLayerId(null);
|
||
createProjectResourceForLayer(nextLayer);
|
||
};
|
||
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) {
|
||
setLayers((currentLayers) => [...currentLayers, 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 (options.addToCanvas) {
|
||
createProjectResourceForLayer(nextLayer);
|
||
}
|
||
|
||
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,
|
||
};
|
||
|
||
setLayers((currentLayers) => [...currentLayers, 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]);
|
||
}
|
||
createProjectResourceForLayer(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,
|
||
};
|
||
|
||
setLayers((currentLayers) => [...currentLayers, nextLayer]);
|
||
selectSingleLayer(nextLayer.id);
|
||
setActiveSidebarPanel('layers');
|
||
setQuickEditPanel(null);
|
||
setActiveTool('select');
|
||
fitLayers([sourceLayer, nextLayer]);
|
||
createProjectResourceForLayer(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;
|
||
}
|
||
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
|
||
selectSingleLayer(nextLayers[0]?.id ?? null);
|
||
setActiveSidebarPanel('layers');
|
||
if (dialogId) {
|
||
removeCanvasGenerationDialogById(dialogId);
|
||
}
|
||
setActiveTool('select');
|
||
nextLayers.forEach((layer) => createProjectResourceForLayer(layer));
|
||
};
|
||
|
||
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 handleWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
const viewportElement = canvasViewportRef.current;
|
||
if (!viewportElement) {
|
||
return;
|
||
}
|
||
|
||
if (!event.ctrlKey && !event.metaKey) {
|
||
setViewport((currentViewport) => ({
|
||
...currentViewport,
|
||
y: currentViewport.y - event.deltaY,
|
||
}));
|
||
return;
|
||
}
|
||
|
||
const rect = viewportElement.getBoundingClientRect();
|
||
const pointerX = event.clientX - rect.left;
|
||
const pointerY = event.clientY - rect.top;
|
||
const scaleMultiplier = event.deltaY > 0 ? 0.9 : 1.1;
|
||
|
||
setViewport((currentViewport) => {
|
||
const nextScale = clamp(
|
||
currentViewport.scale * scaleMultiplier,
|
||
MIN_SCALE,
|
||
MAX_SCALE,
|
||
);
|
||
const worldX = (pointerX - currentViewport.x) / currentViewport.scale;
|
||
const worldY = (pointerY - currentViewport.y) / currentViewport.scale;
|
||
|
||
return {
|
||
x: pointerX - worldX * nextScale,
|
||
y: pointerY - worldY * nextScale,
|
||
scale: nextScale,
|
||
};
|
||
});
|
||
};
|
||
|
||
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 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) {
|
||
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 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;
|
||
}
|
||
const localX = clamp(clientX - rect.left, 0, MINIMAP_SIZE.width);
|
||
const localY = clamp(clientY - rect.top, 0, MINIMAP_SIZE.height);
|
||
const worldX =
|
||
minimapModel.bounds.minX +
|
||
(localX - MINIMAP_PADDING) / minimapModel.scale;
|
||
const worldY =
|
||
minimapModel.bounds.minY +
|
||
(localY - MINIMAP_PADDING) / minimapModel.scale;
|
||
setViewport((currentViewport) => ({
|
||
...currentViewport,
|
||
x: canvasSize.width / 2 - worldX * currentViewport.scale,
|
||
y: canvasSize.height / 2 - worldY * currentViewport.scale,
|
||
}));
|
||
};
|
||
|
||
const moveViewportFromMinimapDrag = (
|
||
dragState: Extract<DragState, { kind: 'minimap' }>,
|
||
clientX: number,
|
||
clientY: number,
|
||
) => {
|
||
const deltaWorldX =
|
||
((clientX - dragState.startClientX) / dragState.minimapScale) *
|
||
MINIMAP_DRAG_SENSITIVITY;
|
||
const deltaWorldY =
|
||
((clientY - dragState.startClientY) / dragState.minimapScale) *
|
||
MINIMAP_DRAG_SENSITIVITY;
|
||
setViewport({
|
||
...dragState.startViewport,
|
||
x:
|
||
dragState.startViewport.x -
|
||
deltaWorldX * dragState.startViewport.scale,
|
||
y:
|
||
dragState.startViewport.y -
|
||
deltaWorldY * dragState.startViewport.scale,
|
||
});
|
||
};
|
||
|
||
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 left = Math.min(canvasMarquee.startX, currentX);
|
||
const right = Math.max(canvasMarquee.startX, currentX);
|
||
const top = Math.min(canvasMarquee.startY, currentY);
|
||
const bottom = Math.max(canvasMarquee.startY, currentY);
|
||
const selectedIds = layers
|
||
.filter((layer) => {
|
||
const layerLeft = viewport.x + layer.x * viewport.scale;
|
||
const layerTop = viewport.y + layer.y * viewport.scale;
|
||
const layerRight = layerLeft + layer.width * viewport.scale;
|
||
const layerBottom = layerTop + layer.height * viewport.scale;
|
||
return (
|
||
layerLeft <= right &&
|
||
layerRight >= left &&
|
||
layerTop <= bottom &&
|
||
layerBottom >= top
|
||
);
|
||
})
|
||
.map((layer) => layer.id);
|
||
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({
|
||
...dragState.startViewport,
|
||
x: dragState.startViewport.x + pointer.x - dragState.startClientX,
|
||
y: dragState.startViewport.y + pointer.y - dragState.startClientY,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (dragState.kind === 'generation-frame') {
|
||
const pointer = getPointerClient(event);
|
||
const deltaX =
|
||
(pointer.x - dragState.startClientX) / dragState.startScale;
|
||
const deltaY =
|
||
(pointer.y - dragState.startClientY) / dragState.startScale;
|
||
updateCanvasGenerationDialogById(dragState.dialogId, (currentDialog) =>
|
||
currentDialog.placeholder
|
||
? {
|
||
...currentDialog,
|
||
placeholder: {
|
||
...currentDialog.placeholder,
|
||
x: dragState.startFrameX + deltaX,
|
||
y: dragState.startFrameY + deltaY,
|
||
},
|
||
}
|
||
: 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) {
|
||
moveViewportFromMinimapDrag(dragState, pointer.x, pointer.y);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const movingLayer = layers.find((layer) => layer.id === dragState.layerId);
|
||
if (!movingLayer) {
|
||
return;
|
||
}
|
||
const pointer = getPointerClient(event);
|
||
const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale;
|
||
const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale;
|
||
const snapped = resolveSnappedLayerPosition(
|
||
movingLayer,
|
||
dragState.startLayerX + deltaX,
|
||
dragState.startLayerY + deltaY,
|
||
layers,
|
||
dragState.startScale,
|
||
);
|
||
setSnapGuide(snapped.guide);
|
||
setLayers((currentLayers) =>
|
||
currentLayers.map((layer) =>
|
||
dragState.layerIds.includes(layer.id)
|
||
? (() => {
|
||
const startLayer = dragState.startLayers.find(
|
||
(item) => item.id === layer.id,
|
||
);
|
||
if (!startLayer) {
|
||
return layer;
|
||
}
|
||
if (layer.id === dragState.layerId) {
|
||
return {
|
||
...layer,
|
||
x: snapped.x,
|
||
y: snapped.y,
|
||
};
|
||
}
|
||
return {
|
||
...layer,
|
||
x:
|
||
startLayer.x +
|
||
deltaX +
|
||
(snapped.x - (dragState.startLayerX + deltaX)),
|
||
y:
|
||
startLayer.y +
|
||
deltaY +
|
||
(snapped.y - (dragState.startLayerY + deltaY)),
|
||
};
|
||
})()
|
||
: layer,
|
||
),
|
||
);
|
||
};
|
||
|
||
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 toolButtons = [
|
||
{ label: '裁剪', icon: Crop },
|
||
{ label: '重绘', icon: Sparkles },
|
||
{ label: '调整', icon: SlidersHorizontal },
|
||
{ label: '复制', icon: Copy },
|
||
];
|
||
|
||
const canvasTools: Array<{
|
||
id: CanvasTool;
|
||
label: string;
|
||
icon: typeof MousePointer2;
|
||
}> = [
|
||
{ id: 'select', label: '选择工具', icon: MousePointer2 },
|
||
{ id: 'hand', label: '抓手工具', icon: Hand },
|
||
{ id: 'upload', label: '上传工具', icon: ImagePlus },
|
||
{ id: 'generate', label: '生成工具', icon: WandSparkles },
|
||
{ id: 'spec', label: '生成规范', icon: ClipboardList },
|
||
{ id: 'character', label: '生成角色形象', icon: Sparkles },
|
||
{ id: 'icon', label: '生成图标素材', icon: ImageIcon },
|
||
{ id: 'text', label: '文字工具', icon: Type },
|
||
{ id: 'shape', label: '形状标注工具', icon: Shapes },
|
||
{ id: 'export', label: '导出工具', icon: Download },
|
||
];
|
||
|
||
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>
|
||
|
||
<div
|
||
ref={canvasViewportRef}
|
||
className={`image-canvas-editor__viewport ${isPanning ? 'image-canvas-editor__viewport--panning' : ''} image-canvas-editor__viewport--tool-${effectiveTool}`}
|
||
style={{ backgroundColor: canvasBackgroundColor }}
|
||
aria-label="画布工作区"
|
||
onPointerDown={handleCanvasPointerDown}
|
||
onPointerMove={handlePointerMove}
|
||
onPointerUp={finishDrag}
|
||
onPointerCancel={finishDrag}
|
||
onWheel={handleWheel}
|
||
onDragOver={handleCanvasDragOver}
|
||
onDragLeave={handleCanvasDragLeave}
|
||
onDrop={handleCanvasDrop}
|
||
onContextMenu={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const position = resolveContextMenuPosition(
|
||
event.clientX,
|
||
event.clientY,
|
||
'blank',
|
||
);
|
||
setImageContextMenu(null);
|
||
setContextMenu({
|
||
kind: 'blank',
|
||
...position,
|
||
canvasPoint: getCanvasPointFromClient(event.clientX, event.clientY),
|
||
});
|
||
}}
|
||
>
|
||
{uploadDropTarget === 'canvas' ? (
|
||
<div
|
||
className="image-canvas-editor__upload-drop-overlay image-canvas-editor__upload-drop-overlay--canvas"
|
||
role="status"
|
||
>
|
||
<span>添加到画布</span>
|
||
<strong>松开即可添加</strong>
|
||
</div>
|
||
) : null}
|
||
<div
|
||
className="image-canvas-editor__world"
|
||
style={{
|
||
width: CANVAS_WORLD_SIZE,
|
||
height: CANVAS_WORLD_SIZE,
|
||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
|
||
}}
|
||
>
|
||
{snapGuide?.vertical !== undefined ? (
|
||
<div
|
||
className="image-canvas-editor__snap-guide image-canvas-editor__snap-guide--vertical"
|
||
data-testid="image-canvas-editor-snap-guide-vertical"
|
||
style={{ left: snapGuide.vertical }}
|
||
/>
|
||
) : null}
|
||
{snapGuide?.horizontal !== undefined ? (
|
||
<div
|
||
className="image-canvas-editor__snap-guide image-canvas-editor__snap-guide--horizontal"
|
||
data-testid="image-canvas-editor-snap-guide-horizontal"
|
||
style={{ top: snapGuide.horizontal }}
|
||
/>
|
||
) : null}
|
||
|
||
{layers
|
||
.slice()
|
||
.filter((layer) => !layer.hidden)
|
||
.sort((left, right) => left.zIndex - right.zIndex)
|
||
.map((layer) => {
|
||
const isSelected = selectedLayerIds.includes(layer.id);
|
||
const isHovered = hoveredLayerId === layer.id;
|
||
const kindLabel = getLayerKindLabel(layer);
|
||
const layerGeneratingLabel =
|
||
generateDialog?.mode === 'edit' &&
|
||
generateDialog.status === 'generating' &&
|
||
generateDialog.sourceLayerId === layer.id
|
||
? '修改中'
|
||
: quickEditPanel?.status === 'generating' &&
|
||
quickEditPanel.sourceLayerId === layer.id
|
||
? '生成中'
|
||
: null;
|
||
return (
|
||
<button
|
||
key={layer.id}
|
||
type="button"
|
||
className={`image-canvas-editor__layer ${isSelected ? 'image-canvas-editor__layer--selected' : ''} ${isHovered ? 'image-canvas-editor__layer--hovered' : ''} ${layerGeneratingLabel ? 'image-canvas-editor__layer--generating' : ''} ${layer.locked ? 'image-canvas-editor__layer--locked' : ''}`}
|
||
style={{
|
||
left: layer.x,
|
||
top: layer.y,
|
||
width: layer.width,
|
||
height: layer.height,
|
||
zIndex: layer.zIndex,
|
||
display: layer.hidden ? 'none' : undefined,
|
||
}}
|
||
onPointerDown={(event) =>
|
||
handleLayerPointerDown(event, layer)
|
||
}
|
||
onClick={(event) => {
|
||
// 测试环境和辅助技术可能只触发 click;
|
||
// 用 click 兜底选中,真实拖拽仍由 pointerDown 负责。
|
||
event.stopPropagation();
|
||
if (isPickingCharacterSpecFromCanvas) {
|
||
return;
|
||
}
|
||
if (isPickingIconSpecFromCanvas) {
|
||
return;
|
||
}
|
||
if (event.shiftKey || isShiftPressedRef.current) {
|
||
return;
|
||
}
|
||
selectSingleLayer(layer.id);
|
||
setImageContextMenu(null);
|
||
}}
|
||
onContextMenu={(event) => {
|
||
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,
|
||
});
|
||
}}
|
||
onMouseEnter={() => setHoveredLayerId(layer.id)}
|
||
onMouseLeave={() =>
|
||
setHoveredLayerId((currentId) =>
|
||
currentId === layer.id ? null : currentId,
|
||
)
|
||
}
|
||
aria-label={`选择${layer.title}`}
|
||
>
|
||
<img
|
||
src={layer.src}
|
||
alt={`画布图片:${layer.title}`}
|
||
style={{
|
||
transform:
|
||
layer.flipX || layer.flipY
|
||
? `scale(${layer.flipX ? -1 : 1}, ${
|
||
layer.flipY ? -1 : 1
|
||
})`
|
||
: undefined,
|
||
}}
|
||
/>
|
||
{kindLabel ? (
|
||
<span
|
||
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${layer.assetKind}`}
|
||
>
|
||
{kindLabel}
|
||
</span>
|
||
) : null}
|
||
<PlatformIconButton
|
||
asChild="spanButton"
|
||
variant="darkMini"
|
||
className={`image-canvas-editor__metadata-corner ${
|
||
kindLabel
|
||
? 'image-canvas-editor__metadata-corner--beside-kind'
|
||
: ''
|
||
}`}
|
||
label={`查看${layer.title}图片信息`}
|
||
icon={<Braces className="h-3 w-3" />}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setMetadataLayer(layer);
|
||
selectSingleLayer(layer.id);
|
||
}}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
/>
|
||
{isHovered ? (
|
||
<PlatformPillBadge
|
||
tone="lightOverlay"
|
||
size="xs"
|
||
className="image-canvas-editor__size-badge"
|
||
>
|
||
{Math.round(layer.originalWidth)} x{' '}
|
||
{Math.round(layer.originalHeight)} px
|
||
</PlatformPillBadge>
|
||
) : null}
|
||
{layerGeneratingLabel ? (
|
||
<span
|
||
className="image-canvas-editor__generation-frame-progress image-canvas-editor__layer-generating-progress"
|
||
role="status"
|
||
>
|
||
{layerGeneratingLabel}
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
);
|
||
})}
|
||
{canvasMarquee ? (
|
||
<div
|
||
className="image-canvas-editor__canvas-marquee"
|
||
aria-hidden="true"
|
||
style={{
|
||
left:
|
||
(Math.min(canvasMarquee.startX, canvasMarquee.currentX) -
|
||
viewport.x) /
|
||
viewport.scale,
|
||
top:
|
||
(Math.min(canvasMarquee.startY, canvasMarquee.currentY) -
|
||
viewport.y) /
|
||
viewport.scale,
|
||
width:
|
||
Math.abs(canvasMarquee.currentX - canvasMarquee.startX) /
|
||
viewport.scale,
|
||
height:
|
||
Math.abs(canvasMarquee.currentY - canvasMarquee.startY) /
|
||
viewport.scale,
|
||
}}
|
||
/>
|
||
) : null}
|
||
{canvasGenerationDialogs.map((dialog) =>
|
||
dialog.placeholder ? (
|
||
<div
|
||
key={dialog.id}
|
||
className={`image-canvas-editor__generation-frame ${
|
||
dialog.mode === 'icon'
|
||
? 'image-canvas-editor__generation-frame--icon'
|
||
: ''
|
||
} ${
|
||
dialog.status === 'generating'
|
||
? 'image-canvas-editor__generation-frame--generating'
|
||
: ''
|
||
}`}
|
||
role="button"
|
||
tabIndex={0}
|
||
style={{
|
||
left: dialog.placeholder.x,
|
||
top: dialog.placeholder.y,
|
||
width: dialog.placeholder.width,
|
||
height: dialog.placeholder.height,
|
||
}}
|
||
aria-label={getGenerationFrameAriaLabel(dialog)}
|
||
onPointerDown={(event) =>
|
||
handleGenerationFramePointerDown(event, dialog)
|
||
}
|
||
onDoubleClick={() => activateCanvasGenerationDialog(dialog)}
|
||
>
|
||
<span className="image-canvas-editor__generation-frame-label">
|
||
<ImageIcon className="h-4 w-4" />
|
||
{getGenerationFrameLabel(dialog)}
|
||
</span>
|
||
{dialog.mode === 'character' ? (
|
||
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--character">
|
||
角色
|
||
</span>
|
||
) : null}
|
||
{dialog.mode === 'spec' ? (
|
||
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--spec">
|
||
规范
|
||
</span>
|
||
) : null}
|
||
{dialog.mode === 'icon' ? (
|
||
<span className="image-canvas-editor__kind-badge image-canvas-editor__kind-badge--icon">
|
||
图标
|
||
</span>
|
||
) : null}
|
||
<span className="image-canvas-editor__generation-frame-size">
|
||
{dialog.placeholder.originalWidth} x{' '}
|
||
{dialog.placeholder.originalHeight}
|
||
</span>
|
||
<span className="image-canvas-editor__generation-frame-icon">
|
||
<ImageIcon className="h-8 w-8" />
|
||
</span>
|
||
{dialog.status === 'generating' ? (
|
||
<span
|
||
className="image-canvas-editor__generation-frame-progress"
|
||
role="status"
|
||
>
|
||
生成中
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
) : null,
|
||
)}
|
||
{(generateDialog?.mode === 'generate' ||
|
||
generateDialog?.mode === 'spec' ||
|
||
generateDialog?.mode === 'character' ||
|
||
generateDialog?.mode === 'icon') &&
|
||
generateDialog.status === 'generating' &&
|
||
generationComposerStyle ? (
|
||
<PlatformStatusMessage
|
||
tone="info"
|
||
surface="platform"
|
||
size="xs"
|
||
className="image-canvas-editor__generate-status image-canvas-editor__generate-status--floating"
|
||
role="status"
|
||
style={generationComposerStyle}
|
||
>
|
||
生成中
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
</div>
|
||
|
||
{selectedLayer && selectedToolbarStyle ? (
|
||
<div
|
||
className="image-canvas-editor__floating-toolbar"
|
||
style={selectedToolbarStyle}
|
||
role="toolbar"
|
||
aria-label="图片工具栏"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
>
|
||
{toolButtons.map(({ label, icon: Icon }) => (
|
||
<EditorIconButton
|
||
key={label}
|
||
label={`${label}占位`}
|
||
title={`${label}占位`}
|
||
icon={Icon}
|
||
onClick={() => triggerPlaceholderAction(label)}
|
||
/>
|
||
))}
|
||
<EditorIconButton
|
||
label="删除图片"
|
||
title="删除图片"
|
||
icon={Trash2}
|
||
onClick={deleteSelectedLayer}
|
||
/>
|
||
<EditorIconButton
|
||
label="快速编辑"
|
||
title="快速编辑"
|
||
icon={Sparkles}
|
||
onClick={() => openQuickEditPanel(selectedLayer)}
|
||
/>
|
||
{isGeneratedLayer(selectedLayer) ? (
|
||
<>
|
||
<EditorIconButton
|
||
label={`查看${selectedLayer.title}图片信息`}
|
||
title={`查看${selectedLayer.title}图片信息`}
|
||
icon={Info}
|
||
onClick={() => setMetadataLayer(selectedLayer)}
|
||
/>
|
||
<EditorIconButton
|
||
label="修改图片"
|
||
title="修改图片"
|
||
icon={WandSparkles}
|
||
onClick={() => openEditDialog(selectedLayer)}
|
||
/>
|
||
</>
|
||
) : null}
|
||
{selectedLayer.assetKind === 'character' ? (
|
||
<EditorIconButton
|
||
label="生成动画"
|
||
title="生成动画"
|
||
icon={Sparkles}
|
||
onClick={() => openCharacterAnimationPanel(selectedLayer)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{contextMenu ? (
|
||
<div
|
||
className="image-canvas-editor__context-menu"
|
||
role="menu"
|
||
aria-label={
|
||
contextMenu.kind === 'blank' ? '画布右键菜单' : '图片功能面板'
|
||
}
|
||
style={{
|
||
left: contextMenu.x,
|
||
top: contextMenu.y,
|
||
}}
|
||
onContextMenu={(event) => event.preventDefault()}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
>
|
||
{contextMenu.kind === 'blank' ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
disabled={!canvasClipboard?.layers.length}
|
||
onClick={() => pasteCanvasClipboard(contextMenu.canvasPoint)}
|
||
>
|
||
粘贴
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
updateScaleFromCenter(viewport.scale * 1.16);
|
||
setContextMenu(null);
|
||
}}
|
||
>
|
||
放大
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
updateScaleFromCenter(viewport.scale * 0.86);
|
||
setContextMenu(null);
|
||
}}
|
||
>
|
||
缩小
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
fitLayers();
|
||
setContextMenu(null);
|
||
}}
|
||
>
|
||
显示画布所有元素
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button type="button" role="menuitem" onClick={() => copyContextLayers()}>
|
||
复制
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => copyContextLayers({ cut: true })}
|
||
>
|
||
剪切
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
disabled={!canvasClipboard?.layers.length}
|
||
onClick={() => pasteCanvasClipboard(contextMenu.canvasPoint)}
|
||
>
|
||
粘贴
|
||
</button>
|
||
<button type="button" role="menuitem" onClick={duplicateContextLayers}>
|
||
创建副本
|
||
</button>
|
||
<hr />
|
||
<button type="button" role="menuitem" onClick={() => moveContextLayers('up')}>
|
||
上移一层
|
||
</button>
|
||
<button type="button" role="menuitem" onClick={() => moveContextLayers('down')}>
|
||
下移一层
|
||
</button>
|
||
<button type="button" role="menuitem" onClick={() => moveContextLayers('top')}>
|
||
置于顶层
|
||
</button>
|
||
<button type="button" role="menuitem" onClick={() => moveContextLayers('bottom')}>
|
||
移动至底层
|
||
</button>
|
||
<hr />
|
||
<button type="button" role="menuitem" onClick={groupContextLayers}>
|
||
创建组
|
||
</button>
|
||
<button type="button" role="menuitem" onClick={ungroupContextLayers}>
|
||
解除组
|
||
</button>
|
||
<button type="button" role="menuitem" onClick={toggleContextLayerVisibility}>
|
||
{contextShouldShowLayer ? '显示' : '隐藏'}
|
||
</button>
|
||
<button type="button" role="menuitem" onClick={toggleContextLayerLock}>
|
||
{contextShouldUnlockLayer ? '解锁' : '锁定'}
|
||
</button>
|
||
<hr />
|
||
<button type="button" role="menuitem" onClick={() => flipContextLayers('x')}>
|
||
水平翻转
|
||
</button>
|
||
<button type="button" role="menuitem" onClick={() => flipContextLayers('y')}>
|
||
垂直翻转
|
||
</button>
|
||
<button type="button" role="menuitem" onClick={exportContextLayer}>
|
||
导出为
|
||
</button>
|
||
<hr />
|
||
{imageContextMenuLayer ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => openQuickEditPanel(imageContextMenuLayer)}
|
||
>
|
||
快速编辑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setMetadataLayer(imageContextMenuLayer);
|
||
setContextMenu(null);
|
||
setImageContextMenu(null);
|
||
}}
|
||
>
|
||
查看图片信息
|
||
</button>
|
||
{imageContextMenuLayer.assetKind === 'character' ? (
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={() =>
|
||
openCharacterAnimationPanel(imageContextMenuLayer)
|
||
}
|
||
>
|
||
生成动画
|
||
</button>
|
||
) : null}
|
||
<hr />
|
||
</>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
className="image-canvas-editor__context-menu-danger"
|
||
onClick={deleteContextLayers}
|
||
>
|
||
删除
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
<EditorIconButton
|
||
className="image-canvas-editor__reset-button"
|
||
label="重置画布视图"
|
||
title="重置画布视图"
|
||
icon={RotateCcw}
|
||
onClick={() => fitLayers()}
|
||
/>
|
||
|
||
<div
|
||
className="image-canvas-editor__panel-dock"
|
||
role="toolbar"
|
||
aria-label="画布面板入口"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
>
|
||
<EditorIconButton
|
||
label="撤销"
|
||
title="撤销"
|
||
icon={Undo2}
|
||
disabled={!canUndo}
|
||
onClick={undoCanvasChange}
|
||
/>
|
||
<EditorIconButton
|
||
label="重做"
|
||
title="重做"
|
||
icon={Redo2}
|
||
disabled={!canRedo}
|
||
onClick={redoCanvasChange}
|
||
/>
|
||
<div className="image-canvas-editor__zoom-menu-wrap">
|
||
<PlatformInlineOptionButton
|
||
className="image-canvas-editor__zoom-trigger"
|
||
aria-label={`当前缩放比例 ${formatPercent(viewport.scale)}`}
|
||
aria-haspopup="menu"
|
||
aria-expanded={isZoomMenuOpen}
|
||
onClick={() => setIsZoomMenuOpen((open) => !open)}
|
||
>
|
||
{formatPercent(viewport.scale)}
|
||
</PlatformInlineOptionButton>
|
||
{isZoomMenuOpen ? (
|
||
<PlatformFloatingMenu label="缩放菜单" placement="top-start">
|
||
<PlatformFloatingMenuItem
|
||
className="image-canvas-editor__zoom-menu-item"
|
||
onClick={() => {
|
||
updateScaleFromCenter(viewport.scale * 1.16);
|
||
setIsZoomMenuOpen(false);
|
||
}}
|
||
>
|
||
放大
|
||
</PlatformFloatingMenuItem>
|
||
<PlatformFloatingMenuItem
|
||
className="image-canvas-editor__zoom-menu-item"
|
||
onClick={() => {
|
||
updateScaleFromCenter(viewport.scale * 0.86);
|
||
setIsZoomMenuOpen(false);
|
||
}}
|
||
>
|
||
缩小
|
||
</PlatformFloatingMenuItem>
|
||
<PlatformFloatingMenuItem
|
||
className="image-canvas-editor__zoom-menu-item"
|
||
onClick={() => {
|
||
fitLayers();
|
||
setIsZoomMenuOpen(false);
|
||
}}
|
||
>
|
||
显示画布所有元素
|
||
</PlatformFloatingMenuItem>
|
||
{[0.5, 1, 2].map((scale) => (
|
||
<PlatformFloatingMenuItem
|
||
key={scale}
|
||
className="image-canvas-editor__zoom-menu-item"
|
||
onClick={() => {
|
||
updateScaleFromCenter(scale);
|
||
setIsZoomMenuOpen(false);
|
||
}}
|
||
>
|
||
缩放至{Math.round(scale * 100)}%
|
||
</PlatformFloatingMenuItem>
|
||
))}
|
||
</PlatformFloatingMenu>
|
||
) : null}
|
||
</div>
|
||
<div className="image-canvas-editor__background-control">
|
||
<PlatformIconButton
|
||
label="画布背景色"
|
||
title="画布背景色"
|
||
aria-expanded={isBackgroundSettingsOpen}
|
||
onClick={() =>
|
||
setIsBackgroundSettingsOpen((isOpen) => !isOpen)
|
||
}
|
||
icon={
|
||
<span
|
||
className="image-canvas-editor__background-swatch-current"
|
||
style={{ backgroundColor: canvasBackgroundColor }}
|
||
/>
|
||
}
|
||
/>
|
||
{isBackgroundSettingsOpen ? (
|
||
<div
|
||
className="image-canvas-editor__background-panel"
|
||
role="dialog"
|
||
aria-label="画布背景设置"
|
||
>
|
||
<div className="image-canvas-editor__background-panel-head">
|
||
<span>画布背景</span>
|
||
<span
|
||
className="image-canvas-editor__background-preview"
|
||
aria-label={`当前画布背景色 ${canvasBackgroundColor}`}
|
||
style={{ backgroundColor: canvasBackgroundColor }}
|
||
/>
|
||
</div>
|
||
<div
|
||
className="image-canvas-editor__background-presets"
|
||
aria-label="画布背景预设色"
|
||
>
|
||
{CANVAS_BACKGROUND_OPTIONS.map((option) => (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
className="image-canvas-editor__background-preset"
|
||
aria-label={option.label}
|
||
aria-pressed={canvasBackgroundColor === option.value}
|
||
onClick={() => applyCanvasBackgroundColor(option.value)}
|
||
>
|
||
<span
|
||
className="image-canvas-editor__background-swatch"
|
||
style={{ backgroundColor: option.value }}
|
||
/>
|
||
<span>{option.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<label className="image-canvas-editor__background-field">
|
||
<span>自定义</span>
|
||
<input
|
||
type="color"
|
||
aria-label="自定义画布背景色"
|
||
value={canvasBackgroundColor}
|
||
onChange={(event) =>
|
||
applyCanvasBackgroundColor(event.currentTarget.value)
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="image-canvas-editor__background-field image-canvas-editor__background-field--hex">
|
||
<span>HEX</span>
|
||
<PlatformTextField
|
||
aria-label="画布背景十六进制颜色"
|
||
value={canvasBackgroundHexValue}
|
||
density="compact"
|
||
size="xs"
|
||
spellCheck={false}
|
||
onChange={(event) => {
|
||
const nextValue = event.currentTarget.value;
|
||
setCanvasBackgroundHexValue(nextValue);
|
||
const normalizedColor =
|
||
normalizeCanvasBackgroundHex(nextValue);
|
||
if (normalizedColor) {
|
||
setCanvasBackgroundColor(normalizedColor);
|
||
setCanvasBackgroundHexValue(normalizedColor);
|
||
}
|
||
}}
|
||
/>
|
||
</label>
|
||
<PlatformActionButton
|
||
tone="secondary"
|
||
size="xs"
|
||
className="image-canvas-editor__background-reset"
|
||
onClick={() =>
|
||
applyCanvasBackgroundColor(DEFAULT_CANVAS_BACKGROUND_COLOR)
|
||
}
|
||
>
|
||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||
恢复默认
|
||
</PlatformActionButton>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<EditorIconButton
|
||
label="打开素材"
|
||
title="素材"
|
||
icon={ImagePlus}
|
||
pressed={activeSidebarPanel === 'assets'}
|
||
onClick={() => toggleSidebarPanel('assets')}
|
||
/>
|
||
<EditorIconButton
|
||
label="打开图层"
|
||
title="图层"
|
||
icon={Layers}
|
||
pressed={activeSidebarPanel === 'layers'}
|
||
onClick={() => toggleSidebarPanel('layers')}
|
||
/>
|
||
<EditorIconButton
|
||
label="切换小地图"
|
||
title="小地图"
|
||
icon={MapIcon}
|
||
pressed={isMinimapOpen}
|
||
onClick={() => setIsMinimapOpen((open) => !open)}
|
||
/>
|
||
</div>
|
||
|
||
{isMinimapOpen && minimapModel ? (
|
||
<button
|
||
type="button"
|
||
className="image-canvas-editor__minimap"
|
||
aria-label="画布小地图"
|
||
title="拖拽移动视图"
|
||
onPointerDown={handleMinimapPointerDown}
|
||
>
|
||
<span className="image-canvas-editor__minimap-stage">
|
||
{minimapModel.layers.map((layer) => (
|
||
<span
|
||
key={layer.id}
|
||
className="image-canvas-editor__minimap-layer"
|
||
title={layer.title}
|
||
style={layer.rect}
|
||
/>
|
||
))}
|
||
<span
|
||
className="image-canvas-editor__minimap-viewport"
|
||
style={minimapModel.viewport}
|
||
/>
|
||
</span>
|
||
</button>
|
||
) : null}
|
||
|
||
<div
|
||
className="image-canvas-editor__bottom-toolbar"
|
||
role="toolbar"
|
||
aria-label="AI画布工具栏"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
>
|
||
{canvasTools.map(({ id, label, icon: Icon }) =>
|
||
id === 'spec' ? (
|
||
<span
|
||
key={id}
|
||
ref={specToolWrapRef}
|
||
className="image-canvas-editor__spec-tool-wrap"
|
||
>
|
||
<EditorIconButton
|
||
label={label}
|
||
title={label}
|
||
icon={Icon}
|
||
pressed={effectiveTool === id}
|
||
onClick={() => switchTool(id)}
|
||
/>
|
||
</span>
|
||
) : (
|
||
<EditorIconButton
|
||
key={id}
|
||
label={label}
|
||
title={label}
|
||
icon={Icon}
|
||
pressed={effectiveTool === id}
|
||
onClick={() => switchTool(id)}
|
||
/>
|
||
),
|
||
)}
|
||
</div>
|
||
|
||
{isSpecMenuOpen
|
||
? renderEditorPortal(
|
||
<PlatformFloatingMenu
|
||
className="image-canvas-editor__spec-menu image-canvas-editor__portal-menu"
|
||
label="生成规范类型"
|
||
placement="top-start"
|
||
style={buildPortalMenuStyle(specToolWrapRef.current, 'above')}
|
||
>
|
||
{(['character', 'ui', 'custom'] as const).map((specType) => (
|
||
<PlatformFloatingMenuItem
|
||
key={specType}
|
||
className="image-canvas-editor__spec-menu-item"
|
||
onClick={() => openSpecDialog(specType)}
|
||
>
|
||
{SPEC_TYPE_LABEL[specType]}
|
||
</PlatformFloatingMenuItem>
|
||
))}
|
||
</PlatformFloatingMenu>,
|
||
)
|
||
: null}
|
||
|
||
{generateDialog?.mode === 'generate' &&
|
||
generateDialog.composerOpen !== false &&
|
||
generationComposerStyle ? (
|
||
<form
|
||
className="image-canvas-editor__generation-composer"
|
||
style={generationComposerStyle}
|
||
role="dialog"
|
||
aria-label="生成图片"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
if (generateDialog.status !== 'generating') {
|
||
void submitImageGeneration(generateDialog);
|
||
}
|
||
}}
|
||
>
|
||
<PlatformIconButton
|
||
variant="surfaceFloating"
|
||
className="image-canvas-editor__generation-ref"
|
||
label="添加参考图"
|
||
disabled={generateDialog.status === 'generating'}
|
||
onClick={() => {
|
||
setUploadTarget('asset');
|
||
uploadInputRef.current?.click();
|
||
}}
|
||
icon={<ImageIcon className="h-4 w-4" />}
|
||
>
|
||
<span>参考图</span>
|
||
</PlatformIconButton>
|
||
<PlatformTextField
|
||
variant="textarea"
|
||
aria-label="生成提示词"
|
||
value={generateDialog.prompt}
|
||
disabled={generateDialog.status === 'generating'}
|
||
placeholder="今天我们要创作什么"
|
||
size="sm"
|
||
density="compact"
|
||
className="image-canvas-editor__generation-prompt"
|
||
onChange={(event) =>
|
||
setGenerateDialog((currentDialog) =>
|
||
currentDialog
|
||
? {
|
||
...currentDialog,
|
||
prompt: event.target.value,
|
||
status:
|
||
currentDialog.status === 'failed'
|
||
? 'idle'
|
||
: currentDialog.status,
|
||
errorMessage:
|
||
currentDialog.status === 'failed'
|
||
? undefined
|
||
: currentDialog.errorMessage,
|
||
}
|
||
: currentDialog,
|
||
)
|
||
}
|
||
/>
|
||
<div className="image-canvas-editor__generation-composer-footer">
|
||
<PlatformInlineOptionButton
|
||
className="image-canvas-editor__generation-ratio"
|
||
aria-label="生成比例 1:1 2k 1张"
|
||
disabled={generateDialog.status === 'generating'}
|
||
onClick={() => triggerPlaceholderAction('生成参数')}
|
||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||
>
|
||
中 · 1:1(2k) · 1张
|
||
</PlatformInlineOptionButton>
|
||
<PlatformInlineOptionButton
|
||
className="image-canvas-editor__generation-model"
|
||
aria-label="生成模型 GPT Image"
|
||
disabled={generateDialog.status === 'generating'}
|
||
onClick={() => triggerPlaceholderAction('模型选择')}
|
||
trailingIcon={<ChevronDown className="h-3 w-3" />}
|
||
>
|
||
GPT Im...
|
||
</PlatformInlineOptionButton>
|
||
<PlatformActionButton
|
||
type="submit"
|
||
tone="secondary"
|
||
size="xs"
|
||
shape="pill"
|
||
className="image-canvas-editor__generation-submit"
|
||
disabled={generateDialog.status === 'generating'}
|
||
aria-label="生成"
|
||
>
|
||
{generateDialog.status === 'generating' ? '生成中' : '12'}
|
||
</PlatformActionButton>
|
||
</div>
|
||
{generateDialog.status === 'generating' ? (
|
||
<PlatformStatusMessage
|
||
tone="info"
|
||
surface="platform"
|
||
size="xs"
|
||
className="image-canvas-editor__generate-status"
|
||
role="status"
|
||
>
|
||
生成中
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
{generateDialog.status === 'failed' ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="platform"
|
||
size="xs"
|
||
className="image-canvas-editor__generate-status"
|
||
role="alert"
|
||
>
|
||
{generateDialog.errorMessage}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
<EditorIconButton
|
||
className="image-canvas-editor__generation-close"
|
||
label="关闭生成图片"
|
||
icon={X}
|
||
variant="surfaceFloating"
|
||
disabled={generateDialog.status === 'generating'}
|
||
onClick={() => {
|
||
setGenerateDialog((currentDialog) =>
|
||
currentDialog?.mode === 'generate'
|
||
? {
|
||
...currentDialog,
|
||
composerOpen: false,
|
||
}
|
||
: currentDialog,
|
||
);
|
||
setActiveTool('select');
|
||
}}
|
||
/>
|
||
</form>
|
||
) : null}
|
||
|
||
{generateDialog?.mode === 'spec' &&
|
||
generateDialog.composerOpen !== false &&
|
||
generationComposerStyle ? (
|
||
<form
|
||
className="image-canvas-editor__generation-composer image-canvas-editor__spec-composer"
|
||
style={generationComposerStyle}
|
||
role="dialog"
|
||
aria-label="生成规范"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
if (generateDialog.status !== 'generating') {
|
||
void submitImageGeneration(generateDialog);
|
||
}
|
||
}}
|
||
>
|
||
<div className="image-canvas-editor__spec-fields">
|
||
{generateDialog.specType === 'custom' ? (
|
||
<label className="image-canvas-editor__field-block">
|
||
<PlatformFieldLabel
|
||
variant="form"
|
||
className="image-canvas-editor__field-title"
|
||
>
|
||
自定义规范提示词
|
||
</PlatformFieldLabel>
|
||
<PlatformTextField
|
||
variant="textarea"
|
||
aria-label="自定义规范提示词"
|
||
value={generateDialog.specValues?.customPrompt ?? ''}
|
||
disabled={generateDialog.status === 'generating'}
|
||
size="sm"
|
||
density="compact"
|
||
className="image-canvas-editor__spec-textarea"
|
||
onChange={(event) =>
|
||
updateSpecFormValue('customPrompt', event.target.value)
|
||
}
|
||
/>
|
||
</label>
|
||
) : (
|
||
<>
|
||
<label className="image-canvas-editor__field-block">
|
||
<PlatformFieldLabel
|
||
variant="form"
|
||
className="image-canvas-editor__field-title"
|
||
>
|
||
玩法设定
|
||
</PlatformFieldLabel>
|
||
<PlatformTextField
|
||
aria-label="玩法设定"
|
||
value={generateDialog.specValues?.playSetting ?? ''}
|
||
disabled={generateDialog.status === 'generating'}
|
||
size="sm"
|
||
density="compact"
|
||
className="image-canvas-editor__spec-input"
|
||
onChange={(event) =>
|
||
updateSpecFormValue('playSetting', event.target.value)
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="image-canvas-editor__field-block">
|
||
<PlatformFieldLabel
|
||
variant="form"
|
||
className="image-canvas-editor__field-title"
|
||
>
|
||
美术风格
|
||
</PlatformFieldLabel>
|
||
<PlatformTextField
|
||
aria-label="美术风格"
|
||
value={generateDialog.specValues?.artStyle ?? ''}
|
||
disabled={generateDialog.status === 'generating'}
|
||
size="sm"
|
||
density="compact"
|
||
className="image-canvas-editor__spec-input"
|
||
onChange={(event) =>
|
||
updateSpecFormValue('artStyle', event.target.value)
|
||
}
|
||
/>
|
||
</label>
|
||
{generateDialog.specType === 'character' ? (
|
||
<>
|
||
<label className="image-canvas-editor__field-block">
|
||
<PlatformFieldLabel
|
||
variant="form"
|
||
className="image-canvas-editor__field-title"
|
||
>
|
||
头身比
|
||
</PlatformFieldLabel>
|
||
<PlatformSelectField
|
||
aria-label="头身比"
|
||
value={generateDialog.specValues?.bodyRatio ?? '3'}
|
||
disabled={generateDialog.status === 'generating'}
|
||
size="sm"
|
||
density="compact"
|
||
className="image-canvas-editor__spec-input"
|
||
onChange={(event) =>
|
||
updateSpecFormValue(
|
||
'bodyRatio',
|
||
event.target.value,
|
||
)
|
||
}
|
||
>
|
||
{['2', '3', '4', '5', '6'].map((value) => (
|
||
<option key={value} value={value}>
|
||
{value}
|
||
</option>
|
||
))}
|
||
</PlatformSelectField>
|
||
</label>
|
||
<label className="image-canvas-editor__field-block">
|
||
<PlatformFieldLabel
|
||
variant="form"
|
||
className="image-canvas-editor__field-title"
|
||
>
|
||
角色视角
|
||
</PlatformFieldLabel>
|
||
<PlatformSelectField
|
||
aria-label="角色视角"
|
||
value={
|
||
generateDialog.specValues?.characterView ?? ''
|
||
}
|
||
disabled={generateDialog.status === 'generating'}
|
||
size="sm"
|
||
density="compact"
|
||
className="image-canvas-editor__spec-input"
|
||
onChange={(event) =>
|
||
updateSpecFormValue(
|
||
'characterView',
|
||
event.target.value,
|
||
)
|
||
}
|
||
>
|
||
{CHARACTER_SPEC_VIEW_OPTIONS.map((value) => (
|
||
<option key={value} value={value}>
|
||
{value}
|
||
</option>
|
||
))}
|
||
</PlatformSelectField>
|
||
</label>
|
||
</>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
{generateDialog.status === 'failed' ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="platform"
|
||
size="xs"
|
||
className="image-canvas-editor__generate-status"
|
||
role="alert"
|
||
>
|
||
{generateDialog.errorMessage}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
<div className="image-canvas-editor__spec-footer">
|
||
<PlatformActionButton
|
||
type="submit"
|
||
tone="secondary"
|
||
size="sm"
|
||
className="image-canvas-editor__spec-submit"
|
||
disabled={generateDialog.status === 'generating'}
|
||
aria-label="提交生成规范"
|
||
>
|
||
{generateDialog.status === 'generating'
|
||
? '生成中'
|
||
: `消耗${SPEC_GENERATION_COST}泥点 · 生成`}
|
||
</PlatformActionButton>
|
||
</div>
|
||
</form>
|
||
) : null}
|
||
|
||
{generateDialog?.mode === 'character' && generationComposerStyle ? (
|
||
<form
|
||
className="image-canvas-editor__character-composer"
|
||
style={generationComposerStyle}
|
||
role="dialog"
|
||
aria-label="生成角色形象"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
if (generateDialog.status !== 'generating') {
|
||
void submitImageGeneration(generateDialog);
|
||
}
|
||
}}
|
||
>
|
||
<div className="image-canvas-editor__character-reference-row">
|
||
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--spec">
|
||
<PlatformFieldLabel
|
||
variant="field"
|
||
className="image-canvas-editor__field-title"
|
||
>
|
||
角色形象规范
|
||
</PlatformFieldLabel>
|
||
<span className="image-canvas-editor__character-spec-wrap">
|
||
<button
|
||
ref={characterSpecButtonRef}
|
||
type="button"
|
||
className="image-canvas-editor__character-spec-ref image-canvas-editor__reference-tile image-canvas-editor__reference-tile--spec"
|
||
disabled={generateDialog.status === 'generating'}
|
||
onClick={() =>
|
||
setIsCharacterSpecMenuOpen((open) => !open)
|
||
}
|
||
>
|
||
<span className="image-canvas-editor__reference-tile-visual">
|
||
{generateDialog.characterSpecReference ? (
|
||
<img
|
||
src={generateDialog.characterSpecReference.src}
|
||
alt=""
|
||
aria-hidden="true"
|
||
/>
|
||
) : (
|
||
<ClipboardList
|
||
className="h-4 w-4"
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
</span>
|
||
<span className="image-canvas-editor__reference-tile-copy">
|
||
{generateDialog.characterSpecReference?.label ??
|
||
'角色形象规范'}
|
||
</span>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
{isCharacterSpecMenuOpen
|
||
? renderEditorPortal(
|
||
<PlatformFloatingMenu
|
||
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
||
label="角色形象规范来源"
|
||
placement="bottom-start"
|
||
style={buildPortalMenuStyle(
|
||
characterSpecButtonRef.current,
|
||
'below',
|
||
)}
|
||
>
|
||
<PlatformFloatingMenuItem
|
||
className="image-canvas-editor__context-menu-item"
|
||
onClick={() => {
|
||
setIsPickingCharacterSpecFromCanvas(true);
|
||
setIsCharacterSpecMenuOpen(false);
|
||
}}
|
||
>
|
||
从画布中选择
|
||
</PlatformFloatingMenuItem>
|
||
<PlatformFloatingMenuItem
|
||
className="image-canvas-editor__context-menu-item"
|
||
onClick={() => {
|
||
setIsCharacterSpecMenuOpen(false);
|
||
openSpecDialog('character');
|
||
}}
|
||
>
|
||
新建角色形象规范
|
||
</PlatformFloatingMenuItem>
|
||
<PlatformFloatingMenuItem
|
||
className="image-canvas-editor__context-menu-item"
|
||
onClick={() => {
|
||
setUploadTarget('character-spec');
|
||
setIsCharacterSpecMenuOpen(false);
|
||
uploadInputRef.current?.click();
|
||
}}
|
||
>
|
||
上传图片
|
||
</PlatformFloatingMenuItem>
|
||
</PlatformFloatingMenu>,
|
||
)
|
||
: null}
|
||
<div className="image-canvas-editor__field-block image-canvas-editor__character-reference-field image-canvas-editor__character-reference-field--regular">
|
||
<PlatformFieldLabel
|
||
variant="field"
|
||
className="image-canvas-editor__field-title"
|
||
>
|
||
常规参考图
|
||
</PlatformFieldLabel>
|
||
<div className="image-canvas-editor__character-reference-list">
|
||
{(generateDialog.characterReferences ?? []).map(
|
||
(reference, index) => (
|
||
<span
|
||
key={reference.id}
|
||
className="image-canvas-editor__character-ref-thumb"
|
||
title={reference.label}
|
||
>
|
||
<img src={reference.src} alt={reference.label} />
|
||
<span className="image-canvas-editor__character-ref-index">
|
||
{index + 1}
|
||
</span>
|
||
</span>
|
||
),
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="image-canvas-editor__character-reference-add image-canvas-editor__reference-tile image-canvas-editor__reference-tile--upload"
|
||
disabled={generateDialog.status === 'generating'}
|
||
onClick={() => {
|
||
setUploadTarget('character-reference');
|
||
uploadInputRef.current?.click();
|
||
}}
|
||
>
|
||
<span className="image-canvas-editor__reference-tile-visual">
|
||
<ImagePlus className="h-4 w-4" aria-hidden="true" />
|
||
</span>
|
||
<span className="image-canvas-editor__reference-tile-copy">
|
||
上传常规参考图
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<label className="image-canvas-editor__field-block">
|
||
<PlatformFieldLabel
|
||
variant="field"
|
||
className="image-canvas-editor__field-title"
|
||
>
|
||
角色设定
|
||
</PlatformFieldLabel>
|
||
<PlatformTextField
|
||
variant="textarea"
|
||
aria-label="角色设定"
|
||
value={generateDialog.prompt}
|
||
disabled={generateDialog.status === 'generating'}
|
||
size="sm"
|
||
density="compact"
|
||
className="image-canvas-editor__generation-prompt"
|
||
onChange={(event) =>
|
||
setGenerateDialog((currentDialog) =>
|
||
currentDialog?.mode === 'character'
|
||
? {
|
||
...currentDialog,
|
||
prompt: event.target.value,
|
||
status:
|
||
currentDialog.status === 'failed'
|
||
? 'idle'
|
||
: currentDialog.status,
|
||
errorMessage:
|
||
currentDialog.status === 'failed'
|
||
? undefined
|
||
: currentDialog.errorMessage,
|
||
}
|
||
: currentDialog,
|
||
)
|
||
}
|
||
/>
|
||
</label>
|
||
{generateDialog.status === 'failed' ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="platform"
|
||
size="xs"
|
||
className="image-canvas-editor__generate-status"
|
||
role="alert"
|
||
>
|
||
{generateDialog.errorMessage}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
<div className="image-canvas-editor__generation-composer-footer">
|
||
<div className="image-canvas-editor__option-field">
|
||
<PlatformFieldLabel
|
||
variant="field"
|
||
className="image-canvas-editor__field-title"
|
||
>
|
||
画面比例
|
||
</PlatformFieldLabel>
|
||
<PlatformInlineOptionButton
|
||
className="image-canvas-editor__generation-ratio"
|
||
disabled={generateDialog.status === 'generating'}
|
||
onClick={() => triggerPlaceholderAction('角色比例')}
|
||
>
|
||
1:1
|
||
</PlatformInlineOptionButton>
|
||
</div>
|
||
<div className="image-canvas-editor__option-field">
|
||
<PlatformFieldLabel
|
||
variant="field"
|
||
className="image-canvas-editor__field-title"
|
||
>
|
||
模型
|
||
</PlatformFieldLabel>
|
||
<PlatformInlineOptionButton
|
||
className="image-canvas-editor__generation-model"
|
||
disabled={generateDialog.status === 'generating'}
|
||
onClick={() => triggerPlaceholderAction('角色模型')}
|
||
>
|
||
GPT Image
|
||
</PlatformInlineOptionButton>
|
||
</div>
|
||
<PlatformActionButton
|
||
type="submit"
|
||
tone="secondary"
|
||
size="xs"
|
||
shape="pill"
|
||
className="image-canvas-editor__generation-submit"
|
||
disabled={generateDialog.status === 'generating'}
|
||
>
|
||
{generateDialog.status === 'generating' ? '生成中' : '生成'}
|
||
</PlatformActionButton>
|
||
</div>
|
||
</form>
|
||
) : null}
|
||
|
||
{generateDialog?.mode === 'icon' &&
|
||
generateDialog.composerOpen !== false &&
|
||
iconComposerStyle ? (
|
||
<form
|
||
className="image-canvas-editor__icon-composer"
|
||
style={iconComposerStyle}
|
||
role="dialog"
|
||
aria-label="生成图标素材"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
if (generateDialog.status !== 'generating') {
|
||
void submitIconSpritesheetGeneration(generateDialog);
|
||
}
|
||
}}
|
||
>
|
||
<div className="image-canvas-editor__field-block">
|
||
<PlatformFieldLabel
|
||
variant="field"
|
||
className="image-canvas-editor__field-title"
|
||
>
|
||
图标素材规范
|
||
</PlatformFieldLabel>
|
||
<div className="image-canvas-editor__icon-spec-row">
|
||
<span className="image-canvas-editor__character-spec-wrap">
|
||
<button
|
||
ref={iconSpecButtonRef}
|
||
type="button"
|
||
className="image-canvas-editor__icon-spec-card"
|
||
disabled={generateDialog.status === 'generating'}
|
||
aria-label={
|
||
generateDialog.iconSpecReference?.label ??
|
||
'图标素材规范'
|
||
}
|
||
onClick={() => setIsIconSpecMenuOpen((open) => !open)}
|
||
>
|
||
<span
|
||
className="image-canvas-editor__icon-spec-preview"
|
||
aria-hidden="true"
|
||
>
|
||
{generateDialog.iconSpecReference?.src ? (
|
||
<img
|
||
src={generateDialog.iconSpecReference.src}
|
||
alt=""
|
||
/>
|
||
) : (
|
||
<ImageIcon className="h-5 w-5" />
|
||
)}
|
||
</span>
|
||
<span className="image-canvas-editor__icon-spec-copy">
|
||
<span className="image-canvas-editor__icon-spec-eyebrow">
|
||
图标素材规范
|
||
</span>
|
||
<span className="image-canvas-editor__icon-spec-title">
|
||
{generateDialog.iconSpecReference?.label ?? '待选择'}
|
||
</span>
|
||
</span>
|
||
<span className="image-canvas-editor__icon-spec-state">
|
||
{generateDialog.iconSpecReference ? '已绑定' : '待绑定'}
|
||
</span>
|
||
</button>
|
||
</span>
|
||
{isIconSpecMenuOpen
|
||
? renderEditorPortal(
|
||
<PlatformFloatingMenu
|
||
className="image-canvas-editor__character-spec-menu image-canvas-editor__portal-menu"
|
||
label="图标素材规范来源"
|
||
placement="bottom-start"
|
||
style={buildPortalMenuStyle(
|
||
iconSpecButtonRef.current,
|
||
'below',
|
||
)}
|
||
>
|
||
<PlatformFloatingMenuItem
|
||
className="image-canvas-editor__context-menu-item"
|
||
onClick={() => {
|
||
setIsPickingIconSpecFromCanvas(true);
|
||
setIsIconSpecMenuOpen(false);
|
||
}}
|
||
>
|
||
从画布中选择
|
||
</PlatformFloatingMenuItem>
|
||
<PlatformFloatingMenuItem
|
||
className="image-canvas-editor__context-menu-item"
|
||
onClick={() => {
|
||
setIsIconSpecMenuOpen(false);
|
||
openSpecDialog('icon');
|
||
}}
|
||
>
|
||
新建图标素材规范
|
||
</PlatformFloatingMenuItem>
|
||
<PlatformFloatingMenuItem
|
||
className="image-canvas-editor__context-menu-item"
|
||
onClick={() => {
|
||
setUploadTarget('icon-spec');
|
||
setIsIconSpecMenuOpen(false);
|
||
uploadInputRef.current?.click();
|
||
}}
|
||
>
|
||
上传图片
|
||
</PlatformFloatingMenuItem>
|
||
</PlatformFloatingMenu>,
|
||
)
|
||
: null}
|
||
<div
|
||
className="image-canvas-editor__icon-spec-actions"
|
||
aria-label="图标素材规范操作"
|
||
>
|
||
<button
|
||
type="button"
|
||
disabled={generateDialog.status === 'generating'}
|
||
onClick={() => {
|
||
setIsPickingIconSpecFromCanvas(true);
|
||
setIsIconSpecMenuOpen(false);
|
||
}}
|
||
>
|
||
画布
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={generateDialog.status === 'generating'}
|
||
onClick={() => {
|
||
setIsIconSpecMenuOpen(false);
|
||
openSpecDialog('icon');
|
||
}}
|
||
>
|
||
新建
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={generateDialog.status === 'generating'}
|
||
onClick={() => {
|
||
setUploadTarget('icon-spec');
|
||
setIsIconSpecMenuOpen(false);
|
||
uploadInputRef.current?.click();
|
||
}}
|
||
>
|
||
上传
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="image-canvas-editor__field-block">
|
||
<PlatformFieldLabel
|
||
variant="field"
|
||
className="image-canvas-editor__field-title"
|
||
>
|
||
素材描述
|
||
</PlatformFieldLabel>
|
||
<div className="image-canvas-editor__icon-description-list">
|
||
{iconDescriptionValues.map((description, index) => (
|
||
<label
|
||
key={index}
|
||
className="image-canvas-editor__icon-description-card"
|
||
>
|
||
<span className="image-canvas-editor__icon-description-index">
|
||
{String(index + 1).padStart(2, '0')}
|
||
</span>
|
||
<span className="image-canvas-editor__icon-description-title">
|
||
素材描述 {index + 1}
|
||
</span>
|
||
<PlatformTextField
|
||
aria-label={`素材描述${index + 1}`}
|
||
value={description}
|
||
disabled={generateDialog.status === 'generating'}
|
||
size="sm"
|
||
density="compact"
|
||
className="image-canvas-editor__icon-description-input"
|
||
onChange={(event) =>
|
||
updateIconDescription(index, event.target.value)
|
||
}
|
||
/>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{generateDialog.status === 'failed' ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="platform"
|
||
size="xs"
|
||
className="image-canvas-editor__generate-status"
|
||
role="alert"
|
||
>
|
||
{generateDialog.errorMessage}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
<div className="image-canvas-editor__icon-footer">
|
||
<button
|
||
type="button"
|
||
className="image-canvas-editor__character-reference-add"
|
||
disabled={
|
||
generateDialog.status === 'generating' ||
|
||
(
|
||
generateDialog.iconDescriptions ??
|
||
DEFAULT_ICON_DESCRIPTIONS
|
||
).length >= ICON_DESCRIPTION_LIMIT
|
||
}
|
||
onClick={addIconDescription}
|
||
>
|
||
添加素材描述
|
||
</button>
|
||
<div className="image-canvas-editor__option-field">
|
||
<PlatformFieldLabel
|
||
variant="field"
|
||
className="image-canvas-editor__field-title"
|
||
>
|
||
模型
|
||
</PlatformFieldLabel>
|
||
<PlatformInlineOptionButton
|
||
className="image-canvas-editor__generation-model"
|
||
disabled={generateDialog.status === 'generating'}
|
||
onClick={() => triggerPlaceholderAction('图标模型')}
|
||
>
|
||
nanobanana2
|
||
</PlatformInlineOptionButton>
|
||
</div>
|
||
<PlatformActionButton
|
||
type="submit"
|
||
tone="secondary"
|
||
size="xs"
|
||
shape="pill"
|
||
className="image-canvas-editor__generation-submit"
|
||
disabled={generateDialog.status === 'generating'}
|
||
aria-label="生成"
|
||
>
|
||
{generateDialog.status === 'generating' ? '生成中' : '生成'}
|
||
</PlatformActionButton>
|
||
</div>
|
||
</form>
|
||
) : null}
|
||
|
||
{isPickingCharacterSpecFromCanvas ? (
|
||
<div className="image-canvas-editor__canvas-pick-hint">
|
||
请选择画布中的图片作为角色形象规范,按 Esc 退出
|
||
</div>
|
||
) : null}
|
||
{isPickingIconSpecFromCanvas ? (
|
||
<div className="image-canvas-editor__canvas-pick-hint">
|
||
请选择画布中的图标素材规范,按 Esc 退出
|
||
</div>
|
||
) : null}
|
||
|
||
{quickEditPanel &&
|
||
quickEditPanel.status !== 'generating' &&
|
||
quickEditSourceLayer &&
|
||
quickEditPanelStyle ? (
|
||
<form
|
||
className="image-canvas-editor__quick-edit-panel"
|
||
style={quickEditPanelStyle}
|
||
role="dialog"
|
||
aria-label="快速编辑图片"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
void submitQuickEdit();
|
||
}}
|
||
>
|
||
<div className="image-canvas-editor__quick-edit-head">
|
||
<div className="image-canvas-editor__quick-edit-reference">
|
||
<img
|
||
src={quickEditSourceLayer.src}
|
||
alt={`${quickEditSourceLayer.title}参考图`}
|
||
/>
|
||
<span>{quickEditSourceLayer.title}</span>
|
||
</div>
|
||
<EditorIconButton
|
||
label="关闭快速编辑图片"
|
||
title="关闭"
|
||
icon={X}
|
||
onClick={() => setQuickEditPanel(null)}
|
||
/>
|
||
</div>
|
||
<PlatformTextField
|
||
variant="textarea"
|
||
aria-label="快速编辑提示词"
|
||
value={quickEditPanel.prompt}
|
||
size="sm"
|
||
density="compact"
|
||
className="image-canvas-editor__quick-edit-prompt"
|
||
onChange={(event) =>
|
||
setQuickEditPanel((currentPanel) =>
|
||
currentPanel
|
||
? {
|
||
...currentPanel,
|
||
prompt: event.target.value,
|
||
status:
|
||
currentPanel.status === 'failed'
|
||
? 'idle'
|
||
: currentPanel.status,
|
||
errorMessage:
|
||
currentPanel.status === 'failed'
|
||
? undefined
|
||
: currentPanel.errorMessage,
|
||
}
|
||
: currentPanel,
|
||
)
|
||
}
|
||
/>
|
||
<div className="image-canvas-editor__quick-edit-controls">
|
||
<PlatformSelectField
|
||
aria-label="快速编辑尺寸"
|
||
value={quickEditPanel.size}
|
||
size="xs"
|
||
density="compact"
|
||
onChange={(event) =>
|
||
setQuickEditPanel((currentPanel) =>
|
||
currentPanel
|
||
? { ...currentPanel, size: event.target.value }
|
||
: currentPanel,
|
||
)
|
||
}
|
||
>
|
||
{quickEditSizeOptions.map((size) => (
|
||
<option key={size} value={size}>
|
||
{size}
|
||
</option>
|
||
))}
|
||
</PlatformSelectField>
|
||
<PlatformSelectField
|
||
aria-label="快速编辑模型"
|
||
value={quickEditPanel.model}
|
||
size="xs"
|
||
density="compact"
|
||
onChange={(event) =>
|
||
setQuickEditPanel((currentPanel) =>
|
||
currentPanel
|
||
? { ...currentPanel, model: event.target.value }
|
||
: currentPanel,
|
||
)
|
||
}
|
||
>
|
||
{quickEditModelOptions.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</PlatformSelectField>
|
||
</div>
|
||
{quickEditPanel.status === 'failed' ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="platform"
|
||
size="xs"
|
||
role="alert"
|
||
>
|
||
{quickEditPanel.errorMessage}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
<PlatformActionButton
|
||
type="submit"
|
||
tone="secondary"
|
||
size="sm"
|
||
className="image-canvas-editor__quick-edit-submit"
|
||
>
|
||
生成
|
||
</PlatformActionButton>
|
||
</form>
|
||
) : null}
|
||
|
||
{imageContextMenu && imageContextMenuLayer && !contextMenu ? (
|
||
<div
|
||
className="image-canvas-editor__context-menu"
|
||
style={{
|
||
left: imageContextMenu.x,
|
||
top: imageContextMenu.y,
|
||
}}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
>
|
||
<PlatformFloatingMenu
|
||
label="图片功能面板"
|
||
placement="bottom-start"
|
||
>
|
||
<PlatformFloatingMenuItem
|
||
className="image-canvas-editor__context-menu-item"
|
||
onClick={() => openQuickEditPanel(imageContextMenuLayer)}
|
||
>
|
||
快速编辑
|
||
</PlatformFloatingMenuItem>
|
||
<PlatformFloatingMenuItem
|
||
className="image-canvas-editor__context-menu-item"
|
||
onClick={() => {
|
||
setMetadataLayer(imageContextMenuLayer);
|
||
setImageContextMenu(null);
|
||
}}
|
||
>
|
||
查看图片信息
|
||
</PlatformFloatingMenuItem>
|
||
{imageContextMenuLayer.assetKind === 'character' ? (
|
||
<PlatformFloatingMenuItem
|
||
className="image-canvas-editor__context-menu-item"
|
||
onClick={() =>
|
||
openCharacterAnimationPanel(imageContextMenuLayer)
|
||
}
|
||
>
|
||
生成动画
|
||
</PlatformFloatingMenuItem>
|
||
) : null}
|
||
<PlatformFloatingMenuItem
|
||
className="image-canvas-editor__context-menu-item"
|
||
onClick={() => deleteLayerById(imageContextMenuLayer.id)}
|
||
>
|
||
删除图片
|
||
</PlatformFloatingMenuItem>
|
||
</PlatformFloatingMenu>
|
||
</div>
|
||
) : null}
|
||
|
||
{characterAnimationPanel &&
|
||
characterAnimationSourceLayer &&
|
||
characterAnimationPanelStyle ? (
|
||
<form
|
||
className="image-canvas-editor__character-animation-panel"
|
||
style={characterAnimationPanelStyle}
|
||
role="dialog"
|
||
aria-label="角色动画生成面板"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
if (characterAnimationPanel.status !== 'generating') {
|
||
void submitCharacterAnimation();
|
||
}
|
||
}}
|
||
>
|
||
<div className="image-canvas-editor__character-animation-head">
|
||
<strong>角色动画</strong>
|
||
<EditorIconButton
|
||
label="关闭角色动画生成面板"
|
||
title="关闭"
|
||
icon={X}
|
||
onClick={() => setCharacterAnimationPanel(null)}
|
||
/>
|
||
</div>
|
||
<PlatformTextField
|
||
variant="textarea"
|
||
aria-label="动画描述"
|
||
value={characterAnimationPanel.promptText}
|
||
maxLength={4000}
|
||
disabled={characterAnimationPanel.status === 'generating'}
|
||
size="sm"
|
||
density="compact"
|
||
className="image-canvas-editor__character-animation-textarea"
|
||
onChange={(event) =>
|
||
setCharacterAnimationPanel((currentPanel) =>
|
||
currentPanel
|
||
? {
|
||
...currentPanel,
|
||
promptText: event.target.value.slice(0, 4000),
|
||
status:
|
||
currentPanel.status === 'failed'
|
||
? 'idle'
|
||
: currentPanel.status,
|
||
errorMessage:
|
||
currentPanel.status === 'failed'
|
||
? undefined
|
||
: currentPanel.errorMessage,
|
||
}
|
||
: currentPanel,
|
||
)
|
||
}
|
||
/>
|
||
<div className="image-canvas-editor__character-animation-presets">
|
||
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => (
|
||
<button
|
||
key={preset.label}
|
||
type="button"
|
||
className="image-canvas-editor__character-animation-preset"
|
||
disabled={characterAnimationPanel.status === 'generating'}
|
||
onClick={() =>
|
||
setCharacterAnimationPanel((currentPanel) =>
|
||
currentPanel
|
||
? {
|
||
...currentPanel,
|
||
promptText: preset.text,
|
||
status: 'idle',
|
||
errorMessage: undefined,
|
||
}
|
||
: currentPanel,
|
||
)
|
||
}
|
||
>
|
||
{preset.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="image-canvas-editor__character-animation-grid">
|
||
<PlatformSelectField
|
||
aria-label="分辨率"
|
||
value={characterAnimationPanel.resolution}
|
||
disabled={characterAnimationPanel.status === 'generating'}
|
||
size="xs"
|
||
density="compact"
|
||
onChange={(event) =>
|
||
setCharacterAnimationPanel((currentPanel) =>
|
||
currentPanel
|
||
? {
|
||
...currentPanel,
|
||
resolution:
|
||
event.target.value === '720p' ? '720p' : '480p',
|
||
status:
|
||
currentPanel.status === 'failed'
|
||
? 'idle'
|
||
: currentPanel.status,
|
||
errorMessage:
|
||
currentPanel.status === 'failed'
|
||
? undefined
|
||
: currentPanel.errorMessage,
|
||
}
|
||
: currentPanel,
|
||
)
|
||
}
|
||
>
|
||
<option value="480p">480p</option>
|
||
<option value="720p">720p</option>
|
||
</PlatformSelectField>
|
||
<PlatformSelectField
|
||
aria-label="画面比例"
|
||
value={characterAnimationPanel.ratio}
|
||
disabled={characterAnimationPanel.status === 'generating'}
|
||
size="xs"
|
||
density="compact"
|
||
onChange={(event) =>
|
||
setCharacterAnimationPanel((currentPanel) =>
|
||
currentPanel
|
||
? {
|
||
...currentPanel,
|
||
ratio:
|
||
CHARACTER_ANIMATION_RATIO_OPTIONS.find(
|
||
(item) => item.value === event.target.value,
|
||
)?.value ?? 'same',
|
||
status:
|
||
currentPanel.status === 'failed'
|
||
? 'idle'
|
||
: currentPanel.status,
|
||
errorMessage:
|
||
currentPanel.status === 'failed'
|
||
? undefined
|
||
: currentPanel.errorMessage,
|
||
}
|
||
: currentPanel,
|
||
)
|
||
}
|
||
>
|
||
{CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</PlatformSelectField>
|
||
<PlatformSelectField
|
||
aria-label="时长"
|
||
value={String(characterAnimationPanel.frameCount)}
|
||
disabled={characterAnimationPanel.status === 'generating'}
|
||
size="xs"
|
||
density="compact"
|
||
onChange={(event) =>
|
||
updateCharacterAnimationDuration(event.target.value)
|
||
}
|
||
>
|
||
{CHARACTER_ANIMATION_DURATION_OPTIONS.map((option) => (
|
||
<option
|
||
key={option.frameCount}
|
||
value={String(option.frameCount)}
|
||
>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</PlatformSelectField>
|
||
</div>
|
||
<div className="image-canvas-editor__character-animation-summary">
|
||
<span
|
||
className="image-canvas-editor__character-animation-summary-text"
|
||
title={characterAnimationPanel.promptText.trim() || undefined}
|
||
aria-label={`生成文本:${
|
||
characterAnimationPanel.promptText.trim() || '动画描述'
|
||
}`}
|
||
>
|
||
{characterAnimationPanel.promptText.trim()
|
||
? characterAnimationPanel.promptText.trim()
|
||
: '动画描述'}
|
||
</span>
|
||
<strong>{characterAnimationPrice}泥点</strong>
|
||
</div>
|
||
{characterAnimationPanel.status === 'completed' &&
|
||
characterAnimationPanel.result ? (
|
||
<PlatformStatusMessage
|
||
tone="success"
|
||
surface="platform"
|
||
size="xs"
|
||
role="status"
|
||
>
|
||
已生成 {characterAnimationPanel.result.frameCount} 帧
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
{characterAnimationPanel.status === 'failed' ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="platform"
|
||
size="xs"
|
||
role="alert"
|
||
>
|
||
{characterAnimationPanel.errorMessage}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
<PlatformActionButton
|
||
type="submit"
|
||
tone="secondary"
|
||
size="sm"
|
||
className="image-canvas-editor__character-animation-submit"
|
||
disabled={characterAnimationPanel.status === 'generating'}
|
||
>
|
||
{characterAnimationPanel.status === 'generating'
|
||
? '生成中'
|
||
: '生成'}
|
||
</PlatformActionButton>
|
||
</form>
|
||
) : null}
|
||
</div>
|
||
</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>
|
||
|
||
<UnifiedModal
|
||
open={
|
||
generateDialog?.mode === 'edit' &&
|
||
generateDialog.status !== 'generating'
|
||
}
|
||
title={generateDialog?.mode === 'edit' ? '修改图片' : '生成图片'}
|
||
size="sm"
|
||
closeLabel={
|
||
generateDialog?.mode === 'edit' ? '关闭修改图片' : '关闭生成图片'
|
||
}
|
||
closeDisabled={generateDialog?.status === 'generating'}
|
||
onClose={() => setGenerateDialog(null)}
|
||
panelClassName="image-canvas-editor__generate-dialog"
|
||
bodyClassName="image-canvas-editor__generate-dialog-body"
|
||
>
|
||
{generateDialog?.mode === 'edit' ? (
|
||
<form
|
||
className="image-canvas-editor__generate-form"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
if (generateDialog.status !== 'generating') {
|
||
void submitImageGeneration(generateDialog);
|
||
}
|
||
}}
|
||
>
|
||
<div className="image-canvas-editor__generate-body">
|
||
<PlatformTextField
|
||
variant="textarea"
|
||
aria-label="生成提示词"
|
||
value={generateDialog.prompt}
|
||
disabled={generateDialog.status === 'generating'}
|
||
size="sm"
|
||
density="roomy"
|
||
className="image-canvas-editor__generate-prompt"
|
||
placeholder={
|
||
generateDialog.mode === 'edit'
|
||
? '描述你想如何修改这张图片'
|
||
: '描述你想生成的图片'
|
||
}
|
||
onChange={(event) =>
|
||
setGenerateDialog((currentDialog) =>
|
||
currentDialog
|
||
? {
|
||
...currentDialog,
|
||
prompt: event.target.value,
|
||
}
|
||
: currentDialog,
|
||
)
|
||
}
|
||
/>
|
||
{generateDialog.status === 'generating' ? (
|
||
<PlatformStatusMessage
|
||
tone="info"
|
||
surface="platform"
|
||
size="xs"
|
||
className="image-canvas-editor__generate-status"
|
||
role="status"
|
||
>
|
||
{generateDialog.mode === 'edit' ? '修改中' : '生成中'}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
{generateDialog.status === 'failed' ? (
|
||
<PlatformStatusMessage
|
||
tone="error"
|
||
surface="platform"
|
||
size="xs"
|
||
className="image-canvas-editor__generate-status"
|
||
role="alert"
|
||
>
|
||
{generateDialog.errorMessage}
|
||
</PlatformStatusMessage>
|
||
) : null}
|
||
<PlatformActionButton
|
||
type="submit"
|
||
size="sm"
|
||
className="image-canvas-editor__generate-submit"
|
||
disabled={generateDialog.status === 'generating'}
|
||
>
|
||
{generateDialog.status === 'generating'
|
||
? generateDialog.mode === 'edit'
|
||
? '修改中'
|
||
: '生成中'
|
||
: generateDialog.mode === 'edit'
|
||
? '修改'
|
||
: '生成'}
|
||
</PlatformActionButton>
|
||
</div>
|
||
</form>
|
||
) : null}
|
||
</UnifiedModal>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export default ImageCanvasEditorView;
|