Files
Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx
kdletters 3d2dc1951f 合并图片画布素材分支
将 codex/editor-asset-library 合并到 dev-jenken

保留编辑器生成规范、角色形象和图标素材能力

补回画布布局轻量保存和小地图拖拽手感修复
2026-06-16 17:08:28 +08:00

6122 lines
210 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;