Files
Genarrative/src/components/image-editor/ImageCanvasEditorView.tsx
kdletters 85834a423d 新增图片画布项目页
新增 /project 项目页和我的页项目入口

补齐图片画布工程列表、重命名和删除 API

支持 /editor/canvas 按 projectid 加载指定工程

更新图片画布文档、TRACKING 和对应测试
2026-06-14 00:11:36 +08:00

2330 lines
76 KiB
TypeScript

import {
Braces,
Check,
ChevronDown,
ChevronRight,
Copy,
Crop,
Download,
Folder,
FolderPlus,
Hand,
ImageIcon,
ImagePlus,
Info,
Layers,
Map as MapIcon,
MousePointer2,
Pencil,
RotateCcw,
Shapes,
SlidersHorizontal,
Sparkles,
Trash2,
Type,
WandSparkles,
X,
} from 'lucide-react';
import {
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type WheelEvent as ReactWheelEvent,
} from 'react';
import {
createEditorProjectResource,
editEditorImage,
type EditorImageGenerationResult,
type EditorProjectLayerSnapshot,
generateEditorImage,
loadEditorProject,
loadOrCreateRecentEditorProject,
saveEditorProjectLayout,
} from '../../services/image-editor/editorProjectClient';
import { ApiClientError } from '../../services/apiClient';
import {
EditorIconButton,
SidebarMediaItem,
} from './ImageCanvasEditorPrimitives';
type EditorAsset = {
id: string;
label: string;
src: string;
width: number;
height: number;
folderId: string;
sourceKind: 'built-in' | 'uploaded';
sourceType: CanvasLayer['sourceType'];
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;
};
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;
};
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 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;
};
const EDITOR_ASSETS: EditorAsset[] = [
{
id: 'puzzle',
label: '拼图素材',
src: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
folderId: 'project',
sourceKind: 'built-in',
sourceType: 'uploaded',
},
{
id: 'match3d',
label: '抓大鹅素材',
src: '/creation-type-references/match3d.webp',
width: 640,
height: 640,
folderId: 'project',
sourceKind: 'built-in',
sourceType: 'uploaded',
},
{
id: 'big-fish',
label: '大鱼素材',
src: '/creation-type-references/big-fish.webp',
width: 720,
height: 405,
folderId: 'references',
sourceKind: 'built-in',
sourceType: 'uploaded',
},
{
id: 'bark-battle',
label: '声浪素材',
src: '/creation-type-references/bark-battle.webp',
width: 640,
height: 900,
folderId: 'references',
sourceKind: 'built-in',
sourceType: 'uploaded',
},
{
id: 'visual-novel',
label: '视觉小说素材',
src: '/creation-type-references/visual-novel.webp',
width: 720,
height: 405,
folderId: 'references',
sourceKind: 'built-in',
sourceType: 'uploaded',
},
];
const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
{ id: 'project', label: '项目素材', collapsed: false },
{ id: 'references', label: '参考素材', collapsed: false },
{ id: 'uploads', label: '上传素材', collapsed: 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,
};
}
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),
};
}
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 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 [creatingFolder, setCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [activeUploadFolderId, setActiveUploadFolderId] = useState('uploads');
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(
INITIAL_LAYERS[0]?.id ?? null,
);
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 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 {
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);
setSelectedLayerId(hydratedLayers[0]?.id ?? null);
}
setIsProjectReady(true);
})
.catch(() => {
if (!cancelled) {
setIsProjectReady(false);
}
});
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 addAssetLayer = (asset: EditorAsset) => {
setActiveUploadFolderId(asset.folderId);
layerCounterRef.current += 1;
const nextLayer = createLayerFromAsset(
asset,
layerCounterRef.current,
viewport,
{
x: canvasSize.width / 2,
y: canvasSize.height / 2,
},
);
setLayers((currentLayers) => [...currentLayers, nextLayer]);
setSelectedLayerId(nextLayer.id);
setHoveredLayerId(null);
if (projectId) {
createEditorProjectResource(projectId, {
imageSrc: nextLayer.src,
objectKey: nextLayer.objectKey,
assetObjectId: nextLayer.assetObjectId,
width: nextLayer.originalWidth,
height: nextLayer.originalHeight,
sourceType: nextLayer.sourceType,
prompt: nextLayer.prompt,
actualPrompt: nextLayer.actualPrompt,
model: nextLayer.model,
provider: nextLayer.provider,
taskId: nextLayer.taskId,
sourceResourceId: nextLayer.sourceResourceId,
}).catch(() => {});
}
};
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,
),
);
setRenamingAsset(null);
};
const toggleAssetFolder = (folderId: string) => {
setAssetFolders((currentFolders) =>
currentFolders.map((folder) =>
folder.id === folderId
? {
...folder,
collapsed: !folder.collapsed,
}
: folder,
),
);
};
const commitNewAssetFolder = () => {
const label = newFolderName.trim();
if (!label) {
setCreatingFolder(false);
setNewFolderName('');
return;
}
const folderId = `folder-${Date.now()}`;
setAssetFolders((currentFolders) => [
...currentFolders,
{
id: folderId,
label,
collapsed: false,
},
]);
setActiveUploadFolderId(folderId);
setCreatingFolder(false);
setNewFolderName('');
};
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,
);
};
const addUploadedLayer = (file: File) => {
if (!file.type.startsWith('image/')) {
window.alert('请选择图片文件');
return;
}
layerCounterRef.current += 1;
const objectUrl =
typeof URL.createObjectURL === 'function'
? URL.createObjectURL(file)
: '';
const fallbackWidth = 420;
const fallbackHeight = 315;
const uploadFolderId =
assetFolders.some((folder) => folder.id === activeUploadFolderId)
? activeUploadFolderId
: 'uploads';
const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale;
const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale;
const nextLayer: CanvasLayer = {
id: `layer-upload-${layerCounterRef.current}`,
resourceId: `local-resource-upload-${layerCounterRef.current}`,
title: file.name || '上传图片',
src: objectUrl,
x: worldCenterX - fallbackWidth / 2,
y: worldCenterY - fallbackHeight / 2,
width: fallbackWidth,
height: fallbackHeight,
originalWidth: fallbackWidth,
originalHeight: fallbackHeight,
zIndex: layerCounterRef.current + 10,
sourceType: 'uploaded',
};
const uploadedAsset: EditorAsset = {
id: `upload-${layerCounterRef.current}`,
label: file.name || '上传图片',
src: objectUrl,
width: fallbackWidth,
height: fallbackHeight,
folderId: uploadFolderId,
sourceKind: 'uploaded',
sourceType: 'uploaded',
};
setLayers((currentLayers) => [...currentLayers, nextLayer]);
setAssets((currentAssets) => [...currentAssets, uploadedAsset]);
setAssetFolders((currentFolders) =>
currentFolders.map((folder) =>
folder.id === uploadFolderId
? {
...folder,
collapsed: false,
}
: folder,
),
);
setSelectedLayerId(nextLayer.id);
setActiveSidebarPanel('layers');
if (objectUrl) {
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 = objectUrl;
}
};
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];
setSelectedLayerId(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');
setSelectedLayerId(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]);
setSelectedLayerId(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]);
}
if (projectId) {
createEditorProjectResource(projectId, {
imageSrc: nextLayer.src,
objectKey: nextLayer.objectKey,
assetObjectId: nextLayer.assetObjectId,
width: nextLayer.originalWidth,
height: nextLayer.originalHeight,
sourceType: nextLayer.sourceType,
prompt: nextLayer.prompt,
actualPrompt: nextLayer.actualPrompt,
model: nextLayer.model,
provider: nextLayer.provider,
taskId: nextLayer.taskId,
sourceResourceId: nextLayer.sourceResourceId,
}).catch(() => {});
}
};
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;
}
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;
}
setSelectedLayerId(null);
};
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);
setSelectedLayerId(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);
setSelectedLayerId(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 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;
}
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 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/*"
aria-label="上传图片文件"
hidden
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) {
addUploadedLayer(file);
}
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' ? (
<EditorIconButton
className="image-canvas-editor__icon-button"
label="新建素材文件夹"
title="新建文件夹"
icon={FolderPlus}
onClick={() => setCreatingFolder(true)}
/>
) : null}
</div>
{activeSidebarPanel === 'assets' ? (
<div className="image-canvas-editor__asset-list">
{creatingFolder ? (
<form
className="image-canvas-editor__folder-create"
onSubmit={(event) => {
event.preventDefault();
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}
>
<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" />
<span>{folder.label}</span>
<span>{folder.assets.length}</span>
<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 (
<SidebarMediaItem
key={asset.id}
title={asset.label}
detail={`${asset.width} x ${asset.height}`}
imageSrc={asset.src}
imageAlt={`素材:${asset.label}`}
primaryLabel={`添加${asset.label}`}
onPrimaryClick={() => addAssetLayer(asset)}
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}
/>
);
})}
</div>
</section>
))}
</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)}`}
imageSrc={layer.src}
imageAlt={`图层缩略图:${layer.title}`}
selected={selectedLayerId === layer.id}
primaryLabel={`选择图层${layer.title}`}
onPrimaryClick={() => setSelectedLayerId(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">
<button
type="button"
className="image-canvas-editor__zoom-trigger"
aria-label={`当前缩放比例 ${formatPercent(viewport.scale)}`}
aria-haspopup="menu"
aria-expanded={isZoomMenuOpen}
onClick={() => setIsZoomMenuOpen((open) => !open)}
>
<span>{formatPercent(viewport.scale)}</span>
</button>
{isZoomMenuOpen ? (
<div
className="image-canvas-editor__zoom-menu"
role="menu"
aria-label="缩放菜单"
>
<button
type="button"
role="menuitem"
onClick={() => {
updateScaleFromCenter(viewport.scale * 1.16);
setIsZoomMenuOpen(false);
}}
>
</button>
<button
type="button"
role="menuitem"
onClick={() => {
updateScaleFromCenter(viewport.scale * 0.86);
setIsZoomMenuOpen(false);
}}
>
</button>
<button
type="button"
role="menuitem"
onClick={() => {
fitLayers();
setIsZoomMenuOpen(false);
}}
>
</button>
{[0.5, 1, 2].map((scale) => (
<button
key={scale}
type="button"
role="menuitem"
onClick={() => {
updateScaleFromCenter(scale);
setIsZoomMenuOpen(false);
}}
>
{Math.round(scale * 100)}%
</button>
))}
</div>
) : 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}
>
<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) ? (
<span
className="image-canvas-editor__metadata-corner"
role="button"
tabIndex={0}
aria-label={`查看${layer.title}元数据`}
onClick={(event) => {
event.stopPropagation();
setMetadataLayer(layer);
setSelectedLayerId(layer.id);
}}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
setMetadataLayer(layer);
setSelectedLayerId(layer.id);
}
}}
>
<Braces className="h-3 w-3" />
</span>
) : 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 ? (
<div
className="image-canvas-editor__background-menu"
role="menu"
aria-label="画布背景色菜单"
>
{CANVAS_BACKGROUND_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
role="menuitem"
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 }}
/>
</button>
))}
</div>
) : null}
</div>
<EditorIconButton
label="打开素材"
title="素材"
icon={ImagePlus}
pressed={activeSidebarPanel === 'assets'}
onClick={() => toggleSidebarPanel('assets')}
/>
<EditorIconButton
label="打开图层"
title="图层"
icon={Layers}
pressed={activeSidebarPanel === 'layers'}
onClick={() => toggleSidebarPanel('layers')}
/>
<EditorIconButton
label="切换小地图"
title="小地图"
icon={MapIcon}
pressed={isMinimapOpen}
onClick={() => setIsMinimapOpen((open) => !open)}
/>
</div>
{isMinimapOpen && minimapModel ? (
<button
type="button"
className="image-canvas-editor__minimap"
aria-label="画布小地图"
title="显示画布所有元素"
onPointerDown={(event) => event.stopPropagation()}
onClick={() => fitLayers()}
>
<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);
}
}}
>
<button
type="button"
className="image-canvas-editor__generation-ref"
aria-label="添加参考图"
disabled={generateDialog.status === 'generating'}
onClick={() => uploadInputRef.current?.click()}
>
<ImageIcon className="h-4 w-4" />
<span></span>
</button>
<textarea
aria-label="生成提示词"
value={generateDialog.prompt}
disabled={generateDialog.status === 'generating'}
placeholder="今天我们要创作什么"
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">
<button
type="button"
className="image-canvas-editor__generation-ratio"
aria-label="生成比例 1:1 2k 1张"
disabled={generateDialog.status === 'generating'}
onClick={() => triggerPlaceholderAction('生成参数')}
>
· 1:1(2k) · 1
<ChevronDown className="h-3 w-3" />
</button>
<button
type="button"
className="image-canvas-editor__generation-model"
aria-label="生成模型 GPT Image"
disabled={generateDialog.status === 'generating'}
onClick={() => triggerPlaceholderAction('模型选择')}
>
GPT Im...
<ChevronDown className="h-3 w-3" />
</button>
<button
type="submit"
className="image-canvas-editor__generation-submit"
disabled={generateDialog.status === 'generating'}
aria-label="生成"
>
{generateDialog.status === 'generating' ? '生成中' : '12'}
</button>
</div>
{generateDialog.status === 'generating' ? (
<div
className="image-canvas-editor__generate-status"
role="status"
>
</div>
) : null}
{generateDialog.status === 'failed' ? (
<div
className="image-canvas-editor__generate-status image-canvas-editor__generate-status--error"
role="alert"
>
{generateDialog.errorMessage}
</div>
) : null}
<EditorIconButton
className="image-canvas-editor__generation-close"
label="关闭生成图片"
icon={X}
disabled={generateDialog.status === 'generating'}
onClick={() => {
setGenerateDialog(null);
setActiveTool('select');
}}
/>
</form>
) : null}
</div>
</div>
{metadataLayer ? (
<div
className="image-canvas-editor__modal-backdrop"
onPointerDown={() => setMetadataLayer(null)}
>
<div
className="image-canvas-editor__metadata-dialog"
role="dialog"
aria-label={`${metadataLayer.title}元数据`}
aria-modal="true"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="image-canvas-editor__metadata-header">
<h2>{metadataLayer.title}</h2>
<EditorIconButton
label="关闭元数据"
icon={X}
onClick={() => setMetadataLayer(null)}
/>
</div>
<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>
</div>
</div>
) : null}
{generateDialog?.mode === 'edit' ? (
<div
className="image-canvas-editor__modal-backdrop"
onPointerDown={() => {
if (generateDialog.status !== 'generating') {
setGenerateDialog(null);
}
}}
>
<form
className="image-canvas-editor__generate-dialog"
role="dialog"
aria-label={generateDialog.mode === 'edit' ? '修改图片' : '生成图片'}
aria-modal="true"
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (generateDialog.status !== 'generating') {
void submitImageGeneration(generateDialog);
}
}}
>
<div className="image-canvas-editor__metadata-header">
<h2>{generateDialog.mode === 'edit' ? '修改图片' : '生成图片'}</h2>
<EditorIconButton
label={
generateDialog.mode === 'edit' ? '关闭修改图片' : '关闭生成图片'
}
icon={X}
disabled={generateDialog.status === 'generating'}
onClick={() => setGenerateDialog(null)}
/>
</div>
<div className="image-canvas-editor__generate-body">
<textarea
aria-label="生成提示词"
value={generateDialog.prompt}
disabled={generateDialog.status === 'generating'}
placeholder={
generateDialog.mode === 'edit'
? '描述你想如何修改这张图片'
: '描述你想生成的图片'
}
onChange={(event) =>
setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
prompt: event.target.value,
}
: currentDialog,
)
}
/>
{generateDialog.status === 'generating' ? (
<div
className="image-canvas-editor__generate-status"
role="status"
>
{generateDialog.mode === 'edit' ? '修改中' : '生成中'}
</div>
) : null}
{generateDialog.status === 'failed' ? (
<div
className="image-canvas-editor__generate-status image-canvas-editor__generate-status--error"
role="alert"
>
{generateDialog.errorMessage}
</div>
) : null}
<button
type="submit"
className="image-canvas-editor__generate-submit"
disabled={generateDialog.status === 'generating'}
>
{generateDialog.status === 'generating'
? generateDialog.mode === 'edit'
? '修改中'
: '生成中'
: generateDialog.mode === 'edit'
? '修改'
: '生成'}
</button>
</div>
</form>
</div>
) : null}
</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;