lovart3/app/canvas/[id]/page.tsx
Your Name c7ffcda87b feat: add infinite canvas feature with AI image generation
- Add canvas project and node database tables
- Create canvas workspace with drag, resize, zoom and pan
- Add ImageNode with background removal, enhance, extend, angle edit, scene fusion
- Add VideoNode with playback controls
- Add TextNode for text editing
- Integrate AI image generation API (Gemini/Alibaba)
- Implement viewport culling for performance optimization
- Add canvas entry in top navigation bar
2026-01-24 19:48:13 +08:00

751 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState, useCallback, use } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
Save,
Undo2,
Redo2,
ZoomIn,
ZoomOut,
MousePointer,
Hand,
Plus,
ImagePlus,
Video,
Type,
Loader2,
Settings,
Trash2,
Scissors,
Sparkles,
Expand,
Maximize2,
Move3D,
Layers,
MoreHorizontal,
PanelLeftClose,
PanelLeft,
Download,
Upload,
Wand2,
Grid3X3,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { ScrollArea } from "@/components/ui/scroll-area";
import { toast } from "sonner";
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
import { canvasService, imageGenerationService } from "@/services/canvas";
import { CanvasWorkspace } from "@/components/canvas";
import { cn } from "@/lib/utils";
interface PageProps {
params: Promise<{ id: string }>;
}
// 图片尺寸预设
const SIZE_PRESETS = [
{ label: "1:1", value: "1:1", width: 1024, height: 1024 },
{ label: "3:2", value: "3:2", width: 1536, height: 1024 },
{ label: "2:3", value: "2:3", width: 1024, height: 1536 },
{ label: "4:3", value: "4:3", width: 1365, height: 1024 },
{ label: "3:4", value: "3:4", width: 1024, height: 1365 },
{ label: "16:9", value: "16:9", width: 1820, height: 1024 },
{ label: "9:16", value: "9:16", width: 1024, height: 1820 },
];
export default function CanvasEditorPage({ params }: PageProps) {
const { id } = use(params);
const router = useRouter();
// Store state
const projectTitle = useCanvasStore((state) => state.projectTitle);
const viewport = useCanvasStore(canvasSelectors.viewport);
const tool = useCanvasStore(canvasSelectors.tool);
const canUndo = useCanvasStore(canvasSelectors.canUndo);
const canRedo = useCanvasStore(canvasSelectors.canRedo);
const isGenerating = useCanvasStore(canvasSelectors.isGenerating);
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
const nodes = useCanvasStore(canvasSelectors.nodes);
// Store actions
const setProject = useCanvasStore((state) => state.setProject);
const setProjectTitle = useCanvasStore((state) => state.setProjectTitle);
const setTool = useCanvasStore((state) => state.setTool);
const zoomIn = useCanvasStore((state) => state.zoomIn);
const zoomOut = useCanvasStore((state) => state.zoomOut);
const undo = useCanvasStore((state) => state.undo);
const redo = useCanvasStore((state) => state.redo);
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const addNode = useCanvasStore((state) => state.addNode);
const updateNode = useCanvasStore((state) => state.updateNode);
const setGenerating = useCanvasStore((state) => state.setGenerating);
// Local state
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [isPanelOpen, setIsPanelOpen] = useState(true);
// 生成配置
const [prompt, setPrompt] = useState("");
const [negativePrompt, setNegativePrompt] = useState("");
const [aspectRatio, setAspectRatio] = useState("1:1");
const [imageCount, setImageCount] = useState(1);
// 获取选中的图片节点
const selectedImageNode = selectedNodeIds.length === 1
? nodes.find(n => n.id === selectedNodeIds[0] && n.type === 'image')
: null;
// 加载画布数据
useEffect(() => {
const loadProject = async () => {
setIsLoading(true);
const result = await canvasService.getProject(id);
if (result.success && result.data) {
setProject(id, result.data.title);
if (result.data.canvasData) {
loadCanvas(result.data.canvasData);
}
} else {
toast.error(result.error || "加载画布失败");
router.push("/canvas");
}
setIsLoading(false);
};
loadProject();
}, [id]);
// 保存画布
const handleSave = useCallback(async () => {
setIsSaving(true);
const canvasData = getCanvasState();
const result = await canvasService.saveCanvasState(id, canvasData);
if (result.success) {
toast.success("保存成功");
} else {
toast.error(result.error || "保存失败");
}
setIsSaving(false);
}, [id, getCanvasState]);
// 更新标题
const handleUpdateTitle = async () => {
if (!editTitle.trim() || editTitle === projectTitle) {
setIsEditingTitle(false);
return;
}
const result = await canvasService.updateProject(id, { title: editTitle.trim() });
if (result.success) {
setProjectTitle(editTitle.trim());
} else {
toast.error(result.error || "更新标题失败");
}
setIsEditingTitle(false);
};
// 添加文本节点
const handleAddTextNode = () => {
addNode({
type: 'text',
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
size: { width: 200, height: 100 },
data: { content: '双击编辑文本' },
connections: [],
});
};
// 添加图片节点
const handleAddImageNode = () => {
addNode({
type: 'image',
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
size: { width: 300, height: 300 },
data: {
src: '',
status: 'pending',
prompt: '',
},
connections: [],
});
};
// 添加视频节点
const handleAddVideoNode = () => {
addNode({
type: 'video',
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
size: { width: 400, height: 300 },
data: {
src: '',
status: 'pending',
prompt: '',
},
connections: [],
});
};
// AI 生成图片
const handleGenerate = async () => {
if (!prompt.trim()) {
toast.error("请输入提示词");
return;
}
setGenerating(true);
// 获取尺寸预设
const sizePreset = SIZE_PRESETS.find(s => s.value === aspectRatio) || SIZE_PRESETS[0];
// 先创建占位节点(显示生成中状态)
const placeholderNodeIds: string[] = [];
for (let i = 0; i < imageCount; i++) {
const nodeId = addNode({
type: 'image',
position: {
x: 100 + (i % 3) * 320,
y: 100 + Math.floor(i / 3) * 320
},
size: { width: 300, height: 300 },
data: {
src: '',
status: 'generating',
prompt: prompt,
},
connections: [],
});
placeholderNodeIds.push(nodeId);
}
try {
// 调用 AI 图片生成 API
const result = await imageGenerationService.generateImage({
prompt,
negativePrompt,
aspectRatio,
imageCount,
});
if (result.success && result.data?.images) {
// 更新节点为生成的图片
result.data.images.forEach((image, index) => {
if (placeholderNodeIds[index]) {
updateNode(placeholderNodeIds[index], {
data: {
src: image.originalUrl || image.thumbnailUrl,
status: 'completed',
prompt: prompt,
},
});
}
});
toast.success(`成功生成 ${result.data.images.length} 张图片`);
setPrompt(""); // 清空提示词
} else {
// 生成失败,更新节点状态
placeholderNodeIds.forEach(nodeId => {
updateNode(nodeId, {
data: {
src: '',
status: 'failed',
prompt: prompt,
},
});
});
toast.error(result.error || "图片生成失败");
}
} catch (error) {
console.error("AI 图片生成失败:", error);
// 生成失败,更新节点状态
placeholderNodeIds.forEach(nodeId => {
updateNode(nodeId, {
data: {
src: '',
status: 'failed',
prompt: prompt,
},
});
});
toast.error("图片生成失败,请重试");
} finally {
setGenerating(false);
}
};
// 键盘快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// 如果正在输入则不触发快捷键
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
// Ctrl/Cmd + S 保存
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
// Ctrl/Cmd + Z 撤销
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
}
// Ctrl/Cmd + Shift + Z 重做
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) {
e.preventDefault();
redo();
}
// V 选择工具
if (e.key === 'v' && !e.ctrlKey && !e.metaKey) {
setTool('select');
}
// H 平移工具
if (e.key === 'h' && !e.ctrlKey && !e.metaKey) {
setTool('pan');
}
// Delete 删除选中
if (e.key === 'Delete' || e.key === 'Backspace') {
useCanvasStore.getState().deleteSelectedNodes();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSave, undo, redo, setTool]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center bg-muted/30">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">加载中...</span>
</div>
</div>
);
}
return (
<TooltipProvider>
<div className="h-full flex flex-col bg-background">
{/* 顶部工具栏 */}
<div className="h-12 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 flex items-center justify-between px-4 shrink-0">
{/* 左侧:返回 + 标题 */}
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<Link href="/canvas">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>返回列表</TooltipContent>
</Tooltip>
{isEditingTitle ? (
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onBlur={handleUpdateTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') handleUpdateTitle();
if (e.key === 'Escape') setIsEditingTitle(false);
}}
className="h-8 w-48"
autoFocus
/>
) : (
<button
className="text-sm font-medium hover:text-primary transition-colors"
onClick={() => {
setEditTitle(projectTitle);
setIsEditingTitle(true);
}}
>
{projectTitle}
</button>
)}
</div>
{/* 中间:工具栏 */}
<div className="flex items-center gap-1">
{/* 选择/平移工具 */}
<div className="flex items-center bg-muted rounded-md p-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === 'select' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7"
onClick={() => setTool('select')}
>
<MousePointer className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>选择工具 (V)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={tool === 'pan' ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7"
onClick={() => setTool('pan')}
>
<Hand className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>平移工具 (H)</TooltipContent>
</Tooltip>
</div>
<Separator orientation="vertical" className="h-6 mx-2" />
{/* 撤销/重做 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={undo}
disabled={!canUndo}
>
<Undo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>撤销 (Ctrl+Z)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={redo}
disabled={!canRedo}
>
<Redo2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>重做 (Ctrl+Shift+Z)</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-6 mx-2" />
{/* 缩放 */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={zoomOut}
>
<ZoomOut className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>缩小</TooltipContent>
</Tooltip>
<span className="text-xs w-12 text-center tabular-nums">
{Math.round(viewport.zoom * 100)}%
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={zoomIn}
>
<ZoomIn className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>放大</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-6 mx-2" />
{/* 添加节点 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Plus className="h-4 w-4 mr-1" />
添加
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center">
<DropdownMenuItem onClick={handleAddTextNode}>
<Type className="h-4 w-4 mr-2" />
文本节点
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAddImageNode}>
<ImagePlus className="h-4 w-4 mr-2" />
图片节点
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAddVideoNode}>
<Video className="h-4 w-4 mr-2" />
视频节点
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* 右侧:面板切换 + 保存 */}
<div className="flex items-center gap-2">
{isGenerating && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
生成中...
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setIsPanelOpen(!isPanelOpen)}
>
{isPanelOpen ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelLeft className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{isPanelOpen ? "隐藏面板" : "显示面板"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
className="h-8"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Save className="h-4 w-4 mr-1" />
)}
保存
</Button>
</TooltipTrigger>
<TooltipContent>保存 (Ctrl+S)</TooltipContent>
</Tooltip>
</div>
</div>
{/* 主体区域 */}
<div className="flex-1 flex overflow-hidden">
{/* 左侧配置面板 - 参考 lobe-chat-pro */}
<div
className={cn(
"border-r bg-card transition-all duration-300 ease-in-out overflow-hidden",
isPanelOpen ? "w-80" : "w-0"
)}
>
{isPanelOpen && (
<ScrollArea className="h-full">
<div className="p-4 space-y-6">
{/* AI 图片生成 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Wand2 className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-sm">AI 图片生成</h3>
</div>
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs">提示词</Label>
<Textarea
placeholder="描述你想要生成的图片..."
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] text-sm resize-none"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">负面提示词</Label>
<Textarea
placeholder="描述你不想出现的内容..."
value={negativePrompt}
onChange={(e) => setNegativePrompt(e.target.value)}
className="min-h-[60px] text-sm resize-none"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">尺寸比例</Label>
<Select value={aspectRatio} onValueChange={setAspectRatio}>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SIZE_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label} ({preset.width}×{preset.height})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">生成数量</Label>
<span className="text-xs text-muted-foreground">{imageCount}</span>
</div>
<Slider
value={[imageCount]}
onValueChange={([v]) => setImageCount(v)}
min={1}
max={4}
step={1}
/>
</div>
<Button
className="w-full"
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
生成中...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
开始生成
</>
)}
</Button>
</div>
</div>
<Separator />
{/* 图片编辑工具 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-sm">图片编辑工具</h3>
</div>
{selectedImageNode ? (
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" className="h-9 justify-start">
<Scissors className="h-4 w-4 mr-2" />
抠图
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Sparkles className="h-4 w-4 mr-2" />
变清晰
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Expand className="h-4 w-4 mr-2" />
扩展
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Maximize2 className="h-4 w-4 mr-2" />
调整
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Move3D className="h-4 w-4 mr-2" />
角度
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Layers className="h-4 w-4 mr-2" />
场景
</Button>
</div>
) : (
<p className="text-xs text-muted-foreground text-center py-4">
选中图片节点以使用编辑工具
</p>
)}
</div>
<Separator />
{/* 快捷操作 */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Grid3X3 className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-sm">快捷操作</h3>
</div>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" className="h-9 justify-start" onClick={handleAddImageNode}>
<ImagePlus className="h-4 w-4 mr-2" />
添加图片
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start" onClick={handleAddTextNode}>
<Type className="h-4 w-4 mr-2" />
添加文本
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Upload className="h-4 w-4 mr-2" />
上传图片
</Button>
<Button variant="outline" size="sm" className="h-9 justify-start">
<Download className="h-4 w-4 mr-2" />
导出
</Button>
</div>
</div>
</div>
</ScrollArea>
)}
</div>
{/* 画布工作区 */}
<div className="flex-1 overflow-hidden bg-muted/30">
<CanvasWorkspace />
</div>
</div>
</div>
</TooltipProvider>
);
}