Files
Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx
kdletters 0004d28253 复用胶囊标签收口画布尺寸提示
画布图片 hover 尺寸标签改为复用 PlatformPillBadge,统一覆盖层 badge 基础结构。

删除尺寸标签局部圆角、字号和排版样式,仅保留画布内定位与深色覆盖。

补充编辑器测试覆盖共享胶囊标签 class,并更新 TRACKING。
2026-06-14 17:02:31 +08:00

3097 lines
101 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 { PlatformPillBadge } from '../common/PlatformPillBadge';
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();
}}
>
<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.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 ? (
<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);
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.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 ? (
<PlatformPillBadge
tone="lightOverlay"
size="xs"
className="image-canvas-editor__size-badge"
>
{Math.round(layer.width)} x {Math.round(layer.height)} px
</PlatformPillBadge>
) : 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">
<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}
{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;