编辑器顶部缩放百分比触发器改为复用 PlatformInlineOptionButton。 保留缩放入口局部尺寸和 hover 视觉覆盖,移除重复混入编辑器图标按钮基础规则。 补充编辑器测试断言共享按钮原语,并更新 TRACKING。
3083 lines
100 KiB
TypeScript
3083 lines
100 KiB
TypeScript
import {
|
|
Braces,
|
|
Check,
|
|
CheckSquare,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
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 DragEvent as ReactDragEvent,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
type PointerEvent as ReactPointerEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type WheelEvent as ReactWheelEvent,
|
|
} from 'react';
|
|
|
|
import {
|
|
createEditorProjectResource,
|
|
createEditorAsset,
|
|
createEditorAssetFolder,
|
|
deleteEditorAsset,
|
|
deleteEditorAssetFolder,
|
|
editEditorImage,
|
|
type EditorAssetLibrarySnapshot,
|
|
type EditorImageGenerationResult,
|
|
type EditorProjectLayerSnapshot,
|
|
generateEditorImage,
|
|
loadEditorAssetLibrary,
|
|
loadEditorProject,
|
|
loadOrCreateRecentEditorProject,
|
|
saveEditorProjectLayout,
|
|
updateEditorAsset,
|
|
updateEditorAssetFolder,
|
|
} from '../../services/image-editor/editorProjectClient';
|
|
import { ApiClientError } from '../../services/apiClient';
|
|
import {
|
|
EditorIconButton,
|
|
SidebarMediaItem,
|
|
} from './ImageCanvasEditorPrimitives';
|
|
import { PlatformActionButton } from '../common/PlatformActionButton';
|
|
import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
|
|
import {
|
|
PlatformFloatingMenu,
|
|
PlatformFloatingMenuItem,
|
|
} from '../common/PlatformFloatingMenu';
|
|
import { PlatformIconButton } from '../common/PlatformIconButton';
|
|
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
|
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
|
import { PlatformTextField } from '../common/PlatformTextField';
|
|
import { UnifiedModal } from '../common/UnifiedModal';
|
|
|
|
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;
|
|
};
|
|
|
|
type CanvasViewport = {
|
|
x: number;
|
|
y: number;
|
|
scale: number;
|
|
};
|
|
|
|
type CanvasTool =
|
|
| 'select'
|
|
| 'hand'
|
|
| 'upload'
|
|
| 'generate'
|
|
| 'text'
|
|
| 'shape'
|
|
| 'export';
|
|
|
|
type SidebarPanel = 'assets' | 'layers';
|
|
|
|
type EditorAssetFolder = {
|
|
id: string;
|
|
label: string;
|
|
collapsed: boolean;
|
|
systemDefault: boolean;
|
|
persisted: boolean;
|
|
};
|
|
|
|
type GenerateDialogState = {
|
|
mode: 'generate' | 'edit';
|
|
prompt: string;
|
|
status: 'idle' | 'generating' | 'failed';
|
|
sourceLayerId?: string;
|
|
generatedLayerId?: string;
|
|
errorMessage?: string;
|
|
placeholder?: {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
originalWidth: number;
|
|
originalHeight: number;
|
|
};
|
|
};
|
|
|
|
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 DragState =
|
|
| {
|
|
kind: 'pan';
|
|
pointerId: number;
|
|
startClientX: number;
|
|
startClientY: number;
|
|
startViewport: CanvasViewport;
|
|
}
|
|
| {
|
|
kind: 'layer';
|
|
pointerId: number;
|
|
layerId: string;
|
|
startClientX: number;
|
|
startClientY: number;
|
|
startLayerX: number;
|
|
startLayerY: number;
|
|
startScale: number;
|
|
}
|
|
| {
|
|
kind: 'generation-frame';
|
|
pointerId: number;
|
|
startClientX: number;
|
|
startClientY: number;
|
|
startFrameX: number;
|
|
startFrameY: number;
|
|
startScale: number;
|
|
}
|
|
| {
|
|
kind: 'minimap';
|
|
pointerId: number;
|
|
};
|
|
|
|
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: 'references',
|
|
sourceKind: 'built-in',
|
|
sourceType: 'uploaded',
|
|
persisted: false,
|
|
},
|
|
{
|
|
id: 'bark-battle',
|
|
label: '声浪素材',
|
|
src: '/creation-type-references/bark-battle.webp',
|
|
width: 640,
|
|
height: 900,
|
|
folderId: 'references',
|
|
sourceKind: 'built-in',
|
|
sourceType: 'uploaded',
|
|
persisted: false,
|
|
},
|
|
{
|
|
id: 'visual-novel',
|
|
label: '视觉小说素材',
|
|
src: '/creation-type-references/visual-novel.webp',
|
|
width: 720,
|
|
height: 405,
|
|
folderId: 'references',
|
|
sourceKind: 'built-in',
|
|
sourceType: 'uploaded',
|
|
persisted: false,
|
|
},
|
|
];
|
|
|
|
const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
|
|
{
|
|
id: 'project',
|
|
label: '项目素材',
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
persisted: false,
|
|
},
|
|
{
|
|
id: 'references',
|
|
label: '参考素材',
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
persisted: false,
|
|
},
|
|
{
|
|
id: 'uploads',
|
|
label: '上传素材',
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
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 CANVAS_BACKGROUND_OPTIONS = [
|
|
{ label: '白色', value: '#ffffff' },
|
|
{ label: '浅灰', value: '#f8fafc' },
|
|
{ label: '暖灰', value: '#f3f0ea' },
|
|
{ label: '冷蓝', value: '#eef6ff' },
|
|
];
|
|
|
|
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 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,
|
|
src: layer.src,
|
|
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,
|
|
};
|
|
}
|
|
|
|
function hydrateLayer(snapshot: EditorProjectLayerSnapshot): CanvasLayer | null {
|
|
const resourceId = typeof snapshot.resourceId === 'string' ? snapshot.resourceId : '';
|
|
const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : '';
|
|
const src = typeof snapshot.src === 'string' ? snapshot.src : '';
|
|
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),
|
|
};
|
|
}
|
|
|
|
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 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 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 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 canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
const assetListRef = useRef<HTMLDivElement | null>(null);
|
|
const dragStateRef = useRef<DragState | null>(null);
|
|
const layerCounterRef = useRef(INITIAL_LAYERS.length);
|
|
const saveTimerRef = useRef<number | null>(null);
|
|
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('uploads');
|
|
const [isAssetSelectionMode, setIsAssetSelectionMode] = useState(false);
|
|
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(
|
|
() => new Set(),
|
|
);
|
|
const [assetMarquee, setAssetMarquee] = useState<AssetMarqueeState | 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 [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 effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool;
|
|
const selectedLayer = useMemo(
|
|
() => layers.find((layer) => layer.id === selectedLayerId) ?? null,
|
|
[layers, selectedLayerId],
|
|
);
|
|
const activeGenerationLayer = useMemo(
|
|
() =>
|
|
generateDialog?.mode === 'generate' && generateDialog.generatedLayerId
|
|
? layers.find((layer) => layer.id === generateDialog.generatedLayerId) ?? null
|
|
: null,
|
|
[generateDialog, layers],
|
|
);
|
|
const generationAnchor =
|
|
generateDialog?.mode === 'generate'
|
|
? (activeGenerationLayer ?? generateDialog.placeholder ?? null)
|
|
: null;
|
|
const generationComposerStyle = generationAnchor
|
|
? {
|
|
left:
|
|
viewport.x +
|
|
(generationAnchor.x + generationAnchor.width / 2) * viewport.scale,
|
|
top:
|
|
viewport.y +
|
|
(generationAnchor.y + generationAnchor.height) * viewport.scale +
|
|
10,
|
|
}
|
|
: null;
|
|
const selectedToolbarStyle = selectedLayer
|
|
? {
|
|
left: clamp(
|
|
viewport.x +
|
|
selectedLayer.x * viewport.scale +
|
|
(selectedLayer.width * viewport.scale) / 2,
|
|
TOOLBAR_HALF_WIDTH,
|
|
Math.max(TOOLBAR_HALF_WIDTH, canvasSize.width - TOOLBAR_HALF_WIDTH),
|
|
),
|
|
top: Math.max(10, viewport.y + selectedLayer.y * viewport.scale - 12),
|
|
}
|
|
: null;
|
|
const groupedAssets = useMemo(
|
|
() =>
|
|
assetFolders.map((folder) => ({
|
|
...folder,
|
|
assets: assets.filter((asset) => asset.folderId === folder.id),
|
|
})),
|
|
[assetFolders, assets],
|
|
);
|
|
const selectableAssets = useMemo(
|
|
() => assets.filter((asset) => asset.sourceKind === 'uploaded'),
|
|
[assets],
|
|
);
|
|
const allSelectableAssetsSelected =
|
|
selectableAssets.length > 0 &&
|
|
selectableAssets.every((asset) => selectedAssetIds.has(asset.id));
|
|
|
|
const selectSingleLayer = (layerId: string | null) => {
|
|
setSelectedLayerId(layerId);
|
|
setSelectedLayerIds(layerId ? [layerId] : []);
|
|
};
|
|
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;
|
|
}
|
|
setProjectId(project.projectId);
|
|
setViewport(project.viewport);
|
|
const hydratedLayers = project.layers
|
|
.map(hydrateLayer)
|
|
.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;
|
|
};
|
|
}, []);
|
|
|
|
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 === 'Escape') {
|
|
setActiveSidebarPanel(null);
|
|
setIsZoomMenuOpen(false);
|
|
setIsBackgroundMenuOpen(false);
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.status === 'generating' ? currentDialog : null,
|
|
);
|
|
return;
|
|
}
|
|
if (event.code !== 'Space' || event.repeat || isEditableTarget(event)) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
setIsSpacePanning(true);
|
|
};
|
|
const handleKeyUp = (event: KeyboardEvent) => {
|
|
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(() => {
|
|
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 createProjectResourceForLayer = (
|
|
layer: CanvasLayer,
|
|
options: { onCreated?: (resourceId: string) => void } = {},
|
|
) => {
|
|
if (!projectId) {
|
|
return;
|
|
}
|
|
createEditorProjectResource(projectId, {
|
|
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 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 addUploadedLayer = async (
|
|
file: File,
|
|
options: {
|
|
folderId?: string;
|
|
canvasPoint?: { x: number; y: number };
|
|
uploadIndex?: number;
|
|
} = {},
|
|
) => {
|
|
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)
|
|
: 'uploads';
|
|
const screenPoint = options.canvasPoint ?? {
|
|
x: canvasSize.width / 2,
|
|
y: canvasSize.height / 2,
|
|
};
|
|
const worldCenterX = (screenPoint.x - viewport.x) / viewport.scale;
|
|
const worldCenterY = (screenPoint.y - viewport.y) / viewport.scale;
|
|
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,
|
|
};
|
|
|
|
setLayers((currentLayers) => [...currentLayers, nextLayer]);
|
|
setAssets((currentAssets) => [...currentAssets, uploadedAsset]);
|
|
setAssetFolders((currentFolders) =>
|
|
currentFolders.map((folder) =>
|
|
folder.id === uploadFolderId
|
|
? {
|
|
...folder,
|
|
collapsed: false,
|
|
}
|
|
: folder,
|
|
),
|
|
);
|
|
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(() => {});
|
|
|
|
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);
|
|
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 } } = {},
|
|
) => {
|
|
Array.from(files).forEach((file, index) => {
|
|
layerCounterRef.current += 1;
|
|
const uploadIndex = layerCounterRef.current;
|
|
void addUploadedLayer(file, {
|
|
...options,
|
|
uploadIndex,
|
|
canvasPoint: options.canvasPoint
|
|
? {
|
|
x: options.canvasPoint.x + index * 28,
|
|
y: options.canvasPoint.y + index * 28,
|
|
}
|
|
: undefined,
|
|
});
|
|
});
|
|
};
|
|
|
|
const deleteSelectedLayer = () => {
|
|
if (!selectedLayerId) {
|
|
return;
|
|
}
|
|
setLayers((currentLayers) => {
|
|
const nextLayers = currentLayers.filter((layer) => layer.id !== selectedLayerId);
|
|
const nextSelectedLayer = nextLayers
|
|
.slice()
|
|
.sort((left, right) => right.zIndex - left.zIndex)[0];
|
|
selectSingleLayer(nextSelectedLayer?.id ?? null);
|
|
return nextLayers;
|
|
});
|
|
setHoveredLayerId(null);
|
|
setMetadataLayer((currentLayer) =>
|
|
currentLayer?.id === selectedLayerId ? null : currentLayer,
|
|
);
|
|
};
|
|
|
|
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;
|
|
setGenerateDialog({
|
|
mode: 'generate',
|
|
prompt: '',
|
|
status: 'idle',
|
|
placeholder: {
|
|
x: worldCenterX - placeholderWidth / 2,
|
|
y: worldCenterY - placeholderHeight / 2,
|
|
width: placeholderWidth,
|
|
height: placeholderHeight,
|
|
originalWidth: 2048,
|
|
originalHeight: 2048,
|
|
},
|
|
});
|
|
setActiveTool('generate');
|
|
selectSingleLayer(null);
|
|
};
|
|
|
|
const openEditDialog = (sourceLayer: CanvasLayer) => {
|
|
setMetadataLayer(null);
|
|
setGenerateDialog({
|
|
mode: 'edit',
|
|
prompt: sourceLayer.prompt
|
|
? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节`
|
|
: '',
|
|
status: 'idle',
|
|
sourceLayerId: sourceLayer.id,
|
|
});
|
|
setActiveTool('generate');
|
|
};
|
|
|
|
const addGeneratedResultLayer = (
|
|
generated: EditorImageGenerationResult,
|
|
options: { sourceLayer?: CanvasLayer; frame?: GenerateDialogState['placeholder'] } = {},
|
|
) => {
|
|
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} 修改结果`
|
|
: `生成图片 ${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,
|
|
prompt: generated.prompt,
|
|
actualPrompt: generated.actualPrompt ?? generated.prompt,
|
|
model: generated.model,
|
|
provider: generated.provider,
|
|
taskId: generated.taskId,
|
|
sourceResourceId: options.sourceLayer?.resourceId,
|
|
};
|
|
|
|
setLayers((currentLayers) => [...currentLayers, nextLayer]);
|
|
selectSingleLayer(nextLayer.id);
|
|
setActiveSidebarPanel('layers');
|
|
if (options.sourceLayer) {
|
|
setGenerateDialog(null);
|
|
setActiveTool('select');
|
|
} else {
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'generate'
|
|
? {
|
|
...currentDialog,
|
|
status: 'idle',
|
|
generatedLayerId: nextLayer.id,
|
|
placeholder: undefined,
|
|
errorMessage: undefined,
|
|
}
|
|
: currentDialog,
|
|
);
|
|
}
|
|
if (options.sourceLayer) {
|
|
fitLayers([options.sourceLayer, nextLayer]);
|
|
}
|
|
createProjectResourceForLayer(nextLayer);
|
|
};
|
|
|
|
const submitImageGeneration = async (dialog: GenerateDialogState) => {
|
|
const normalizedPrompt =
|
|
dialog.prompt.trim() ||
|
|
(dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片');
|
|
setGenerateDialog({
|
|
...dialog,
|
|
prompt: normalizedPrompt,
|
|
status: 'generating',
|
|
});
|
|
|
|
try {
|
|
if (dialog.mode === 'edit') {
|
|
const sourceLayer = layers.find((layer) => layer.id === dialog.sourceLayerId);
|
|
if (!sourceLayer) {
|
|
throw new Error('未找到要修改的图片');
|
|
}
|
|
if (!sourceLayer.src.startsWith('data:image/')) {
|
|
throw new Error('当前图片缺少可提交的原图数据,请先使用生成图片结果进行修改');
|
|
}
|
|
const generated = await editEditorImage({
|
|
prompt: normalizedPrompt,
|
|
sourceImageSrc: sourceLayer.src,
|
|
});
|
|
addGeneratedResultLayer(generated, { sourceLayer });
|
|
} else {
|
|
const generated = await generateEditorImage({ prompt: normalizedPrompt });
|
|
addGeneratedResultLayer(generated, { frame: dialog.placeholder });
|
|
}
|
|
} catch (error) {
|
|
setGenerateDialog({
|
|
...dialog,
|
|
prompt: normalizedPrompt,
|
|
status: 'failed',
|
|
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;
|
|
}
|
|
selectSingleLayer(null);
|
|
};
|
|
|
|
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,
|
|
});
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const pointer = getPointerClient(event);
|
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
|
selectSingleLayer(layer.id);
|
|
dragStateRef.current = {
|
|
kind: 'layer',
|
|
pointerId: getPointerId(event),
|
|
layerId: layer.id,
|
|
startClientX: pointer.x,
|
|
startClientY: pointer.y,
|
|
startLayerX: layer.x,
|
|
startLayerY: layer.y,
|
|
startScale: viewport.scale,
|
|
};
|
|
};
|
|
|
|
const handleGenerationFramePointerDown = (
|
|
event: ReactPointerEvent<HTMLDivElement>,
|
|
) => {
|
|
if (!generateDialog?.placeholder) {
|
|
return;
|
|
}
|
|
const button = getPointerButton(event);
|
|
if (button === 1 || effectiveTool === 'hand') {
|
|
event.stopPropagation();
|
|
startPan(event as unknown as ReactPointerEvent<HTMLDivElement>);
|
|
return;
|
|
}
|
|
if (button !== 0 || generateDialog.status === 'generating') {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const pointer = getPointerClient(event);
|
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
|
selectSingleLayer(null);
|
|
dragStateRef.current = {
|
|
kind: 'generation-frame',
|
|
pointerId: getPointerId(event),
|
|
startClientX: pointer.x,
|
|
startClientY: pointer.y,
|
|
startFrameX: generateDialog.placeholder.x,
|
|
startFrameY: generateDialog.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 handleMinimapPointerDown = (
|
|
event: ReactPointerEvent<HTMLButtonElement>,
|
|
) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const pointer = getPointerClient(event);
|
|
canvasViewportRef.current?.setPointerCapture?.(event.pointerId);
|
|
dragStateRef.current = {
|
|
kind: 'minimap',
|
|
pointerId: getPointerId(event),
|
|
};
|
|
moveViewportFromMinimapPointer(pointer.x, pointer.y);
|
|
};
|
|
|
|
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
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;
|
|
setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'generate' && currentDialog.placeholder
|
|
? {
|
|
...currentDialog,
|
|
placeholder: {
|
|
...currentDialog.placeholder,
|
|
x: dragState.startFrameX + deltaX,
|
|
y: dragState.startFrameY + deltaY,
|
|
},
|
|
}
|
|
: currentDialog,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (dragState.kind === 'minimap') {
|
|
const pointer = getPointerClient(event);
|
|
moveViewportFromMinimapPointer(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) =>
|
|
layer.id === dragState.layerId
|
|
? {
|
|
...layer,
|
|
x: snapped.x,
|
|
y: snapped.y,
|
|
}
|
|
: layer,
|
|
),
|
|
);
|
|
};
|
|
|
|
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
const dragState = dragStateRef.current;
|
|
const pointerId = getPointerId(event);
|
|
if (
|
|
dragState &&
|
|
(dragState.pointerId < 0 || pointerId < 0 || dragState.pointerId === pointerId)
|
|
) {
|
|
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') {
|
|
uploadInputRef.current?.click();
|
|
return;
|
|
}
|
|
if (tool === 'generate') {
|
|
openGenerateDialog();
|
|
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: 'text', label: '文字工具', icon: Type },
|
|
{ id: 'shape', label: '形状标注工具', icon: Shapes },
|
|
{ id: 'export', label: '导出工具', icon: Download },
|
|
];
|
|
|
|
return (
|
|
<section
|
|
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) {
|
|
addUploadedFiles(files);
|
|
}
|
|
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();
|
|
}}
|
|
>
|
|
<input
|
|
aria-label="素材文件夹名称"
|
|
value={newFolderName}
|
|
autoFocus
|
|
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.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
}}
|
|
onDrop={(event) => {
|
|
if (!event.dataTransfer.files.length) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
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 ? (
|
|
<input
|
|
aria-label={`重命名文件夹${folder.label}`}
|
|
value={renamingFolder.value}
|
|
autoFocus
|
|
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);
|
|
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 ? (
|
|
<input
|
|
aria-label={`重命名素材${asset.label}`}
|
|
value={renamingAsset.value}
|
|
autoFocus
|
|
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.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
}}
|
|
onDrop={(event) => {
|
|
if (!event.dataTransfer.files.length) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
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 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="bottom-end">
|
|
<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>
|
|
|
|
<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 = selectedLayerId === layer.id;
|
|
const isHovered = hoveredLayerId === layer.id;
|
|
return (
|
|
<button
|
|
key={layer.id}
|
|
type="button"
|
|
className={`image-canvas-editor__layer ${isSelected ? 'image-canvas-editor__layer--selected' : ''} ${isHovered ? 'image-canvas-editor__layer--hovered' : ''}`}
|
|
style={{
|
|
left: layer.x,
|
|
top: layer.y,
|
|
width: layer.width,
|
|
height: layer.height,
|
|
zIndex: layer.zIndex,
|
|
}}
|
|
onPointerDown={(event) => handleLayerPointerDown(event, layer)}
|
|
onMouseEnter={() => setHoveredLayerId(layer.id)}
|
|
onMouseLeave={() =>
|
|
setHoveredLayerId((currentId) =>
|
|
currentId === layer.id ? null : currentId,
|
|
)
|
|
}
|
|
aria-label={`选择${layer.title}`}
|
|
>
|
|
<img src={layer.src} alt={`画布图片:${layer.title}`} />
|
|
{isGeneratedLayer(layer) ? (
|
|
<PlatformIconButton
|
|
asChild="spanButton"
|
|
variant="darkMini"
|
|
className="image-canvas-editor__metadata-corner"
|
|
label={`查看${layer.title}元数据`}
|
|
icon={<Braces className="h-3 w-3" />}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setMetadataLayer(layer);
|
|
selectSingleLayer(layer.id);
|
|
}}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
/>
|
|
) : null}
|
|
{isHovered ? (
|
|
<span className="image-canvas-editor__size-badge">
|
|
{Math.round(layer.width)} x {Math.round(layer.height)} px
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
);
|
|
})}
|
|
{generateDialog?.mode === 'generate' && generateDialog.placeholder ? (
|
|
<div
|
|
className="image-canvas-editor__generation-frame"
|
|
style={{
|
|
left: generateDialog.placeholder.x,
|
|
top: generateDialog.placeholder.y,
|
|
width: generateDialog.placeholder.width,
|
|
height: generateDialog.placeholder.height,
|
|
}}
|
|
aria-label="图像生成占位图"
|
|
onPointerDown={handleGenerationFramePointerDown}
|
|
>
|
|
<span className="image-canvas-editor__generation-frame-label">
|
|
<ImageIcon className="h-4 w-4" />
|
|
Image Generator
|
|
</span>
|
|
<span className="image-canvas-editor__generation-frame-size">
|
|
{generateDialog.placeholder.originalWidth} x{' '}
|
|
{generateDialog.placeholder.originalHeight}
|
|
</span>
|
|
<span className="image-canvas-editor__generation-frame-icon">
|
|
<ImageIcon className="h-8 w-8" />
|
|
</span>
|
|
</div>
|
|
) : 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}
|
|
/>
|
|
{isGeneratedLayer(selectedLayer) ? (
|
|
<>
|
|
<EditorIconButton
|
|
label={`查看${selectedLayer.title}元数据`}
|
|
title={`查看${selectedLayer.title}元数据`}
|
|
icon={Info}
|
|
onClick={() => setMetadataLayer(selectedLayer)}
|
|
/>
|
|
<EditorIconButton
|
|
label="修改图片"
|
|
title="修改图片"
|
|
icon={WandSparkles}
|
|
onClick={() => openEditDialog(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__background-control">
|
|
<button
|
|
type="button"
|
|
aria-label="画布背景色"
|
|
title="画布背景色"
|
|
aria-expanded={isBackgroundMenuOpen}
|
|
onClick={() => setIsBackgroundMenuOpen((open) => !open)}
|
|
>
|
|
<span
|
|
className="image-canvas-editor__background-swatch-current"
|
|
style={{ backgroundColor: canvasBackgroundColor }}
|
|
/>
|
|
</button>
|
|
{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}
|
|
|
|
{generateDialog?.mode === 'generate' ? null : (
|
|
<div
|
|
className="image-canvas-editor__bottom-toolbar"
|
|
role="toolbar"
|
|
aria-label="AI画布工具栏"
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
>
|
|
{canvasTools.map(({ id, label, icon: Icon }) => (
|
|
<EditorIconButton
|
|
key={id}
|
|
label={label}
|
|
title={label}
|
|
icon={Icon}
|
|
pressed={effectiveTool === id}
|
|
onClick={() => switchTool(id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{generateDialog?.mode === 'generate' && 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={() => 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}
|
|
disabled={generateDialog.status === 'generating'}
|
|
onClick={() => {
|
|
setGenerateDialog(null);
|
|
setActiveTool('select');
|
|
}}
|
|
/>
|
|
</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>{metadataLayer.sourceType}</dd>
|
|
<dt>尺寸</dt>
|
|
<dd>
|
|
{metadataLayer.originalWidth} x {metadataLayer.originalHeight}
|
|
</dd>
|
|
<dt>模型</dt>
|
|
<dd>{metadataLayer.model ?? '-'}</dd>
|
|
<dt>服务</dt>
|
|
<dd>{metadataLayer.provider ?? '-'}</dd>
|
|
<dt>任务</dt>
|
|
<dd>{metadataLayer.taskId ?? '-'}</dd>
|
|
<dt>对象</dt>
|
|
<dd>{metadataLayer.objectKey ?? metadataLayer.assetObjectId ?? '-'}</dd>
|
|
<dt>Prompt</dt>
|
|
<dd>{metadataLayer.prompt ?? '-'}</dd>
|
|
</dl>
|
|
) : null}
|
|
</UnifiedModal>
|
|
|
|
<UnifiedModal
|
|
open={generateDialog?.mode === 'edit'}
|
|
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;
|