将 codex/editor-asset-library 合并到 dev-jenken 保留编辑器生成规范、角色形象和图标素材能力 补回画布布局轻量保存和小地图拖拽手感修复
6122 lines
210 KiB
TypeScript
6122 lines
210 KiB
TypeScript
import {
|
||
Braces,
|
||
Check,
|
||
CheckSquare,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
ClipboardList,
|
||
Copy,
|
||
Crop,
|
||
Download,
|
||
Folder,
|
||
FolderPlus,
|
||
Hand,
|
||
ImageIcon,
|
||
ImagePlus,
|
||
Info,
|
||
Layers,
|
||
Map as MapIcon,
|
||
MousePointer2,
|
||
Pencil,
|
||
PencilLine,
|
||
RotateCcw,
|
||
Shapes,
|
||
SlidersHorizontal,
|
||
Sparkles,
|
||
Square,
|
||
Trash2,
|
||
Type,
|
||
WandSparkles,
|
||
X,
|
||
} from 'lucide-react';
|
||
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 EditorAssetLibrarySnapshot,
|
||
type EditorCharacterAnimationFrameCount,
|
||
type EditorCharacterAnimationGenerationResult,
|
||
type EditorCharacterAnimationRatio,
|
||
type EditorCharacterAnimationResolution,
|
||
type EditorIconSpritesheetGenerationResult,
|
||
type EditorIconSpritesheetIconResult,
|
||
type EditorImageGenerationResult,
|
||
type EditorProjectLayerSnapshot,
|
||
generateEditorCharacterAnimation,
|
||
generateEditorIconSpritesheet,
|
||
generateEditorImage,
|
||
loadEditorAssetLibrary,
|
||
loadEditorProject,
|
||
loadOrCreateRecentEditorProject,
|
||
saveEditorProjectLayout,
|
||
updateEditorAsset,
|
||
updateEditorAssetFolder,
|
||
} from '../../services/image-editor/editorProjectClient';
|
||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
|
||
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 {
|
||
EditorIconButton,
|
||
SidebarMediaItem,
|
||
} from './ImageCanvasEditorPrimitives';
|
||
|
||
type EditorAsset = {
|
||
id: string;
|
||
label: string;
|
||
src: string;
|
||
width: number;
|
||
height: number;
|
||
folderId: string;
|
||
sourceKind: 'built-in' | 'uploaded';
|
||
sourceType: CanvasLayer['sourceType'];
|
||
persisted: boolean;
|
||
prompt?: string;
|
||
actualPrompt?: string;
|
||
model?: string;
|
||
provider?: string;
|
||
taskId?: string;
|
||
objectKey?: string;
|
||
assetObjectId?: string;
|
||
};
|
||
|
||
type CanvasLayer = {
|
||
id: string;
|
||
resourceId: string;
|
||
title: string;
|
||
src: string;
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
originalWidth: number;
|
||
originalHeight: number;
|
||
zIndex: number;
|
||
sourceType: 'uploaded' | 'generated' | 'mock_generated';
|
||
prompt?: string | null;
|
||
actualPrompt?: string | null;
|
||
model?: string | null;
|
||
provider?: string | null;
|
||
taskId?: string | null;
|
||
objectKey?: string | null;
|
||
assetObjectId?: string | null;
|
||
sourceResourceId?: string | null;
|
||
groupId?: string | null;
|
||
assetKind?: 'spec' | 'character' | 'icon' | 'icon-spec' | null;
|
||
};
|
||
|
||
type CanvasViewport = {
|
||
x: number;
|
||
y: number;
|
||
scale: number;
|
||
};
|
||
|
||
type CanvasTool =
|
||
| 'select'
|
||
| 'hand'
|
||
| 'upload'
|
||
| 'generate'
|
||
| 'spec'
|
||
| 'character'
|
||
| 'icon'
|
||
| 'text'
|
||
| 'shape'
|
||
| 'export';
|
||
|
||
type SidebarPanel = 'assets' | 'layers';
|
||
|
||
type EditorAssetFolder = {
|
||
id: string;
|
||
label: string;
|
||
collapsed: boolean;
|
||
systemDefault: boolean;
|
||
persisted: boolean;
|
||
};
|
||
|
||
type GenerateDialogState = {
|
||
id?: string;
|
||
mode: 'generate' | 'edit' | 'spec' | 'character' | 'icon';
|
||
prompt: string;
|
||
status: 'idle' | 'generating' | 'failed';
|
||
composerOpen?: boolean;
|
||
sourceLayerId?: string;
|
||
generatedLayerId?: string;
|
||
specType?: SpecGenerationType;
|
||
specValues?: SpecFormValues;
|
||
characterSpecReference?: CharacterReferenceImage | null;
|
||
characterReferences?: CharacterReferenceImage[];
|
||
iconSpecReference?: CharacterReferenceImage | null;
|
||
iconDescriptions?: string[];
|
||
errorMessage?: string;
|
||
placeholder?: {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
originalWidth: number;
|
||
originalHeight: number;
|
||
};
|
||
};
|
||
|
||
type CanvasGenerationDialogMode = Exclude<GenerateDialogState['mode'], 'edit'>;
|
||
|
||
type CanvasGenerationDialogState = GenerateDialogState & {
|
||
id: string;
|
||
mode: CanvasGenerationDialogMode;
|
||
};
|
||
|
||
type SpecGenerationType = 'character' | 'ui' | 'icon' | 'custom';
|
||
|
||
type SpecFormValues = {
|
||
playSetting: string;
|
||
artStyle: string;
|
||
bodyRatio: string;
|
||
characterView: string;
|
||
customPrompt: string;
|
||
};
|
||
|
||
type CharacterReferenceImage = {
|
||
id: string;
|
||
label: string;
|
||
src: string;
|
||
};
|
||
|
||
type ImageContextMenuState = {
|
||
layerId: string;
|
||
x: number;
|
||
y: number;
|
||
};
|
||
|
||
type QuickEditPanelState = {
|
||
sourceLayerId: string;
|
||
prompt: string;
|
||
size: string;
|
||
model: string;
|
||
status: 'idle' | 'generating' | 'failed';
|
||
errorMessage?: string;
|
||
};
|
||
|
||
type CharacterAnimationPanelState = {
|
||
sourceLayerId: string;
|
||
promptText: string;
|
||
resolution: EditorCharacterAnimationResolution;
|
||
ratio: EditorCharacterAnimationRatio;
|
||
frameCount: EditorCharacterAnimationFrameCount;
|
||
durationSeconds: 4 | 5 | 6;
|
||
status: 'idle' | 'generating' | 'completed' | 'failed';
|
||
errorMessage?: string;
|
||
result?: EditorCharacterAnimationGenerationResult;
|
||
};
|
||
|
||
type UploadTarget =
|
||
| 'asset'
|
||
| 'character-spec'
|
||
| 'character-reference'
|
||
| 'icon-spec';
|
||
|
||
type SnapGuide = {
|
||
vertical?: number;
|
||
horizontal?: number;
|
||
};
|
||
|
||
type SnapCandidate = {
|
||
position: number;
|
||
guide: number;
|
||
distance: number;
|
||
};
|
||
|
||
type AssetMarqueeState = {
|
||
pointerId: number;
|
||
startX: number;
|
||
startY: number;
|
||
currentX: number;
|
||
currentY: number;
|
||
};
|
||
|
||
type CanvasMarqueeState = {
|
||
pointerId: number;
|
||
startX: number;
|
||
startY: number;
|
||
currentX: number;
|
||
currentY: number;
|
||
};
|
||
|
||
type DragState =
|
||
| {
|
||
kind: 'pan';
|
||
pointerId: number;
|
||
startClientX: number;
|
||
startClientY: number;
|
||
startViewport: CanvasViewport;
|
||
}
|
||
| {
|
||
kind: 'layer';
|
||
pointerId: number;
|
||
layerId: string;
|
||
layerIds: string[];
|
||
startClientX: number;
|
||
startClientY: number;
|
||
startLayerX: number;
|
||
startLayerY: number;
|
||
startLayers: Array<{ id: string; x: number; y: number }>;
|
||
startScale: number;
|
||
}
|
||
| {
|
||
kind: 'generation-frame';
|
||
dialogId: string;
|
||
pointerId: number;
|
||
startClientX: number;
|
||
startClientY: number;
|
||
startFrameX: number;
|
||
startFrameY: number;
|
||
startScale: number;
|
||
}
|
||
| {
|
||
kind: 'minimap';
|
||
pointerId: number;
|
||
startClientX: number;
|
||
startClientY: number;
|
||
startViewport: CanvasViewport;
|
||
minimapScale: number;
|
||
moved: boolean;
|
||
};
|
||
|
||
const EDITOR_ASSETS: EditorAsset[] = [
|
||
{
|
||
id: 'puzzle',
|
||
label: '拼图素材',
|
||
src: '/creation-type-references/puzzle.webp',
|
||
width: 640,
|
||
height: 640,
|
||
folderId: 'project',
|
||
sourceKind: 'built-in',
|
||
sourceType: 'uploaded',
|
||
persisted: false,
|
||
},
|
||
{
|
||
id: 'match3d',
|
||
label: '抓大鹅素材',
|
||
src: '/creation-type-references/match3d.webp',
|
||
width: 640,
|
||
height: 640,
|
||
folderId: 'project',
|
||
sourceKind: 'built-in',
|
||
sourceType: 'uploaded',
|
||
persisted: false,
|
||
},
|
||
{
|
||
id: 'big-fish',
|
||
label: '大鱼素材',
|
||
src: '/creation-type-references/big-fish.webp',
|
||
width: 720,
|
||
height: 405,
|
||
folderId: 'project',
|
||
sourceKind: 'built-in',
|
||
sourceType: 'uploaded',
|
||
persisted: false,
|
||
},
|
||
{
|
||
id: 'bark-battle',
|
||
label: '声浪素材',
|
||
src: '/creation-type-references/bark-battle.webp',
|
||
width: 640,
|
||
height: 900,
|
||
folderId: 'project',
|
||
sourceKind: 'built-in',
|
||
sourceType: 'uploaded',
|
||
persisted: false,
|
||
},
|
||
{
|
||
id: 'visual-novel',
|
||
label: '视觉小说素材',
|
||
src: '/creation-type-references/visual-novel.webp',
|
||
width: 720,
|
||
height: 405,
|
||
folderId: 'project',
|
||
sourceKind: 'built-in',
|
||
sourceType: 'uploaded',
|
||
persisted: false,
|
||
},
|
||
];
|
||
|
||
const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
|
||
{
|
||
id: 'project',
|
||
label: '项目素材',
|
||
collapsed: false,
|
||
systemDefault: true,
|
||
persisted: false,
|
||
},
|
||
];
|
||
|
||
const INITIAL_LAYERS: CanvasLayer[] = [
|
||
{
|
||
id: 'layer-puzzle',
|
||
resourceId: 'resource-puzzle',
|
||
title: '拼图素材',
|
||
src: '/creation-type-references/puzzle.webp',
|
||
x: 470,
|
||
y: 300,
|
||
width: 420,
|
||
height: 420,
|
||
originalWidth: 640,
|
||
originalHeight: 640,
|
||
zIndex: 1,
|
||
sourceType: 'uploaded',
|
||
},
|
||
{
|
||
id: 'layer-big-fish',
|
||
resourceId: 'resource-big-fish',
|
||
title: '大鱼素材',
|
||
src: '/creation-type-references/big-fish.webp',
|
||
x: 930,
|
||
y: 360,
|
||
width: 420,
|
||
height: 236,
|
||
originalWidth: 720,
|
||
originalHeight: 405,
|
||
zIndex: 2,
|
||
sourceType: 'uploaded',
|
||
},
|
||
];
|
||
|
||
const CANVAS_WORLD_SIZE = 12000;
|
||
const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
|
||
const MIN_SCALE = 0.24;
|
||
const MAX_SCALE = 3.2;
|
||
const TOOLBAR_HALF_WIDTH = 132;
|
||
const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
|
||
const SNAP_THRESHOLD_SCREEN_PX = 18;
|
||
const FIT_VIEW_PADDING = 10;
|
||
const MINIMAP_SIZE = { width: 132, height: 84 };
|
||
const MINIMAP_PADDING = 8;
|
||
const MINIMAP_DRAG_SENSITIVITY = 0.3;
|
||
const SPEC_GENERATION_COST = 5;
|
||
const SPEC_GENERATION_SIZE = '2048x1152';
|
||
const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 };
|
||
const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
|
||
const CHARACTER_FRAME_ORIGINAL_SIZE = { width: 2048, height: 2048 };
|
||
const CHARACTER_FRAME_DISPLAY_SIZE = { width: 420, height: 420 };
|
||
const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 };
|
||
const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 };
|
||
const DEFAULT_IMAGE_MODEL = 'gpt-image-2';
|
||
const ICON_DESCRIPTION_LIMIT = 100;
|
||
// 图标素材面板按描述项扩宽,避免在画布子面板里做滑动列表。
|
||
const ICON_DESCRIPTION_CARD_WIDTH_REM = 8.4;
|
||
const ICON_COMPOSER_MIN_WIDTH_REM = 28;
|
||
const ICON_COMPOSER_HORIZONTAL_CHROME_REM = 2.4;
|
||
const DEFAULT_ICON_DESCRIPTIONS = [
|
||
'返回按钮',
|
||
'设置按钮',
|
||
'下一关按钮',
|
||
'提示按钮',
|
||
'原图按钮',
|
||
'冻结按钮',
|
||
];
|
||
const QUICK_EDIT_SIZE_PRESETS = [
|
||
'1024x1024',
|
||
'1536x1024',
|
||
'2048x1152',
|
||
'1024x1536',
|
||
] as const;
|
||
const QUICK_EDIT_MODEL_OPTIONS = [
|
||
{ label: 'GPT Image', value: DEFAULT_IMAGE_MODEL },
|
||
] as const;
|
||
const CHARACTER_ANIMATION_MODEL = 'seedance2.0';
|
||
const CHARACTER_ANIMATION_ACTION_PROMPTS = [
|
||
{ label: '待机', text: '待机动作,轻微呼吸起伏。' },
|
||
{ label: '行走', text: '循环行走动作,步伐稳定。' },
|
||
{ label: '奔跑', text: '循环奔跑动作,动作清晰有力。' },
|
||
{ label: '跳跃', text: '起跳、滞空、落地动作。' },
|
||
{ label: '攻击', text: '攻击动作,前摇、出手、收招清晰。' },
|
||
{ label: '受击', text: '受击后短暂后仰并恢复站姿。' },
|
||
{ label: '倒下', text: '倒下动作,重心下落自然。' },
|
||
] as const;
|
||
const CHARACTER_ANIMATION_RATIO_OPTIONS: Array<{
|
||
label: string;
|
||
value: EditorCharacterAnimationRatio;
|
||
}> = [
|
||
{ label: '与角色图片保持同尺寸', value: 'same' },
|
||
{ label: '1:1', value: '1:1' },
|
||
{ label: '4:3', value: '4:3' },
|
||
{ label: '16:9', value: '16:9' },
|
||
{ label: '9:16', value: '9:16' },
|
||
{ label: '3:4', value: '3:4' },
|
||
];
|
||
const CHARACTER_ANIMATION_DURATION_OPTIONS = [
|
||
{ label: '32帧·4秒', frameCount: 32, durationSeconds: 4 },
|
||
{ label: '40帧·5秒', frameCount: 40, durationSeconds: 5 },
|
||
{ label: '48帧·6秒', frameCount: 48, durationSeconds: 6 },
|
||
] as const;
|
||
const CANVAS_BACKGROUND_OPTIONS = [
|
||
{ label: '白色', value: '#ffffff' },
|
||
{ label: '浅灰', value: '#f8fafc' },
|
||
{ label: '暖灰', value: '#f3f0ea' },
|
||
{ label: '冷蓝', value: '#eef6ff' },
|
||
];
|
||
|
||
const DEFAULT_SPEC_FORM_VALUES: Record<SpecGenerationType, SpecFormValues> = {
|
||
character: {
|
||
playSetting: '战棋类RPG玩法',
|
||
artStyle: '像素风',
|
||
bodyRatio: '3',
|
||
characterView:
|
||
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
|
||
customPrompt: '',
|
||
},
|
||
ui: {
|
||
playSetting: '抓娃娃题材的抓大鹅玩法',
|
||
artStyle: '毛茸茸',
|
||
bodyRatio: '3',
|
||
characterView:
|
||
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
|
||
customPrompt: '',
|
||
},
|
||
icon: {
|
||
playSetting: '休闲小游戏',
|
||
artStyle: '清爽卡通',
|
||
bodyRatio: '3',
|
||
characterView:
|
||
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
|
||
customPrompt: '',
|
||
},
|
||
custom: {
|
||
playSetting: '',
|
||
artStyle: '',
|
||
bodyRatio: '3',
|
||
characterView:
|
||
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
|
||
customPrompt: '',
|
||
},
|
||
};
|
||
|
||
const SPEC_TYPE_LABEL: Record<SpecGenerationType, string> = {
|
||
character: '角色形象规范',
|
||
ui: 'UI素材规范',
|
||
icon: '图标素材规范',
|
||
custom: '自定义规范',
|
||
};
|
||
|
||
const CHARACTER_SPEC_VIEW_OPTIONS = [
|
||
DEFAULT_SPEC_FORM_VALUES.character.characterView,
|
||
'左向三分之二侧身站姿',
|
||
'左向三分之二侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯左视图,也禁止生成正面立绘。',
|
||
'右向三分之二侧身站姿,保留少量正面信息,强调面部轮廓、胸肩结构与主要装备层次。',
|
||
'背向斜侧身站姿,保留少量侧脸信息,突出背部服饰层次、武器挂载与轮廓识别。',
|
||
];
|
||
|
||
function clamp(value: number, min: number, max: number) {
|
||
return Math.min(max, Math.max(min, value));
|
||
}
|
||
|
||
function formatPercent(value: number) {
|
||
return `${Math.round(value * 100)}%`;
|
||
}
|
||
|
||
function formatImageSizeValue(width: number, height: number) {
|
||
const safeWidth = Math.max(1, Math.round(width || 1024));
|
||
const safeHeight = Math.max(1, Math.round(height || 1024));
|
||
return `${safeWidth}x${safeHeight}`;
|
||
}
|
||
|
||
function buildQuickEditSizeOptions(currentSize: string) {
|
||
return Array.from(new Set([currentSize, ...QUICK_EDIT_SIZE_PRESETS]));
|
||
}
|
||
|
||
function buildQuickEditModelOptions(currentModel: string) {
|
||
const options = [...QUICK_EDIT_MODEL_OPTIONS];
|
||
return options.some((option) => option.value === currentModel)
|
||
? options
|
||
: [{ label: currentModel, value: currentModel }, ...options];
|
||
}
|
||
|
||
function triggerPlaceholderAction(label: string) {
|
||
window.alert(`${label}功能建设中`);
|
||
}
|
||
|
||
function createLayerFromAsset(
|
||
asset: EditorAsset,
|
||
index: number,
|
||
viewport: CanvasViewport,
|
||
screenCenter: { x: number; y: number },
|
||
): CanvasLayer {
|
||
const longestSide = Math.max(asset.width, asset.height);
|
||
const sizeRatio = longestSide > 0 ? 360 / longestSide : 1;
|
||
const width = Math.round(asset.width * sizeRatio);
|
||
const height = Math.round(asset.height * sizeRatio);
|
||
const worldCenterX = (screenCenter.x - viewport.x) / viewport.scale;
|
||
const worldCenterY = (screenCenter.y - viewport.y) / viewport.scale;
|
||
const offset = index * 34;
|
||
|
||
return {
|
||
id: `layer-${asset.id}-${index}`,
|
||
resourceId: `local-resource-${asset.id}-${index}`,
|
||
title: asset.label,
|
||
src: asset.src,
|
||
x: worldCenterX - width / 2 + offset,
|
||
y: worldCenterY - height / 2 + offset,
|
||
width,
|
||
height,
|
||
originalWidth: asset.width,
|
||
originalHeight: asset.height,
|
||
zIndex: index + 10,
|
||
sourceType: asset.sourceType,
|
||
prompt: asset.prompt,
|
||
actualPrompt: asset.actualPrompt,
|
||
model: asset.model,
|
||
provider: asset.provider,
|
||
taskId: asset.taskId,
|
||
objectKey: asset.objectKey,
|
||
assetObjectId: asset.assetObjectId,
|
||
} satisfies CanvasLayer;
|
||
}
|
||
|
||
function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
|
||
return {
|
||
layerId: layer.id,
|
||
resourceId: layer.resourceId,
|
||
title: layer.title,
|
||
x: layer.x,
|
||
y: layer.y,
|
||
width: layer.width,
|
||
height: layer.height,
|
||
originalWidth: layer.originalWidth,
|
||
originalHeight: layer.originalHeight,
|
||
zIndex: layer.zIndex,
|
||
sourceType: layer.sourceType,
|
||
prompt: layer.prompt,
|
||
actualPrompt: layer.actualPrompt,
|
||
model: layer.model,
|
||
provider: layer.provider,
|
||
taskId: layer.taskId,
|
||
objectKey: layer.objectKey,
|
||
assetObjectId: layer.assetObjectId,
|
||
sourceResourceId: layer.sourceResourceId,
|
||
groupId: layer.groupId,
|
||
assetKind: layer.assetKind,
|
||
};
|
||
}
|
||
|
||
function hydrateLayer(
|
||
snapshot: EditorProjectLayerSnapshot,
|
||
resourcesById: Map<string, { imageSrc: string }>,
|
||
): CanvasLayer | null {
|
||
const resourceId =
|
||
typeof snapshot.resourceId === 'string' ? snapshot.resourceId : '';
|
||
const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : '';
|
||
const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : '';
|
||
const src = snapshotSrc || resourcesById.get(resourceId)?.imageSrc || '';
|
||
const title =
|
||
typeof snapshot.title === 'string' ? snapshot.title : '画布图片';
|
||
if (!resourceId || !layerId || !src) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id: layerId,
|
||
resourceId,
|
||
title,
|
||
src,
|
||
x: numberFromSnapshot(snapshot.x, 0),
|
||
y: numberFromSnapshot(snapshot.y, 0),
|
||
width: numberFromSnapshot(snapshot.width, 320),
|
||
height: numberFromSnapshot(snapshot.height, 320),
|
||
originalWidth: numberFromSnapshot(snapshot.originalWidth, 320),
|
||
originalHeight: numberFromSnapshot(snapshot.originalHeight, 320),
|
||
zIndex: numberFromSnapshot(snapshot.zIndex, 1),
|
||
sourceType: isCanvasSourceType(snapshot.sourceType)
|
||
? snapshot.sourceType
|
||
: 'uploaded',
|
||
prompt: stringOrNull(snapshot.prompt),
|
||
actualPrompt: stringOrNull(snapshot.actualPrompt),
|
||
model: stringOrNull(snapshot.model),
|
||
provider: stringOrNull(snapshot.provider),
|
||
taskId: stringOrNull(snapshot.taskId),
|
||
objectKey: stringOrNull(snapshot.objectKey),
|
||
assetObjectId: stringOrNull(snapshot.assetObjectId),
|
||
sourceResourceId: stringOrNull(snapshot.sourceResourceId),
|
||
groupId: stringOrNull(snapshot.groupId),
|
||
assetKind: canvasAssetKindOrNull(snapshot.assetKind),
|
||
};
|
||
}
|
||
|
||
function mapAssetLibrarySnapshot(library: EditorAssetLibrarySnapshot): {
|
||
folders: EditorAssetFolder[];
|
||
assets: EditorAsset[];
|
||
} {
|
||
return {
|
||
folders: library.folders.map((folder) => ({
|
||
id: folder.folderId,
|
||
label: folder.label,
|
||
collapsed: folder.collapsed,
|
||
systemDefault: folder.systemDefault,
|
||
persisted: true,
|
||
})),
|
||
assets: library.assets.map((asset) => ({
|
||
id: asset.assetId,
|
||
label: asset.label,
|
||
src: asset.imageSrc,
|
||
width: asset.width,
|
||
height: asset.height,
|
||
folderId: asset.folderId,
|
||
sourceKind: 'uploaded',
|
||
sourceType: asset.sourceType,
|
||
persisted: true,
|
||
prompt: asset.prompt ?? undefined,
|
||
actualPrompt: asset.actualPrompt ?? undefined,
|
||
model: asset.model ?? undefined,
|
||
provider: asset.provider ?? undefined,
|
||
taskId: asset.taskId ?? undefined,
|
||
objectKey: asset.objectKey ?? undefined,
|
||
assetObjectId: asset.assetObjectId ?? undefined,
|
||
})),
|
||
};
|
||
}
|
||
|
||
function mergeAssetLibraryWithBuiltIns(library: EditorAssetLibrarySnapshot) {
|
||
const mapped = mapAssetLibrarySnapshot(library);
|
||
const persistedFolderIds = new Set(mapped.folders.map((folder) => folder.id));
|
||
const builtInFolders = EDITOR_ASSET_FOLDERS.filter(
|
||
(folder) => !persistedFolderIds.has(folder.id),
|
||
);
|
||
return {
|
||
folders: [...mapped.folders, ...builtInFolders],
|
||
assets: [...EDITOR_ASSETS, ...mapped.assets],
|
||
};
|
||
}
|
||
|
||
function numberFromSnapshot(value: unknown, fallback: number) {
|
||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||
}
|
||
|
||
function stringOrNull(value: unknown) {
|
||
return typeof value === 'string' && value.trim() ? value : null;
|
||
}
|
||
|
||
function canvasAssetKindOrNull(value: unknown): CanvasLayer['assetKind'] {
|
||
return value === 'spec' ||
|
||
value === 'character' ||
|
||
value === 'icon' ||
|
||
value === 'icon-spec'
|
||
? value
|
||
: null;
|
||
}
|
||
|
||
function isCanvasSourceType(
|
||
value: unknown,
|
||
): value is CanvasLayer['sourceType'] {
|
||
return (
|
||
value === 'uploaded' || value === 'generated' || value === 'mock_generated'
|
||
);
|
||
}
|
||
|
||
function isGeneratedLayer(layer: CanvasLayer) {
|
||
return (
|
||
layer.sourceType === 'generated' || layer.sourceType === 'mock_generated'
|
||
);
|
||
}
|
||
|
||
function buildCharacterSpecPrompt(values: SpecFormValues) {
|
||
return [
|
||
'生成2D 角色美术视觉规范设定图,纯白底板,整齐排布全身标准立绘;固定统一头身比例、勾线粗细恒定;展示待机行走攻击基础动作帧样例,重心对齐不变位,服饰配饰分层结构示意,搭配专属角色色卡标注色号,无多余杂物,精准尺寸标注,高清矢量规范稿',
|
||
'禁止模糊、笔触杂乱、光影方向混乱、比例畸形、3D 渲染、实景照片、水印、花纹堆砌、画面抖动错位效果、噪点,',
|
||
`玩法设计:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.character.playSetting}`,
|
||
`美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.character.artStyle}`,
|
||
`头身比:${values.bodyRatio.trim() || DEFAULT_SPEC_FORM_VALUES.character.bodyRatio}`,
|
||
`视角要求:${values.characterView.trim() || DEFAULT_SPEC_FORM_VALUES.character.characterView}`,
|
||
].join('\n');
|
||
}
|
||
|
||
function buildUiSpecPrompt(values: SpecFormValues) {
|
||
return [
|
||
'生成一张完整游戏UI规范汇总设定展板,纯白色干净背景,Figma专业设计稿质感,矢量锐利线条,页面划分九大区域:色彩规范、字体规范、图标规范、按钮规范、组件规范、布局规范、特效规范、IP规范、主视觉。主视觉居中较大显示,其他八个区域环绕主视觉',
|
||
'',
|
||
`玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.ui.playSetting}`,
|
||
`美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.ui.artStyle}`,
|
||
].join('\n');
|
||
}
|
||
|
||
function buildIconSpecPrompt(values: SpecFormValues) {
|
||
return [
|
||
'生成一张游戏图标素材视觉规范展板,纯白色干净背景,展示按钮图标的统一视角、线条粗细、填充风格、描边、阴影、圆角、材质、状态层级和色彩规范,图标样例需要成组排列且风格高度统一。',
|
||
'',
|
||
`玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.icon.playSetting}`,
|
||
`美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.icon.artStyle}`,
|
||
].join('\n');
|
||
}
|
||
|
||
function buildSpecPrompt(type: SpecGenerationType, values: SpecFormValues) {
|
||
if (type === 'character') {
|
||
return buildCharacterSpecPrompt(values);
|
||
}
|
||
if (type === 'ui') {
|
||
return buildUiSpecPrompt(values);
|
||
}
|
||
if (type === 'icon') {
|
||
return buildIconSpecPrompt(values);
|
||
}
|
||
return values.customPrompt.trim();
|
||
}
|
||
|
||
function getLayerKindLabel(layer: CanvasLayer) {
|
||
if (layer.assetKind === 'spec') {
|
||
return '规范';
|
||
}
|
||
if (layer.assetKind === 'character') {
|
||
return '角色';
|
||
}
|
||
if (layer.assetKind === 'icon') {
|
||
return '图标';
|
||
}
|
||
if (layer.assetKind === 'icon-spec') {
|
||
return '图标规范';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function formatLayerImageType(layer: CanvasLayer) {
|
||
if (layer.assetKind === 'spec') {
|
||
return '规范图片';
|
||
}
|
||
if (layer.assetKind === 'character') {
|
||
return '角色图片';
|
||
}
|
||
if (layer.assetKind === 'icon') {
|
||
return '图标素材图片';
|
||
}
|
||
if (layer.assetKind === 'icon-spec') {
|
||
return '图标素材规范图片';
|
||
}
|
||
return isGeneratedLayer(layer) ? '生成图片' : '上传图片';
|
||
}
|
||
|
||
function calculateCharacterAnimationPrice(
|
||
resolution: EditorCharacterAnimationResolution,
|
||
durationSeconds: number,
|
||
) {
|
||
return (resolution === '720p' ? 20 : 10) * durationSeconds;
|
||
}
|
||
|
||
function createCanvasLayerReference(
|
||
layer: CanvasLayer,
|
||
): CharacterReferenceImage {
|
||
return {
|
||
id: `canvas-${layer.id}`,
|
||
label: layer.title,
|
||
src: layer.src,
|
||
};
|
||
}
|
||
|
||
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 getLayerBounds(targetLayers: CanvasLayer[]) {
|
||
if (targetLayers.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return targetLayers.reduce(
|
||
(current, layer) => ({
|
||
minX: Math.min(current.minX, layer.x),
|
||
minY: Math.min(current.minY, layer.y),
|
||
maxX: Math.max(current.maxX, layer.x + layer.width),
|
||
maxY: Math.max(current.maxY, layer.y + layer.height),
|
||
}),
|
||
{
|
||
minX: Number.POSITIVE_INFINITY,
|
||
minY: Number.POSITIVE_INFINITY,
|
||
maxX: Number.NEGATIVE_INFINITY,
|
||
maxY: Number.NEGATIVE_INFINITY,
|
||
},
|
||
);
|
||
}
|
||
|
||
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 isCanvasGenerationDialog(
|
||
dialog: GenerateDialogState | null,
|
||
): dialog is CanvasGenerationDialogState {
|
||
return Boolean(
|
||
dialog?.id &&
|
||
(dialog.mode === 'generate' ||
|
||
dialog.mode === 'spec' ||
|
||
dialog.mode === 'character' ||
|
||
dialog.mode === 'icon'),
|
||
);
|
||
}
|
||
|
||
function getGenerationFrameAriaLabel(dialog: CanvasGenerationDialogState) {
|
||
if (dialog.mode === 'character') {
|
||
return '角色生成占位图';
|
||
}
|
||
if (dialog.mode === 'spec') {
|
||
return '规范生成占位图';
|
||
}
|
||
if (dialog.mode === 'icon') {
|
||
return '图标素材生成占位图';
|
||
}
|
||
return '图像生成占位图';
|
||
}
|
||
|
||
function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) {
|
||
if (dialog.mode === 'character') {
|
||
return 'Character Generator';
|
||
}
|
||
if (dialog.mode === 'spec') {
|
||
return 'Spec Generator';
|
||
}
|
||
if (dialog.mode === 'icon') {
|
||
return 'Icon Generator';
|
||
}
|
||
return 'Image Generator';
|
||
}
|
||
|
||
function resolveImageGenerationErrorMessage(error: unknown) {
|
||
if (
|
||
error instanceof ApiClientError &&
|
||
(error.status === 401 || error.status === 403)
|
||
) {
|
||
return '请先登录后再生成图片';
|
||
}
|
||
|
||
return error instanceof Error && error.message.trim()
|
||
? error.message
|
||
: '生成图片失败';
|
||
}
|
||
|
||
export function ImageCanvasEditorView() {
|
||
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 isShiftPressedRef = useRef(false);
|
||
const layerCounterRef = useRef(INITIAL_LAYERS.length);
|
||
const generationDialogCounterRef = useRef(0);
|
||
const saveTimerRef = useRef<number | null>(null);
|
||
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 generateDialogRef = useRef<GenerateDialogState | null>(null);
|
||
const inactiveGenerateDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
|
||
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
|
||
() => {},
|
||
);
|
||
const [projectId, setProjectId] = useState<string | null>(null);
|
||
const [isProjectReady, setIsProjectReady] = 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[]>(EDITOR_ASSETS);
|
||
const [layers, setLayers] = useState<CanvasLayer[]>(INITIAL_LAYERS);
|
||
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 [canvasMarquee, setCanvasMarquee] = useState<CanvasMarqueeState | null>(
|
||
null,
|
||
);
|
||
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(
|
||
INITIAL_LAYERS[0]?.id ?? null,
|
||
);
|
||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>(
|
||
INITIAL_LAYERS[0]?.id ? [INITIAL_LAYERS[0].id] : [],
|
||
);
|
||
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 [isBackgroundMenuOpen, setIsBackgroundMenuOpen] = useState(false);
|
||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
||
const [isMinimapOpen, setIsMinimapOpen] = useState(true);
|
||
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState(
|
||
CANVAS_BACKGROUND_OPTIONS[1]?.value ?? '#f8fafc',
|
||
);
|
||
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 [quickEditPanel, setQuickEditPanel] =
|
||
useState<QuickEditPanelState | null>(null);
|
||
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
||
useState<CharacterAnimationPanelState | null>(null);
|
||
|
||
selectedLayerIdRef.current = selectedLayerId;
|
||
generateDialogRef.current = generateDialog;
|
||
inactiveGenerateDialogsRef.current = inactiveGenerateDialogs;
|
||
|
||
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 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 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 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);
|
||
}, [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(() => {});
|
||
},
|
||
[],
|
||
);
|
||
|
||
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 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(() => {
|
||
if (!cancelled) {
|
||
setIsProjectReady(false);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [createProjectResourceForLayer, selectSingleLayer]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
loadEditorAssetLibrary()
|
||
.then((library) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
const nextLibrary = mergeAssetLibraryWithBuiltIns(library);
|
||
setAssetFolders(nextLibrary.folders);
|
||
setAssets(nextLibrary.assets);
|
||
const defaultFolder = nextLibrary.folders.find(
|
||
(folder) => folder.systemDefault,
|
||
);
|
||
setActiveUploadFolderId(
|
||
defaultFolder?.id ?? nextLibrary.folders[0]?.id ?? 'project',
|
||
);
|
||
})
|
||
.catch(() => {});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
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.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);
|
||
setIsBackgroundMenuOpen(false);
|
||
setIsSpecMenuOpen(false);
|
||
setImageContextMenu(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);
|
||
};
|
||
}, []);
|
||
|
||
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(() => {
|
||
if (!projectId || !isProjectReady) {
|
||
return undefined;
|
||
}
|
||
if (saveTimerRef.current) {
|
||
window.clearTimeout(saveTimerRef.current);
|
||
}
|
||
|
||
saveTimerRef.current = window.setTimeout(() => {
|
||
saveEditorProjectLayout(projectId, {
|
||
viewport,
|
||
layers: layers.map(serializeLayer),
|
||
}).catch(() => {});
|
||
}, 450);
|
||
|
||
return () => {
|
||
if (saveTimerRef.current) {
|
||
window.clearTimeout(saveTimerRef.current);
|
||
}
|
||
};
|
||
}, [isProjectReady, layers, 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,
|
||
);
|
||
|
||
setViewport({
|
||
x: canvasSize.width / 2 - (bounds.minX + boundsWidth / 2) * scale,
|
||
y: canvasSize.height / 2 - (bounds.minY + boundsHeight / 2) * scale,
|
||
scale,
|
||
});
|
||
},
|
||
[canvasSize.height, canvasSize.width, layers],
|
||
);
|
||
|
||
const updateScaleFromCenter = (nextScale: number) => {
|
||
const viewportElement = canvasViewportRef.current;
|
||
if (!viewportElement) {
|
||
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;
|
||
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 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,
|
||
},
|
||
);
|
||
setLayers((currentLayers) => [...currentLayers, nextLayer]);
|
||
selectSingleLayer(nextLayer.id);
|
||
setHoveredLayerId(null);
|
||
createProjectResourceForLayer(nextLayer);
|
||
};
|
||
|
||
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),
|
||
);
|
||
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 = mergeAssetLibraryWithBuiltIns(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];
|
||
setAssets((currentAssets) =>
|
||
currentAssets.filter((asset) => !selectedAssetIds.has(asset.id)),
|
||
);
|
||
setSelectedAssetIds(new Set());
|
||
ids.forEach((assetId) => {
|
||
void deleteEditorAsset(assetId);
|
||
});
|
||
};
|
||
|
||
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 uploadIndex = options.uploadIndex ?? layerCounterRef.current + 1;
|
||
layerCounterRef.current = Math.max(layerCounterRef.current, uploadIndex);
|
||
const imageSrc = await readImageFileAsDataUrl(file);
|
||
const fallbackWidth = 420;
|
||
const fallbackHeight = 315;
|
||
const uploadFolderId = assetFolders.some(
|
||
(folder) => folder.id === (options.folderId ?? activeUploadFolderId),
|
||
)
|
||
? (options.folderId ?? activeUploadFolderId)
|
||
: 'project';
|
||
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',
|
||
};
|
||
const uploadedAsset: EditorAsset = {
|
||
id: `upload-${uploadIndex}`,
|
||
label: file.name || '上传图片',
|
||
src: imageSrc,
|
||
width: fallbackWidth,
|
||
height: fallbackHeight,
|
||
folderId: uploadFolderId,
|
||
sourceKind: 'uploaded',
|
||
sourceType: 'uploaded',
|
||
persisted: false,
|
||
};
|
||
|
||
if (options.addToCanvas) {
|
||
setLayers((currentLayers) => [...currentLayers, nextLayer]);
|
||
}
|
||
setAssets((currentAssets) => [...currentAssets, uploadedAsset]);
|
||
setAssetFolders((currentFolders) =>
|
||
currentFolders.map((folder) =>
|
||
folder.id === uploadFolderId
|
||
? {
|
||
...folder,
|
||
collapsed: false,
|
||
}
|
||
: folder,
|
||
),
|
||
);
|
||
if (options.addToCanvas) {
|
||
selectSingleLayer(nextLayer.id);
|
||
setActiveSidebarPanel('layers');
|
||
}
|
||
|
||
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,
|
||
}
|
||
: currentAsset,
|
||
),
|
||
);
|
||
})
|
||
.catch(() => {});
|
||
|
||
if (options.addToCanvas) {
|
||
createProjectResourceForLayer(nextLayer);
|
||
}
|
||
|
||
if (imageSrc) {
|
||
const uploadedImage = new Image();
|
||
uploadedImage.onload = () => {
|
||
const originalWidth = uploadedImage.naturalWidth || fallbackWidth;
|
||
const originalHeight = uploadedImage.naturalHeight || fallbackHeight;
|
||
const longestSide = Math.max(originalWidth, originalHeight);
|
||
const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1;
|
||
const width = Math.round(originalWidth * sizeRatio);
|
||
const height = Math.round(originalHeight * sizeRatio);
|
||
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;
|
||
} = {},
|
||
) => {
|
||
Array.from(files).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);
|
||
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 = () => deleteLayerById(selectedLayerId);
|
||
|
||
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;
|
||
} = {},
|
||
) => {
|
||
layerCounterRef.current += 1;
|
||
const generatedIndex = layerCounterRef.current;
|
||
const originalWidth = generated.width || 1024;
|
||
const originalHeight = generated.height || 1024;
|
||
const longestSide = Math.max(originalWidth, originalHeight);
|
||
const sizeRatio = longestSide > 0 ? Math.min(1, 420 / longestSide) : 1;
|
||
const width = options.frame?.width ?? Math.round(originalWidth * sizeRatio);
|
||
const height =
|
||
options.frame?.height ?? Math.round(originalHeight * sizeRatio);
|
||
const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale;
|
||
const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale;
|
||
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
|
||
: (options.frame?.x ?? worldCenterX - width / 2),
|
||
y: options.sourceLayer
|
||
? options.sourceLayer.y
|
||
: (options.frame?.y ?? 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,
|
||
};
|
||
|
||
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,
|
||
) => {
|
||
layerCounterRef.current += 1;
|
||
const generatedIndex = layerCounterRef.current;
|
||
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: sourceLayer.width,
|
||
height: sourceLayer.height,
|
||
originalWidth: sourceLayer.originalWidth,
|
||
originalHeight: sourceLayer.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,
|
||
};
|
||
|
||
setLayers((currentLayers) => [...currentLayers, nextLayer]);
|
||
selectSingleLayer(nextLayer.id);
|
||
setActiveSidebarPanel('layers');
|
||
setQuickEditPanel(null);
|
||
setActiveTool('select');
|
||
fitLayers([sourceLayer, nextLayer]);
|
||
createProjectResourceForLayer(nextLayer);
|
||
};
|
||
|
||
const addIconSpritesheetResultLayers = (
|
||
generated: EditorIconSpritesheetGenerationResult,
|
||
iconResults: EditorIconSpritesheetIconResult[],
|
||
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 longestSide = Math.max(originalWidth, originalHeight);
|
||
const sizeRatio = longestSide > 0 ? Math.min(1, 128 / longestSide) : 1;
|
||
const width = Math.round(originalWidth * sizeRatio);
|
||
const height = Math.round(originalHeight * sizeRatio);
|
||
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',
|
||
});
|
||
|
||
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,
|
||
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);
|
||
} 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 });
|
||
} 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,
|
||
});
|
||
} 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,
|
||
});
|
||
} else {
|
||
const generated = await generateEditorImage({
|
||
prompt: normalizedPrompt,
|
||
});
|
||
addGeneratedResultLayer(generated, {
|
||
frame: getGeneratingDialogPlaceholder(dialog),
|
||
dialogId: canvasDialog?.id,
|
||
});
|
||
}
|
||
} 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 (event.dataTransfer.types.includes('Files')) {
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = 'copy';
|
||
}
|
||
};
|
||
|
||
const handleCanvasDrop = (event: ReactDragEvent<HTMLDivElement>) => {
|
||
const files = event.dataTransfer.files;
|
||
if (!files.length) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
const rect = canvasViewportRef.current?.getBoundingClientRect();
|
||
const canvasPoint = rect
|
||
? {
|
||
x: event.clientX - rect.left,
|
||
y: event.clientY - rect.top,
|
||
}
|
||
: {
|
||
x: canvasSize.width / 2,
|
||
y: canvasSize.height / 2,
|
||
};
|
||
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()}`;
|
||
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: characterAnimationSourceLayer.src,
|
||
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 = '';
|
||
}}
|
||
/>
|
||
{activeSidebarPanel ? (
|
||
<aside className="image-canvas-editor__sidebar" aria-label="图片资源栏">
|
||
<div className="image-canvas-editor__sidebar-header">
|
||
<div className="min-w-0">
|
||
<h2 className="image-canvas-editor__sidebar-title">
|
||
{activeSidebarPanel === 'assets' ? '素材' : '图层'}
|
||
</h2>
|
||
<div className="image-canvas-editor__sidebar-count">
|
||
{activeSidebarPanel === 'assets'
|
||
? assets.length
|
||
: layers.length}
|
||
</div>
|
||
</div>
|
||
{activeSidebarPanel === 'assets' ? (
|
||
<div className="image-canvas-editor__sidebar-header-actions">
|
||
<EditorIconButton
|
||
className="image-canvas-editor__icon-button"
|
||
label="素材选择模式"
|
||
title="选择"
|
||
icon={isAssetSelectionMode ? CheckSquare : Square}
|
||
pressed={isAssetSelectionMode}
|
||
onClick={() =>
|
||
setIsAssetSelectionMode((currentMode) => !currentMode)
|
||
}
|
||
/>
|
||
<EditorIconButton
|
||
className="image-canvas-editor__icon-button"
|
||
label="新建素材文件夹"
|
||
title="新建文件夹"
|
||
icon={FolderPlus}
|
||
onClick={() => setCreatingFolder(true)}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<EditorIconButton
|
||
className="image-canvas-editor__icon-button"
|
||
label="图层打组"
|
||
title="打组"
|
||
icon={FolderPlus}
|
||
disabled={!selectedLayerId && selectedLayerIds.length === 0}
|
||
onClick={groupSelectedLayers}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{activeSidebarPanel === 'assets' ? (
|
||
<div
|
||
ref={assetListRef}
|
||
className="image-canvas-editor__asset-list"
|
||
onPointerDown={handleAssetMarqueePointerDown}
|
||
onPointerMove={handleAssetMarqueePointerMove}
|
||
onPointerUp={handleAssetMarqueePointerUp}
|
||
onPointerCancel={handleAssetMarqueePointerUp}
|
||
>
|
||
{creatingFolder ? (
|
||
<form
|
||
className="image-canvas-editor__folder-create"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
void commitNewAssetFolder();
|
||
}}
|
||
>
|
||
<PlatformTextField
|
||
aria-label="素材文件夹名称"
|
||
value={newFolderName}
|
||
autoFocus
|
||
size="xs"
|
||
density="compact"
|
||
className="image-canvas-editor__folder-create-input"
|
||
onChange={(event) => setNewFolderName(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
setCreatingFolder(false);
|
||
setNewFolderName('');
|
||
}
|
||
}}
|
||
/>
|
||
<EditorIconButton
|
||
type="submit"
|
||
label="保存素材文件夹"
|
||
icon={Check}
|
||
/>
|
||
<EditorIconButton
|
||
label="取消新建素材文件夹"
|
||
icon={X}
|
||
onClick={() => {
|
||
setCreatingFolder(false);
|
||
setNewFolderName('');
|
||
}}
|
||
/>
|
||
</form>
|
||
) : null}
|
||
{groupedAssets.map((folder) => (
|
||
<section
|
||
key={folder.id}
|
||
className="image-canvas-editor__asset-folder"
|
||
aria-label={folder.label}
|
||
onDragOver={(event) => {
|
||
if (event.dataTransfer.types.includes('Files')) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
event.dataTransfer.dropEffect = 'copy';
|
||
}
|
||
}}
|
||
onDrop={(event) => {
|
||
if (!event.dataTransfer.files.length) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
addUploadedFiles(event.dataTransfer.files, {
|
||
folderId: folder.id,
|
||
});
|
||
}}
|
||
>
|
||
<div className="image-canvas-editor__asset-folder-header">
|
||
<EditorIconButton
|
||
label={`${folder.collapsed ? '展开' : '折叠'}${folder.label}`}
|
||
title={folder.collapsed ? '展开' : '折叠'}
|
||
icon={folder.collapsed ? ChevronRight : ChevronDown}
|
||
expanded={!folder.collapsed}
|
||
onClick={() => toggleAssetFolder(folder.id)}
|
||
/>
|
||
<Folder className="h-4 w-4" />
|
||
{renamingFolder?.folderId === folder.id ? (
|
||
<PlatformTextField
|
||
aria-label={`重命名文件夹${folder.label}`}
|
||
value={renamingFolder.value}
|
||
autoFocus
|
||
size="xs"
|
||
density="compact"
|
||
className="image-canvas-editor__folder-rename-input"
|
||
onChange={(event) =>
|
||
setRenamingFolder({
|
||
folderId: folder.id,
|
||
value: event.target.value,
|
||
})
|
||
}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
commitFolderRename(folder);
|
||
}
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
setRenamingFolder(null);
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<span>{folder.label}</span>
|
||
)}
|
||
<span>{folder.assets.length}</span>
|
||
{renamingFolder?.folderId === folder.id ? (
|
||
<>
|
||
<EditorIconButton
|
||
label={`保存文件夹${folder.label}名称`}
|
||
title="保存"
|
||
icon={Check}
|
||
onClick={() => commitFolderRename(folder)}
|
||
/>
|
||
<EditorIconButton
|
||
label={`取消重命名文件夹${folder.label}`}
|
||
title="取消"
|
||
icon={X}
|
||
onClick={() => setRenamingFolder(null)}
|
||
/>
|
||
</>
|
||
) : (
|
||
<EditorIconButton
|
||
label={`重命名文件夹${folder.label}`}
|
||
title="重命名"
|
||
icon={PencilLine}
|
||
onClick={() => startRenamingFolder(folder)}
|
||
/>
|
||
)}
|
||
{!folder.systemDefault ? (
|
||
<EditorIconButton
|
||
label={`删除文件夹${folder.label}`}
|
||
title="删除"
|
||
icon={Trash2}
|
||
onClick={() => deleteAssetFolder(folder)}
|
||
/>
|
||
) : null}
|
||
<EditorIconButton
|
||
label={`上传到${folder.label}`}
|
||
title="上传"
|
||
icon={ImagePlus}
|
||
onClick={() => {
|
||
setActiveUploadFolderId(folder.id);
|
||
setUploadTarget('asset');
|
||
uploadInputRef.current?.click();
|
||
}}
|
||
/>
|
||
</div>
|
||
<div
|
||
className="image-canvas-editor__asset-folder-list"
|
||
hidden={folder.collapsed}
|
||
>
|
||
{folder.assets.map((asset) => {
|
||
const isRenaming = renamingAsset?.assetId === asset.id;
|
||
const titleNode = isRenaming ? (
|
||
<PlatformTextField
|
||
aria-label={`重命名素材${asset.label}`}
|
||
value={renamingAsset.value}
|
||
autoFocus
|
||
size="xs"
|
||
density="compact"
|
||
className="image-canvas-editor__asset-rename-input"
|
||
onChange={(event) =>
|
||
setRenamingAsset({
|
||
assetId: asset.id,
|
||
value: event.target.value,
|
||
})
|
||
}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
commitAssetRename(asset);
|
||
}
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
setRenamingAsset(null);
|
||
}
|
||
}}
|
||
/>
|
||
) : undefined;
|
||
const actions = isRenaming ? (
|
||
<div className="image-canvas-editor__asset-actions">
|
||
<EditorIconButton
|
||
label={`保存素材${asset.label}名称`}
|
||
title="保存"
|
||
icon={Check}
|
||
onClick={() => commitAssetRename(asset)}
|
||
/>
|
||
<EditorIconButton
|
||
label={`取消重命名素材${asset.label}`}
|
||
title="取消"
|
||
icon={X}
|
||
onClick={() => setRenamingAsset(null)}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="image-canvas-editor__asset-actions">
|
||
<EditorIconButton
|
||
label={`重命名素材${asset.label}`}
|
||
title="重命名"
|
||
icon={Pencil}
|
||
onClick={() => startRenamingAsset(asset)}
|
||
/>
|
||
{asset.sourceKind === 'uploaded' ? (
|
||
<EditorIconButton
|
||
label={`删除素材${asset.label}`}
|
||
title="删除"
|
||
icon={Trash2}
|
||
onClick={() => deleteUploadedAsset(asset)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
return (
|
||
<div key={asset.id} data-asset-id={asset.id}>
|
||
<SidebarMediaItem
|
||
title={asset.label}
|
||
detail={`${asset.width} x ${asset.height}`}
|
||
imageSrc={asset.src}
|
||
imageAlt={`素材:${asset.label}`}
|
||
primaryLabel={
|
||
isAssetSelectionMode
|
||
? `选择素材${asset.label}`
|
||
: `添加${asset.label}`
|
||
}
|
||
onPrimaryClick={() => {
|
||
if (isAssetSelectionMode) {
|
||
toggleAssetSelected(asset.id);
|
||
return;
|
||
}
|
||
addAssetLayer(asset);
|
||
}}
|
||
selected={selectedAssetIds.has(asset.id)}
|
||
rowClassName="image-canvas-editor__asset-row"
|
||
primaryClassName="image-canvas-editor__asset-button"
|
||
thumbnailClassName="image-canvas-editor__asset-thumb"
|
||
metaClassName="image-canvas-editor__asset-meta"
|
||
titleNode={titleNode}
|
||
actions={actions}
|
||
onPointerEnter={(event) => {
|
||
if (isAssetSelectionMode && event.buttons === 1) {
|
||
setSelectedAssetIds((currentIds) => {
|
||
const nextIds = new Set(currentIds);
|
||
nextIds.add(asset.id);
|
||
return nextIds;
|
||
});
|
||
}
|
||
}}
|
||
onDragOver={(event) => {
|
||
if (event.dataTransfer.types.includes('Files')) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
event.dataTransfer.dropEffect = 'copy';
|
||
}
|
||
}}
|
||
onDrop={(event) => {
|
||
if (!event.dataTransfer.files.length) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
addUploadedFiles(event.dataTransfer.files, {
|
||
folderId: asset.folderId,
|
||
});
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
))}
|
||
{isAssetSelectionMode ? (
|
||
<PlatformBatchActionToolbar
|
||
className="image-canvas-editor__asset-batch-toolbar"
|
||
label="素材批量操作"
|
||
>
|
||
<PlatformActionButton
|
||
tone="secondary"
|
||
size="sm"
|
||
onClick={toggleAllAssetsSelected}
|
||
>
|
||
{allSelectableAssetsSelected ? (
|
||
<CheckSquare className="h-4 w-4" />
|
||
) : (
|
||
<Square className="h-4 w-4" />
|
||
)}
|
||
{selectedAssetIds.size > 0
|
||
? `${allSelectableAssetsSelected ? '取消全选' : '全选'} · 已选 ${selectedAssetIds.size}`
|
||
: '全选'}
|
||
</PlatformActionButton>
|
||
<PlatformActionButton
|
||
tone="warning"
|
||
size="sm"
|
||
disabled={selectedAssetIds.size === 0}
|
||
onClick={deleteSelectedAssets}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
删除
|
||
</PlatformActionButton>
|
||
<PlatformActionButton
|
||
tone="secondary"
|
||
size="sm"
|
||
onClick={closeAssetSelectionMode}
|
||
>
|
||
取消
|
||
</PlatformActionButton>
|
||
</PlatformBatchActionToolbar>
|
||
) : null}
|
||
{assetMarquee ? (
|
||
<div
|
||
className="image-canvas-editor__asset-marquee"
|
||
aria-hidden="true"
|
||
style={{
|
||
left: Math.min(assetMarquee.startX, assetMarquee.currentX),
|
||
top: Math.min(assetMarquee.startY, assetMarquee.currentY),
|
||
width: Math.abs(
|
||
assetMarquee.currentX - assetMarquee.startX,
|
||
),
|
||
height: Math.abs(
|
||
assetMarquee.currentY - assetMarquee.startY,
|
||
),
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<div className="image-canvas-editor__layers-list">
|
||
{layers
|
||
.slice()
|
||
.sort((left, right) => right.zIndex - left.zIndex)
|
||
.map((layer) => (
|
||
<SidebarMediaItem
|
||
key={layer.id}
|
||
title={layer.title}
|
||
detail={`${Math.round(layer.width)} x ${Math.round(layer.height)}${layer.groupId ? ' · 已打组' : ''}`}
|
||
imageSrc={layer.src}
|
||
imageAlt={`图层缩略图:${layer.title}`}
|
||
selected={selectedLayerId === layer.id}
|
||
primaryLabel={`选择图层${layer.title}`}
|
||
onPrimaryClick={() => selectSingleLayer(layer.id)}
|
||
rowClassName="image-canvas-editor__layer-row"
|
||
primaryClassName="image-canvas-editor__layer-row-button"
|
||
thumbnailClassName="image-canvas-editor__layer-row-thumb"
|
||
metaClassName="image-canvas-editor__layer-row-meta"
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</aside>
|
||
) : null}
|
||
|
||
<div className="image-canvas-editor__main">
|
||
<div className="image-canvas-editor__topbar">
|
||
<div className="image-canvas-editor__title-block">
|
||
<h1>图片编辑器</h1>
|
||
<span>画布</span>
|
||
</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}
|
||
onDrop={handleCanvasDrop}
|
||
>
|
||
<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()
|
||
.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' : ''}`}
|
||
style={{
|
||
left: layer.x,
|
||
top: layer.y,
|
||
width: layer.width,
|
||
height: layer.height,
|
||
zIndex: layer.zIndex,
|
||
}}
|
||
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();
|
||
selectSingleLayer(layer.id);
|
||
setImageContextMenu({
|
||
layerId: layer.id,
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
});
|
||
}}
|
||
onMouseEnter={() => setHoveredLayerId(layer.id)}
|
||
onMouseLeave={() =>
|
||
setHoveredLayerId((currentId) =>
|
||
currentId === layer.id ? null : currentId,
|
||
)
|
||
}
|
||
aria-label={`选择${layer.title}`}
|
||
>
|
||
<img src={layer.src} alt={`画布图片:${layer.title}`} />
|
||
{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.width)} x {Math.round(layer.height)}{' '}
|
||
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}
|
||
|
||
<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()}
|
||
>
|
||
<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={isBackgroundMenuOpen}
|
||
onClick={() => setIsBackgroundMenuOpen((open) => !open)}
|
||
icon={
|
||
<span
|
||
className="image-canvas-editor__background-swatch-current"
|
||
style={{ backgroundColor: canvasBackgroundColor }}
|
||
/>
|
||
}
|
||
/>
|
||
{isBackgroundMenuOpen ? (
|
||
<PlatformFloatingMenu
|
||
className="image-canvas-editor__background-menu"
|
||
label="画布背景色菜单"
|
||
placement="top-start"
|
||
>
|
||
{CANVAS_BACKGROUND_OPTIONS.map((option) => (
|
||
<PlatformFloatingMenuItem
|
||
key={option.value}
|
||
className="image-canvas-editor__background-menu-item"
|
||
aria-label={`切换画布背景色为${option.label}`}
|
||
aria-pressed={canvasBackgroundColor === option.value}
|
||
onClick={() => {
|
||
setCanvasBackgroundColor(option.value);
|
||
setIsBackgroundMenuOpen(false);
|
||
}}
|
||
>
|
||
<span
|
||
className="image-canvas-editor__background-swatch"
|
||
style={{ backgroundColor: option.value }}
|
||
/>
|
||
</PlatformFloatingMenuItem>
|
||
))}
|
||
</PlatformFloatingMenu>
|
||
) : 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 ? (
|
||
<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>Prompt</dt>
|
||
<dd className="image-canvas-editor__metadata-prompt">
|
||
{metadataLayer.prompt ? (
|
||
<>
|
||
<span>{metadataLayer.prompt}</span>
|
||
<PlatformActionButton
|
||
type="button"
|
||
tone="secondary"
|
||
size="xs"
|
||
className="image-canvas-editor__metadata-copy"
|
||
onClick={() => {
|
||
void navigator.clipboard?.writeText(
|
||
metadataLayer.prompt ?? '',
|
||
);
|
||
}}
|
||
>
|
||
复制Prompt
|
||
</PlatformActionButton>
|
||
</>
|
||
) : (
|
||
'-'
|
||
)}
|
||
</dd>
|
||
<dt>Model</dt>
|
||
<dd>{metadataLayer.model ?? '-'}</dd>
|
||
<dt>Size</dt>
|
||
<dd>
|
||
{Math.round(metadataLayer.width)} x{' '}
|
||
{Math.round(metadataLayer.height)} px
|
||
</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>
|
||
);
|
||
}
|
||
|
||
function resolveSnappedLayerPosition(
|
||
movingLayer: CanvasLayer,
|
||
proposedX: number,
|
||
proposedY: number,
|
||
layers: CanvasLayer[],
|
||
scale: number,
|
||
) {
|
||
const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE);
|
||
const verticalTargets = [
|
||
0,
|
||
CANVAS_WORLD_ORIGIN,
|
||
...layers
|
||
.filter((layer) => layer.id !== movingLayer.id)
|
||
.flatMap((layer) => [
|
||
layer.x,
|
||
layer.x + layer.width / 2,
|
||
layer.x + layer.width,
|
||
]),
|
||
];
|
||
const horizontalTargets = [
|
||
0,
|
||
CANVAS_WORLD_ORIGIN,
|
||
...layers
|
||
.filter((layer) => layer.id !== movingLayer.id)
|
||
.flatMap((layer) => [
|
||
layer.y,
|
||
layer.y + layer.height / 2,
|
||
layer.y + layer.height,
|
||
]),
|
||
];
|
||
|
||
const xSnap = findNearestSnap(
|
||
proposedX,
|
||
[0, movingLayer.width / 2, movingLayer.width],
|
||
verticalTargets,
|
||
threshold,
|
||
);
|
||
const ySnap = findNearestSnap(
|
||
proposedY,
|
||
[0, movingLayer.height / 2, movingLayer.height],
|
||
horizontalTargets,
|
||
threshold,
|
||
);
|
||
|
||
return {
|
||
x: xSnap ? xSnap.position : proposedX,
|
||
y: ySnap ? ySnap.position : proposedY,
|
||
guide:
|
||
xSnap || ySnap
|
||
? {
|
||
vertical: xSnap?.guide,
|
||
horizontal: ySnap?.guide,
|
||
}
|
||
: null,
|
||
};
|
||
}
|
||
|
||
function findNearestSnap(
|
||
origin: number,
|
||
offsets: number[],
|
||
targets: number[],
|
||
threshold: number,
|
||
): SnapCandidate | null {
|
||
let nearest: SnapCandidate | null = null;
|
||
for (const offset of offsets) {
|
||
for (const target of targets) {
|
||
const distance = Math.abs(target - (origin + offset));
|
||
if (distance > threshold) {
|
||
continue;
|
||
}
|
||
if (!nearest || distance < nearest.distance) {
|
||
nearest = {
|
||
position: target - offset,
|
||
guide: target,
|
||
distance,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
return nearest;
|
||
}
|
||
|
||
export default ImageCanvasEditorView;
|