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
This commit is contained in:
parent
acaffcb65a
commit
c7ffcda87b
@ -3215,7 +3215,7 @@ export function AdminSettingsClient() {
|
||||
<div>
|
||||
<CardTitle>存储配置</CardTitle>
|
||||
<CardDescription>配置 S3 兼容对象存储</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={showStorageDialog} onOpenChange={setShowStorageDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => { resetStorageForm(); setShowStorageDialog(true); }}>
|
||||
|
||||
289
app/(chat)/canvas/page.tsx
Normal file
289
app/(chat)/canvas/page.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, LayoutGrid, Trash2, Edit2, MoreVertical, Clock, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { canvasService } from "@/services/canvas";
|
||||
|
||||
interface CanvasProject {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function CanvasListPage() {
|
||||
const router = useRouter();
|
||||
const [projects, setProjects] = useState<CanvasProject[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<CanvasProject | null>(null);
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [projectToRename, setProjectToRename] = useState<CanvasProject | null>(null);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
|
||||
// 加载画布列表
|
||||
const loadProjects = async () => {
|
||||
setIsLoading(true);
|
||||
const result = await canvasService.getProjects();
|
||||
if (result.success && result.data) {
|
||||
setProjects(result.data);
|
||||
} else {
|
||||
toast.error(result.error || "加载失败");
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
// 创建新画布
|
||||
const handleCreateProject = async () => {
|
||||
setIsCreating(true);
|
||||
const result = await canvasService.createProject({
|
||||
title: "未命名画布",
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
toast.success("创建成功");
|
||||
router.push(`/canvas/${result.data.id}`);
|
||||
} else {
|
||||
toast.error(result.error || "创建失败");
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除画布
|
||||
const handleDeleteProject = async () => {
|
||||
if (!projectToDelete) return;
|
||||
|
||||
const result = await canvasService.deleteProject(projectToDelete.id);
|
||||
if (result.success) {
|
||||
toast.success("删除成功");
|
||||
setProjects(projects.filter(p => p.id !== projectToDelete.id));
|
||||
} else {
|
||||
toast.error(result.error || "删除失败");
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
setProjectToDelete(null);
|
||||
};
|
||||
|
||||
// 重命名画布
|
||||
const handleRenameProject = async () => {
|
||||
if (!projectToRename || !newTitle.trim()) return;
|
||||
|
||||
const result = await canvasService.updateProject(projectToRename.id, {
|
||||
title: newTitle.trim(),
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success("重命名成功");
|
||||
setProjects(projects.map(p =>
|
||||
p.id === projectToRename.id ? { ...p, title: newTitle.trim() } : p
|
||||
));
|
||||
} else {
|
||||
toast.error(result.error || "重命名失败");
|
||||
}
|
||||
setRenameDialogOpen(false);
|
||||
setProjectToRename(null);
|
||||
setNewTitle("");
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<LayoutGrid className="h-6 w-6 text-purple-500" />
|
||||
<h1 className="text-2xl font-bold">无限画布</h1>
|
||||
</div>
|
||||
<Button onClick={handleCreateProject} disabled={isCreating}>
|
||||
{isCreating ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
新建画布
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 画布列表 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<LayoutGrid className="h-16 w-16 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">还没有画布</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
创建一个新画布,开始你的创作之旅
|
||||
</p>
|
||||
<Button onClick={handleCreateProject} disabled={isCreating}>
|
||||
{isCreating ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
新建画布
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{projects.map((project) => (
|
||||
<Card
|
||||
key={project.id}
|
||||
className="group cursor-pointer hover:shadow-lg transition-shadow"
|
||||
onClick={() => router.push(`/canvas/${project.id}`)}
|
||||
>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-base font-medium line-clamp-1">
|
||||
{project.title}
|
||||
</CardTitle>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setProjectToRename(project);
|
||||
setNewTitle(project.title);
|
||||
setRenameDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="h-4 w-4 mr-2" />
|
||||
重命名
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setProjectToDelete(project);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="aspect-video bg-muted rounded-md flex items-center justify-center overflow-hidden">
|
||||
{project.thumbnail ? (
|
||||
<img
|
||||
src={project.thumbnail}
|
||||
alt={project.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<LayoutGrid className="h-12 w-12 text-muted-foreground/30" />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="p-4 pt-0">
|
||||
<div className="flex items-center text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{formatDate(project.updatedAt)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除画布 "{projectToDelete?.title}" 吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteProject}>
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 重命名对话框 */}
|
||||
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名画布</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="输入新名称"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleRenameProject();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleRenameProject} disabled={!newTitle.trim()}>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
app/api/canvas/[id]/route.ts
Normal file
116
app/api/canvas/[id]/route.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/app/(auth)/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { canvasProject } from '@/lib/db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
// 获取单个画布
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const [project] = await db
|
||||
.select()
|
||||
.from(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, id),
|
||||
eq(canvasProject.userId, session.user.id)
|
||||
)
|
||||
);
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json({ success: false, error: '画布不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: project });
|
||||
} catch (error) {
|
||||
console.error('获取画布失败:', error);
|
||||
return NextResponse.json({ success: false, error: '获取画布失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 更新画布
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { title, description, thumbnail, canvasData } = body;
|
||||
|
||||
const updateData: any = { updatedAt: new Date() };
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
|
||||
if (canvasData !== undefined) updateData.canvasData = canvasData;
|
||||
|
||||
const [updatedProject] = await db
|
||||
.update(canvasProject)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, id),
|
||||
eq(canvasProject.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!updatedProject) {
|
||||
return NextResponse.json({ success: false, error: '画布不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: updatedProject });
|
||||
} catch (error) {
|
||||
console.error('更新画布失败:', error);
|
||||
return NextResponse.json({ success: false, error: '更新画布失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 删除画布
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const [deletedProject] = await db
|
||||
.delete(canvasProject)
|
||||
.where(
|
||||
and(
|
||||
eq(canvasProject.id, id),
|
||||
eq(canvasProject.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!deletedProject) {
|
||||
return NextResponse.json({ success: false, error: '画布不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: deletedProject });
|
||||
} catch (error) {
|
||||
console.error('删除画布失败:', error);
|
||||
return NextResponse.json({ success: false, error: '删除画布失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
149
app/api/canvas/generate/image/route.ts
Normal file
149
app/api/canvas/generate/image/route.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getEnabledImageModels, getProviderById } from "@/lib/db/queries";
|
||||
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
|
||||
import { createAlibabaImage } from "@/lib/ai/providers/alibaba-image";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
interface GenerateImageRequest {
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
model?: string; // 模型 ID
|
||||
aspectRatio?: string; // 宽高比 "1:1", "16:9" 等
|
||||
imageCount?: number; // 生成数量
|
||||
referenceImage?: string; // 参考图片(用于图生图)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body: GenerateImageRequest = await request.json();
|
||||
const { prompt, negativePrompt, model, aspectRatio = "1:1", imageCount = 1, referenceImage } = body;
|
||||
|
||||
if (!prompt) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "请输入提示词" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取可用的图片生成模型
|
||||
const imageModels = await getEnabledImageModels();
|
||||
|
||||
if (!imageModels || imageModels.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "暂无可用的图片生成模型" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 选择模型(如果指定了 model,使用指定的;否则使用默认的)
|
||||
let selectedModel = model
|
||||
? imageModels.find(m => m.model.modelId === model)
|
||||
: imageModels.find(m => m.model.isDefault) || imageModels[0];
|
||||
|
||||
if (!selectedModel) {
|
||||
selectedModel = imageModels[0];
|
||||
}
|
||||
|
||||
const provider = await getProviderById(selectedModel.model.providerId);
|
||||
if (!provider) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "模型提供商配置错误" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 计算尺寸
|
||||
const sizeMap: Record<string, { width: number; height: number }> = {
|
||||
"1:1": { width: 1024, height: 1024 },
|
||||
"3:2": { width: 1536, height: 1024 },
|
||||
"2:3": { width: 1024, height: 1536 },
|
||||
"4:3": { width: 1365, height: 1024 },
|
||||
"3:4": { width: 1024, height: 1365 },
|
||||
"16:9": { width: 1820, height: 1024 },
|
||||
"9:16": { width: 1024, height: 1820 },
|
||||
};
|
||||
const size = sizeMap[aspectRatio] || sizeMap["1:1"];
|
||||
|
||||
// 构建消息
|
||||
const messages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: prompt,
|
||||
}
|
||||
];
|
||||
|
||||
// 图片参数
|
||||
const imageParams = {
|
||||
aspectRatio,
|
||||
imageSize: `${size.width}x${size.height}`,
|
||||
imageCount,
|
||||
negativePrompt,
|
||||
};
|
||||
|
||||
let result;
|
||||
|
||||
// 根据提供商类型调用不同的生成器
|
||||
if (provider.type === "gemini") {
|
||||
const geminiProvider = createGeminiImage({
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseUrl,
|
||||
});
|
||||
|
||||
result = await geminiProvider.doGenerate({
|
||||
messages,
|
||||
modelId: selectedModel.model.modelId,
|
||||
imageParams,
|
||||
});
|
||||
} else if (provider.type === "alibaba-image") {
|
||||
const alibabaProvider = createAlibabaImage({
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseUrl,
|
||||
});
|
||||
|
||||
result = await alibabaProvider.doGenerate({
|
||||
messages,
|
||||
modelId: selectedModel.model.modelId,
|
||||
imageParams,
|
||||
referenceImage,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "不支持的图片生成模型类型" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (result.images && result.images.length > 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
images: result.images,
|
||||
model: selectedModel.model.displayName,
|
||||
prompt,
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "图片生成失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("[Canvas Generate Image] Error:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "生成失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
58
app/api/canvas/route.ts
Normal file
58
app/api/canvas/route.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/app/(auth)/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { canvasProject } from '@/lib/db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
|
||||
// 获取画布列表
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const projects = await db
|
||||
.select()
|
||||
.from(canvasProject)
|
||||
.where(eq(canvasProject.userId, session.user.id))
|
||||
.orderBy(desc(canvasProject.updatedAt));
|
||||
|
||||
return NextResponse.json({ success: true, data: projects });
|
||||
} catch (error) {
|
||||
console.error('获取画布列表失败:', error);
|
||||
return NextResponse.json({ success: false, error: '获取画布列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 创建画布
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, description, canvasData } = body;
|
||||
|
||||
const [newProject] = await db
|
||||
.insert(canvasProject)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
title: title || '未命名画布',
|
||||
description,
|
||||
canvasData: canvasData || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ success: true, data: newProject });
|
||||
} catch (error) {
|
||||
console.error('创建画布失败:', error);
|
||||
return NextResponse.json({ success: false, error: '创建画布失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
18
app/canvas/[id]/layout.tsx
Normal file
18
app/canvas/[id]/layout.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "无限画布",
|
||||
description: "AI 驱动的无限画布创作工具",
|
||||
};
|
||||
|
||||
export default function CanvasEditorLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden bg-background">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
750
app/canvas/[id]/page.tsx
Normal file
750
app/canvas/[id]/page.tsx
Normal file
@ -0,0 +1,750 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
270
components/canvas/CanvasWorkspace.tsx
Normal file
270
components/canvas/CanvasWorkspace.tsx
Normal file
@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState, useCallback, useMemo } from "react";
|
||||
import { Rnd } from "react-rnd";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCanvasStore, canvasSelectors } from "@/store/canvas";
|
||||
import type { CanvasNode, Position } from "@/types/canvas";
|
||||
import { ImageNode } from "./nodes/ImageNode";
|
||||
import { TextNode } from "./nodes/TextNode";
|
||||
import { VideoNode } from "./nodes/VideoNode";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 性能优化:判断节点是否在视口内
|
||||
function isNodeInViewport(
|
||||
node: CanvasNode,
|
||||
viewport: { x: number; y: number; zoom: number },
|
||||
containerSize: { width: number; height: number }
|
||||
): boolean {
|
||||
const nodeScreenX = node.position.x * viewport.zoom + viewport.x;
|
||||
const nodeScreenY = node.position.y * viewport.zoom + viewport.y;
|
||||
const nodeScreenWidth = node.size.width * viewport.zoom;
|
||||
const nodeScreenHeight = node.size.height * viewport.zoom;
|
||||
|
||||
// 添加缓冲区(200px),使即将进入视口的节点也被渲染
|
||||
const buffer = 200;
|
||||
|
||||
return (
|
||||
nodeScreenX + nodeScreenWidth > -buffer &&
|
||||
nodeScreenX < containerSize.width + buffer &&
|
||||
nodeScreenY + nodeScreenHeight > -buffer &&
|
||||
nodeScreenY < containerSize.height + buffer
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasWorkspace() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
|
||||
const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 });
|
||||
|
||||
// Store state
|
||||
const nodes = useCanvasStore(canvasSelectors.nodes);
|
||||
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
|
||||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||||
const tool = useCanvasStore(canvasSelectors.tool);
|
||||
|
||||
// 性能优化:监听容器大小变化
|
||||
React.useEffect(() => {
|
||||
const updateSize = () => {
|
||||
if (containerRef.current) {
|
||||
setContainerSize({
|
||||
width: containerRef.current.clientWidth,
|
||||
height: containerRef.current.clientHeight,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateSize();
|
||||
window.addEventListener('resize', updateSize);
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
}, []);
|
||||
|
||||
// 性能优化:只渲染视口内的节点(虚拟化)
|
||||
const visibleNodes = useMemo(() => {
|
||||
// 如果节点数量少于 50,直接渲染所有节点
|
||||
if (nodes.length < 50) return nodes;
|
||||
|
||||
return nodes.filter(node =>
|
||||
isNodeInViewport(node, viewport, containerSize) ||
|
||||
selectedNodeIds.includes(node.id) // 选中的节点总是渲染
|
||||
);
|
||||
}, [nodes, viewport, containerSize, selectedNodeIds]);
|
||||
|
||||
// Store actions
|
||||
const setViewport = useCanvasStore((state) => state.setViewport);
|
||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||
const clearSelection = useCanvasStore((state) => state.clearSelection);
|
||||
const moveNode = useCanvasStore((state) => state.moveNode);
|
||||
const resizeNode = useCanvasStore((state) => state.resizeNode);
|
||||
const pushHistory = useCanvasStore((state) => state.pushHistory);
|
||||
|
||||
// 处理画布点击(取消选择)
|
||||
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
clearSelection();
|
||||
}
|
||||
}, [clearSelection]);
|
||||
|
||||
// 处理画布平移开始
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (tool === 'pan' || e.button === 1 || (e.button === 0 && e.altKey)) {
|
||||
setIsPanning(true);
|
||||
setPanStart({ x: e.clientX - viewport.x, y: e.clientY - viewport.y });
|
||||
e.preventDefault();
|
||||
}
|
||||
}, [tool, viewport]);
|
||||
|
||||
// 处理画布平移
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (isPanning) {
|
||||
setViewport({
|
||||
x: e.clientX - panStart.x,
|
||||
y: e.clientY - panStart.y,
|
||||
});
|
||||
}
|
||||
}, [isPanning, panStart, setViewport]);
|
||||
|
||||
// 处理画布平移结束
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, []);
|
||||
|
||||
// 处理滚轮缩放
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newZoom = Math.max(0.1, Math.min(4, viewport.zoom * delta));
|
||||
|
||||
// 以鼠标位置为中心缩放
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const scaleChange = newZoom / viewport.zoom;
|
||||
const newX = mouseX - (mouseX - viewport.x) * scaleChange;
|
||||
const newY = mouseY - (mouseY - viewport.y) * scaleChange;
|
||||
|
||||
setViewport({ x: newX, y: newY, zoom: newZoom });
|
||||
}
|
||||
} else {
|
||||
// 普通滚动平移
|
||||
setViewport({
|
||||
x: viewport.x - e.deltaX,
|
||||
y: viewport.y - e.deltaY,
|
||||
});
|
||||
}
|
||||
}, [viewport, setViewport]);
|
||||
|
||||
// 渲染节点
|
||||
const renderNode = (node: CanvasNode) => {
|
||||
const isSelected = selectedNodeIds.includes(node.id);
|
||||
|
||||
return (
|
||||
<Rnd
|
||||
key={node.id}
|
||||
position={{
|
||||
x: node.position.x * viewport.zoom + viewport.x,
|
||||
y: node.position.y * viewport.zoom + viewport.y,
|
||||
}}
|
||||
size={{
|
||||
width: node.size.width * viewport.zoom,
|
||||
height: node.size.height * viewport.zoom,
|
||||
}}
|
||||
scale={1}
|
||||
onDragStart={() => {
|
||||
if (!isSelected) {
|
||||
selectNode(node.id);
|
||||
}
|
||||
}}
|
||||
onDragStop={(e, d) => {
|
||||
const newX = (d.x - viewport.x) / viewport.zoom;
|
||||
const newY = (d.y - viewport.y) / viewport.zoom;
|
||||
moveNode(node.id, { x: newX, y: newY });
|
||||
pushHistory();
|
||||
}}
|
||||
onResizeStop={(e, direction, ref, delta, position) => {
|
||||
const newWidth = parseInt(ref.style.width) / viewport.zoom;
|
||||
const newHeight = parseInt(ref.style.height) / viewport.zoom;
|
||||
const newX = (position.x - viewport.x) / viewport.zoom;
|
||||
const newY = (position.y - viewport.y) / viewport.zoom;
|
||||
|
||||
resizeNode(node.id, { width: newWidth, height: newHeight });
|
||||
moveNode(node.id, { x: newX, y: newY });
|
||||
pushHistory();
|
||||
}}
|
||||
bounds="parent"
|
||||
enableResizing={isSelected && tool === 'select'}
|
||||
disableDragging={tool !== 'select'}
|
||||
className={cn(
|
||||
"absolute",
|
||||
isSelected && "ring-2 ring-primary ring-offset-2"
|
||||
)}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
selectNode(node.id, e.shiftKey);
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="w-full h-full"
|
||||
>
|
||||
{node.type === 'image' && (
|
||||
<ImageNode node={node} isSelected={isSelected} />
|
||||
)}
|
||||
{node.type === 'text' && (
|
||||
<TextNode node={node} isSelected={isSelected} />
|
||||
)}
|
||||
{node.type === 'video' && (
|
||||
<VideoNode node={node} isSelected={isSelected} />
|
||||
)}
|
||||
{node.type === 'message' && (
|
||||
<div className="w-full h-full bg-card rounded-lg p-4 shadow-sm border">
|
||||
<span className="text-sm">{(node.data as any).content}</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</Rnd>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative w-full h-full overflow-hidden bg-[#1a1a2e]",
|
||||
tool === 'pan' && "cursor-grab",
|
||||
isPanning && "cursor-grabbing"
|
||||
)}
|
||||
onClick={handleCanvasClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{/* 网格背景 */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: `${20 * viewport.zoom}px ${20 * viewport.zoom}px`,
|
||||
backgroundPosition: `${viewport.x}px ${viewport.y}px`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 节点层 - 使用虚拟化渲染 */}
|
||||
<div className="absolute inset-0">
|
||||
{visibleNodes.map(renderNode)}
|
||||
</div>
|
||||
|
||||
{/* 性能指示器(节点数量大于 50 时显示) */}
|
||||
{nodes.length >= 50 && (
|
||||
<div className="absolute top-4 right-4 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1 text-xs text-muted-foreground">
|
||||
渲染: {visibleNodes.length}/{nodes.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态提示 */}
|
||||
{nodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-lg mb-2">画布是空的</p>
|
||||
<p className="text-sm">点击顶部"添加"按钮开始创作</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩放指示器 */}
|
||||
<div className="absolute bottom-4 right-4 bg-background/80 backdrop-blur-sm rounded-md px-3 py-1.5 text-xs text-muted-foreground">
|
||||
{Math.round(viewport.zoom * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
components/canvas/index.ts
Normal file
4
components/canvas/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { CanvasWorkspace } from './CanvasWorkspace';
|
||||
export { ImageNode } from './nodes/ImageNode';
|
||||
export { TextNode } from './nodes/TextNode';
|
||||
export { VideoNode } from './nodes/VideoNode';
|
||||
279
components/canvas/nodes/ImageNode.tsx
Normal file
279
components/canvas/nodes/ImageNode.tsx
Normal file
@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ImagePlus, Loader2, AlertCircle, MoreVertical, Scissors, Sparkles, Expand, Maximize2, Move3D, Layers, Download, Trash2, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
import type { CanvasNode, ImageNodeData } from "@/types/canvas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AngleEditDialog } from "@/components/angle-edit-dialog";
|
||||
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
||||
|
||||
interface ImageNodeProps {
|
||||
node: CanvasNode;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
||||
const data = node.data as ImageNodeData;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [angleDialogOpen, setAngleDialogOpen] = useState(false);
|
||||
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
|
||||
|
||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||
const addNode = useCanvasStore((state) => state.addNode);
|
||||
|
||||
// 处理图片上传
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const src = event.target?.result as string;
|
||||
updateNode(node.id, {
|
||||
data: { ...data, src, status: 'completed' },
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
// 处理图片编辑操作 - 集成我们现有的 API
|
||||
const handleAction = async (action: string) => {
|
||||
if (!data.src) {
|
||||
toast.error("请先上传图片");
|
||||
return;
|
||||
}
|
||||
|
||||
// 对话框类型的操作
|
||||
if (action === 'angle') {
|
||||
setAngleDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
if (action === 'scene-fusion') {
|
||||
setSceneFusionDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
if (action === 'resize') {
|
||||
toast.info("尺寸调整功能即将上线");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
let result;
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'rmbg':
|
||||
result = await imageGenerationService.removeBackground(data.src);
|
||||
break;
|
||||
case 'enhance':
|
||||
result = await imageGenerationService.enhanceImage(data.src);
|
||||
break;
|
||||
case 'extend':
|
||||
result = await imageGenerationService.extendImage(data.src);
|
||||
break;
|
||||
default:
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.success && result.data?.url) {
|
||||
// 创建新的图片节点显示处理结果
|
||||
addNode({
|
||||
type: 'image',
|
||||
position: {
|
||||
x: node.position.x + node.size.width + 20,
|
||||
y: node.position.y
|
||||
},
|
||||
size: { width: node.size.width, height: node.size.height },
|
||||
data: {
|
||||
src: result.data.url,
|
||||
status: 'completed',
|
||||
prompt: `${action} 处理结果`,
|
||||
originalSrc: data.src,
|
||||
},
|
||||
connections: [node.id],
|
||||
});
|
||||
|
||||
toast.success(`${action} 处理完成`);
|
||||
} else {
|
||||
toast.error(result?.error || "处理失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${action} 处理失败:`, error);
|
||||
toast.error("处理失败,请重试");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 角度编辑完成回调
|
||||
const handleAngleEditComplete = (resultUrl: string) => {
|
||||
// 创建新的图片节点显示结果
|
||||
addNode({
|
||||
type: 'image',
|
||||
position: {
|
||||
x: node.position.x + node.size.width + 20,
|
||||
y: node.position.y
|
||||
},
|
||||
size: { width: node.size.width, height: node.size.height },
|
||||
data: {
|
||||
src: resultUrl,
|
||||
status: 'completed',
|
||||
prompt: '角度编辑结果',
|
||||
originalSrc: data.src,
|
||||
},
|
||||
connections: [node.id],
|
||||
});
|
||||
};
|
||||
|
||||
// 下载图片
|
||||
const handleDownload = () => {
|
||||
if (data.src) {
|
||||
const link = document.createElement('a');
|
||||
link.href = data.src;
|
||||
link.download = `image-${node.id}.png`;
|
||||
link.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full h-full bg-card rounded-lg overflow-hidden border shadow-sm",
|
||||
isSelected && "border-primary"
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* 图片内容 */}
|
||||
{data.src ? (
|
||||
<img
|
||||
src={data.src}
|
||||
alt={data.prompt || "图片"}
|
||||
className="w-full h-full object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
) : data.status === 'generating' ? (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
|
||||
<span className="text-sm text-muted-foreground">生成中...</span>
|
||||
</div>
|
||||
) : data.status === 'failed' ? (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10">
|
||||
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
|
||||
<span className="text-sm text-destructive">生成失败</span>
|
||||
</div>
|
||||
) : (
|
||||
<label className="w-full h-full flex flex-col items-center justify-center bg-muted cursor-pointer hover:bg-muted/80 transition-colors">
|
||||
<ImagePlus className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<span className="text-sm text-muted-foreground">点击上传图片</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 处理中遮罩 */}
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-white mb-2" />
|
||||
<span className="text-sm text-white">处理中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬浮操作栏 */}
|
||||
{isHovered && data.src && !isProcessing && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="h-7 w-7">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleAction('rmbg')}>
|
||||
<Scissors className="h-4 w-4 mr-2" />
|
||||
抠图
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAction('enhance')}>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
变清晰
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAction('extend')}>
|
||||
<Expand className="h-4 w-4 mr-2" />
|
||||
图片扩展
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAction('resize')}>
|
||||
<Maximize2 className="h-4 w-4 mr-2" />
|
||||
尺寸调整
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAction('angle')}>
|
||||
<Move3D className="h-4 w-4 mr-2" />
|
||||
角度编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAction('scene-fusion')}>
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
场景裂变
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => deleteNode(node.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提示词显示 */}
|
||||
{data.prompt && isHovered && !isProcessing && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
|
||||
<p className="text-xs text-white line-clamp-2">{data.prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 角度编辑对话框 */}
|
||||
{data.src && (
|
||||
<AngleEditDialog
|
||||
open={angleDialogOpen}
|
||||
onOpenChange={setAngleDialogOpen}
|
||||
imageUrl={data.src}
|
||||
onComplete={handleAngleEditComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 场景裂变对话框 */}
|
||||
<SceneFusionDialog
|
||||
open={sceneFusionDialogOpen}
|
||||
onOpenChange={setSceneFusionDialogOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
99
components/canvas/nodes/TextNode.tsx
Normal file
99
components/canvas/nodes/TextNode.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import type { CanvasNode, TextNodeData } from "@/types/canvas";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TextNodeProps {
|
||||
node: CanvasNode;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export function TextNode({ node, isSelected }: TextNodeProps) {
|
||||
const data = node.data as TextNodeData;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editContent, setEditContent] = useState(data.content);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||
|
||||
// 进入编辑模式时聚焦
|
||||
useEffect(() => {
|
||||
if (isEditing && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
// 保存编辑
|
||||
const handleSave = () => {
|
||||
updateNode(node.id, {
|
||||
data: { ...data, content: editContent },
|
||||
});
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
// 取消编辑
|
||||
const handleCancel = () => {
|
||||
setEditContent(data.content);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
// 双击进入编辑
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full h-full bg-card rounded-lg overflow-hidden border shadow-sm p-3",
|
||||
isSelected && "border-primary"
|
||||
)}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{isEditing ? (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleSave();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
}}
|
||||
className="w-full h-full resize-none bg-transparent outline-none text-sm"
|
||||
style={{
|
||||
fontSize: data.fontSize || 14,
|
||||
color: data.color || 'inherit',
|
||||
fontWeight: data.fontWeight || 'normal',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full overflow-auto text-sm whitespace-pre-wrap"
|
||||
style={{
|
||||
fontSize: data.fontSize || 14,
|
||||
color: data.color || 'inherit',
|
||||
fontWeight: data.fontWeight || 'normal',
|
||||
}}
|
||||
>
|
||||
{data.content || '双击编辑文本'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑提示 */}
|
||||
{!isEditing && isSelected && (
|
||||
<div className="absolute bottom-1 right-1 text-[10px] text-muted-foreground">
|
||||
双击编辑
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
247
components/canvas/nodes/VideoNode.tsx
Normal file
247
components/canvas/nodes/VideoNode.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Video, Loader2, AlertCircle, MoreVertical, Download, Trash2, Play, Pause, Volume2, VolumeX } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { toast } from "sonner";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import type { CanvasNode, VideoNodeData } from "@/types/canvas";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VideoNodeProps {
|
||||
node: CanvasNode;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export function VideoNode({ node, isSelected }: VideoNodeProps) {
|
||||
const data = node.data as VideoNodeData;
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(true);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||
|
||||
// 处理视频上传
|
||||
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 检查文件大小(限制 100MB)
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
toast.error("视频文件不能超过 100MB");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const src = event.target?.result as string;
|
||||
updateNode(node.id, {
|
||||
data: { ...data, src, status: 'completed' },
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
// 播放/暂停
|
||||
const togglePlay = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
// 静音/取消静音
|
||||
const toggleMute = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !isMuted;
|
||||
setIsMuted(!isMuted);
|
||||
}
|
||||
};
|
||||
|
||||
// 更新进度
|
||||
const handleTimeUpdate = () => {
|
||||
if (videoRef.current) {
|
||||
const progress = (videoRef.current.currentTime / videoRef.current.duration) * 100;
|
||||
setProgress(progress);
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转进度
|
||||
const handleSeek = (value: number[]) => {
|
||||
if (videoRef.current) {
|
||||
const time = (value[0] / 100) * videoRef.current.duration;
|
||||
videoRef.current.currentTime = time;
|
||||
setProgress(value[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载视频
|
||||
const handleDownload = () => {
|
||||
if (data.src) {
|
||||
const link = document.createElement('a');
|
||||
link.href = data.src;
|
||||
link.download = `video-${node.id}.mp4`;
|
||||
link.click();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full h-full bg-card rounded-lg overflow-hidden border shadow-sm",
|
||||
isSelected && "border-primary"
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* 视频内容 */}
|
||||
{data.src ? (
|
||||
<div className="relative w-full h-full">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={data.src}
|
||||
className="w-full h-full object-cover"
|
||||
muted={isMuted}
|
||||
loop
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
|
||||
{/* 播放控制层 */}
|
||||
{isHovered && (
|
||||
<div className="absolute inset-0 bg-black/30 flex flex-col justify-end">
|
||||
{/* 中央播放按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-14 w-14 rounded-full bg-black/50 hover:bg-black/70 text-white"
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-6 w-6" />
|
||||
) : (
|
||||
<Play className="h-6 w-6 ml-1" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 底部控制栏 */}
|
||||
<div className="p-3 space-y-2">
|
||||
{/* 进度条 */}
|
||||
<Slider
|
||||
value={[progress]}
|
||||
onValueChange={handleSeek}
|
||||
max={100}
|
||||
step={0.1}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
|
||||
{/* 控制按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-white hover:bg-white/20"
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-white hover:bg-white/20"
|
||||
onClick={toggleMute}
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : data.status === 'generating' ? (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-muted">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mb-2" />
|
||||
<span className="text-sm text-muted-foreground">生成中...</span>
|
||||
{data.progress && (
|
||||
<span className="text-xs text-muted-foreground mt-1">{data.progress}%</span>
|
||||
)}
|
||||
</div>
|
||||
) : data.status === 'failed' ? (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-destructive/10">
|
||||
<AlertCircle className="h-8 w-8 text-destructive mb-2" />
|
||||
<span className="text-sm text-destructive">生成失败</span>
|
||||
</div>
|
||||
) : (
|
||||
<label className="w-full h-full flex flex-col items-center justify-center bg-muted cursor-pointer hover:bg-muted/80 transition-colors">
|
||||
<Video className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<span className="text-sm text-muted-foreground">点击上传视频</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={handleVideoUpload}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 悬浮操作栏 */}
|
||||
{isHovered && data.src && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="h-7 w-7">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => deleteNode(node.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提示词显示 */}
|
||||
{data.prompt && isHovered && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
|
||||
<p className="text-xs text-white line-clamp-2">{data.prompt}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit } from "lucide-react";
|
||||
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
@ -254,6 +254,19 @@ export function TopNavbar({
|
||||
<span className="text-sm font-medium">图片编辑</span>
|
||||
</Button>
|
||||
|
||||
{/* 无限画布 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
|
||||
onClick={() => router.push("/canvas")}
|
||||
type="button"
|
||||
title="无限画布"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 text-purple-400" />
|
||||
<span className="text-sm font-medium">无限画布</span>
|
||||
</Button>
|
||||
|
||||
{/* 我的图册 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
1354
docs/无限画布集成开发方案.md
Normal file
1354
docs/无限画布集成开发方案.md
Normal file
File diff suppressed because it is too large
Load Diff
1749
lib/db/migrations/meta/0013_snapshot.json
Normal file
1749
lib/db/migrations/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -92,6 +92,13 @@
|
||||
"when": 1765109627149,
|
||||
"tag": "0027_add_default_theme",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1769247486013,
|
||||
"tag": "0013_loving_ultimatum",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -360,3 +360,46 @@ export const generatedImage = pgTable("GeneratedImage", {
|
||||
});
|
||||
|
||||
export type GeneratedImage = InferSelectModel<typeof generatedImage>;
|
||||
|
||||
// ============================================
|
||||
// 无限画布相关表
|
||||
// ============================================
|
||||
|
||||
// 画布项目表
|
||||
export const canvasProject = pgTable("CanvasProject", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
userId: uuid("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
title: varchar("title", { length: 256 }).notNull().default("未命名画布"),
|
||||
description: text("description"),
|
||||
thumbnail: text("thumbnail"),
|
||||
// 存储完整画布状态(节点、边、视口等)
|
||||
canvasData: jsonb("canvasData").$type<{
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
viewport: { x: number; y: number; zoom: number };
|
||||
}>(),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type CanvasProject = InferSelectModel<typeof canvasProject>;
|
||||
|
||||
// 画布节点表(可选,用于更细粒度的查询)
|
||||
export const canvasNode = pgTable("CanvasNode", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
projectId: uuid("projectId")
|
||||
.notNull()
|
||||
.references(() => canvasProject.id, { onDelete: "cascade" }),
|
||||
type: varchar("type", { length: 32 }).notNull(), // 'message' | 'image' | 'video' | 'text' | 'scene-fusion'
|
||||
position: jsonb("position").$type<{ x: number; y: number }>(),
|
||||
size: jsonb("size").$type<{ width: number; height: number }>(),
|
||||
data: jsonb("data"), // 节点具体数据
|
||||
connections: jsonb("connections").$type<string[]>().default([]),
|
||||
parentId: uuid("parentId"), // 父节点ID,用于对话上下文
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type CanvasNode = InferSelectModel<typeof canvasNode>;
|
||||
|
||||
@ -64,8 +64,9 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"form-data": "^4.0.5",
|
||||
"framer-motion": "^11.3.19",
|
||||
"framer-motion": "^11.18.2",
|
||||
"geist": "^1.3.1",
|
||||
"immer": "^11.1.3",
|
||||
"katex": "^0.16.25",
|
||||
"lucide-react": "^0.446.0",
|
||||
"matter-js": "^0.20.0",
|
||||
@ -89,6 +90,7 @@
|
||||
"react-data-grid": "7.0.0-beta.47",
|
||||
"react-dom": "19.0.0-rc-45804af1-20241021",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"redis": "^5.0.0",
|
||||
"resumable-stream": "^2.0.0",
|
||||
@ -105,7 +107,8 @@
|
||||
"tokenlens": "1.3.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"zod": "^3.25.76"
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.2",
|
||||
|
||||
129
pnpm-lock.yaml
generated
129
pnpm-lock.yaml
generated
@ -85,10 +85,10 @@ importers:
|
||||
version: 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||
'@react-three/drei':
|
||||
specifier: ^10.7.7
|
||||
version: 10.7.7(@react-three/fiber@9.5.0(@types/react@18.3.18)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0))(@types/react@18.3.18)(@types/three@0.182.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
|
||||
version: 10.7.7(@react-three/fiber@9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0))(@types/react@18.3.18)(@types/three@0.182.0)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
|
||||
'@react-three/fiber':
|
||||
specifier: ^9.5.0
|
||||
version: 9.5.0(@types/react@18.3.18)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
|
||||
version: 9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
|
||||
'@types/form-data':
|
||||
specifier: ^2.5.2
|
||||
version: 2.5.2
|
||||
@ -147,11 +147,14 @@ importers:
|
||||
specifier: ^4.0.5
|
||||
version: 4.0.5
|
||||
framer-motion:
|
||||
specifier: ^11.3.19
|
||||
specifier: ^11.18.2
|
||||
version: 11.18.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||
geist:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021))
|
||||
immer:
|
||||
specifier: ^11.1.3
|
||||
version: 11.1.3
|
||||
katex:
|
||||
specifier: ^0.16.25
|
||||
version: 0.16.25
|
||||
@ -221,6 +224,9 @@ importers:
|
||||
react-resizable-panels:
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.7(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||
react-rnd:
|
||||
specifier: ^10.5.2
|
||||
version: 10.5.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||
react-syntax-highlighter:
|
||||
specifier: ^15.6.6
|
||||
version: 15.6.6(react@19.0.0-rc-45804af1-20241021)
|
||||
@ -272,6 +278,9 @@ importers:
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
zustand:
|
||||
specifier: 5.0.4
|
||||
version: 5.0.4(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021))
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.2.2
|
||||
@ -3079,6 +3088,7 @@ packages:
|
||||
'@vercel/postgres@0.10.0':
|
||||
resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==}
|
||||
engines: {node: '>=18.14'}
|
||||
deprecated: '@vercel/postgres is deprecated. You can either choose an alternate storage solution from the Vercel Marketplace if you want to set up a new database. Or you can follow this guide to migrate your existing Vercel Postgres db: https://neon.com/docs/guides/vercel-postgres-transition-guide'
|
||||
|
||||
'@vitest/expect@3.2.4':
|
||||
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
|
||||
@ -3260,6 +3270,10 @@ packages:
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
clsx@1.2.1:
|
||||
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
@ -3935,6 +3949,9 @@ packages:
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
immer@11.1.3:
|
||||
resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==}
|
||||
|
||||
import-in-the-middle@1.15.0:
|
||||
resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==}
|
||||
|
||||
@ -4002,6 +4019,9 @@ packages:
|
||||
jose@6.1.3:
|
||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-tokens@9.0.1:
|
||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||
|
||||
@ -4132,6 +4152,10 @@ packages:
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
@ -4418,6 +4442,10 @@ packages:
|
||||
oauth4webapi@3.3.1:
|
||||
resolution: {integrity: sha512-ZwX7UqYrP3Lr+Glhca3a1/nF2jqf7VVyJfhGuW5JtrfDUxt0u+IoBPzFjZ2dd7PJGkdM6CFPVVYzuDYKHv101A==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
obuf@1.1.2:
|
||||
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
|
||||
|
||||
@ -4587,6 +4615,9 @@ packages:
|
||||
promise-worker-transferable@1.0.4:
|
||||
resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
property-information@5.6.0:
|
||||
resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==}
|
||||
|
||||
@ -4665,6 +4696,12 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
re-resizable@6.11.2:
|
||||
resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==}
|
||||
peerDependencies:
|
||||
react: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react-data-grid@7.0.0-beta.47:
|
||||
resolution: {integrity: sha512-28kjsmwQGD/9RXYC50zn5Zv/SQMhBBoSvG5seq0fM8XXi9TZ0zr9Z5T3YJqLwcEtoNzTOq3y0njkmdujGkIwQQ==}
|
||||
peerDependencies:
|
||||
@ -4676,6 +4713,15 @@ packages:
|
||||
peerDependencies:
|
||||
react: 19.0.0-rc-45804af1-20241021
|
||||
|
||||
react-draggable@4.4.6:
|
||||
resolution: {integrity: sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==}
|
||||
peerDependencies:
|
||||
react: '>= 16.3.0'
|
||||
react-dom: '>= 16.3.0'
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-markdown@10.1.0:
|
||||
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
|
||||
peerDependencies:
|
||||
@ -4708,6 +4754,12 @@ packages:
|
||||
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
react-rnd@10.5.2:
|
||||
resolution: {integrity: sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw==}
|
||||
peerDependencies:
|
||||
react: '>=16.3.0'
|
||||
react-dom: '>=16.3.0'
|
||||
|
||||
react-style-singleton@2.2.3:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
engines: {node: '>=10'}
|
||||
@ -5050,6 +5102,9 @@ packages:
|
||||
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
|
||||
engines: {node: '>=6.10'}
|
||||
|
||||
tslib@2.6.2:
|
||||
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@ -5330,8 +5385,8 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
zustand@5.0.9:
|
||||
resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==}
|
||||
zustand@5.0.4:
|
||||
resolution: {integrity: sha512-39VFTN5InDtMd28ZhjLyuTnlytDr9HfwO512Ai4I8ZABCoyAj4F1+sr7sD1jP/+p7k77Iko0Pb5NhgBFDCX0kQ==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=18.0.0'
|
||||
@ -7407,12 +7462,12 @@ snapshots:
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@18.3.18)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0))(@types/react@18.3.18)(@types/three@0.182.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)':
|
||||
'@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0))(@types/react@18.3.18)(@types/three@0.182.0)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@mediapipe/tasks-vision': 0.10.17
|
||||
'@monogrid/gainmap-js': 3.4.0(three@0.182.0)
|
||||
'@react-three/fiber': 9.5.0(@types/react@18.3.18)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
|
||||
'@react-three/fiber': 9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)
|
||||
'@use-gesture/react': 10.3.1(react@19.0.0-rc-45804af1-20241021)
|
||||
camera-controls: 3.1.2(three@0.182.0)
|
||||
cross-env: 7.0.3
|
||||
@ -7429,10 +7484,10 @@ snapshots:
|
||||
three-mesh-bvh: 0.8.3(three@0.182.0)
|
||||
three-stdlib: 2.36.1(three@0.182.0)
|
||||
troika-three-text: 0.52.4(three@0.182.0)
|
||||
tunnel-rat: 0.1.2(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021)
|
||||
tunnel-rat: 0.1.2(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021)
|
||||
use-sync-external-store: 1.6.0(react@19.0.0-rc-45804af1-20241021)
|
||||
utility-types: 3.11.0
|
||||
zustand: 5.0.9(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021))
|
||||
zustand: 5.0.4(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021))
|
||||
optionalDependencies:
|
||||
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
|
||||
transitivePeerDependencies:
|
||||
@ -7440,7 +7495,7 @@ snapshots:
|
||||
- '@types/three'
|
||||
- immer
|
||||
|
||||
'@react-three/fiber@9.5.0(@types/react@18.3.18)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)':
|
||||
'@react-three/fiber@9.5.0(@types/react@18.3.18)(immer@11.1.3)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(three@0.182.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
'@types/webxr': 0.5.24
|
||||
@ -7453,7 +7508,7 @@ snapshots:
|
||||
suspend-react: 0.1.3(react@19.0.0-rc-45804af1-20241021)
|
||||
three: 0.182.0
|
||||
use-sync-external-store: 1.6.0(react@19.0.0-rc-45804af1-20241021)
|
||||
zustand: 5.0.9(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021))
|
||||
zustand: 5.0.4(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021))
|
||||
optionalDependencies:
|
||||
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
|
||||
transitivePeerDependencies:
|
||||
@ -8497,6 +8552,8 @@ snapshots:
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
clsx@1.2.1: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
@ -9203,6 +9260,8 @@ snapshots:
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
immer@11.1.3: {}
|
||||
|
||||
import-in-the-middle@1.15.0:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
@ -9261,6 +9320,8 @@ snapshots:
|
||||
|
||||
jose@6.1.3: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
||||
json-schema@0.4.0: {}
|
||||
@ -9364,6 +9425,10 @@ snapshots:
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
lowlight@1.20.0:
|
||||
@ -9883,6 +9948,8 @@ snapshots:
|
||||
|
||||
oauth4webapi@3.3.1: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
obuf@1.1.2:
|
||||
optional: true
|
||||
|
||||
@ -10067,6 +10134,12 @@ snapshots:
|
||||
is-promise: 2.2.2
|
||||
lie: 3.3.0
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
property-information@5.6.0:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
@ -10249,6 +10322,11 @@ snapshots:
|
||||
'@types/react': 18.3.18
|
||||
'@types/react-dom': 18.3.5(@types/react@18.3.18)
|
||||
|
||||
re-resizable@6.11.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
|
||||
dependencies:
|
||||
react: 19.0.0-rc-45804af1-20241021
|
||||
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
|
||||
|
||||
react-data-grid@7.0.0-beta.47(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
@ -10260,6 +10338,15 @@ snapshots:
|
||||
react: 19.0.0-rc-45804af1-20241021
|
||||
scheduler: 0.25.0-rc-45804af1-20241021
|
||||
|
||||
react-draggable@4.4.6(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
|
||||
dependencies:
|
||||
clsx: 1.2.1
|
||||
prop-types: 15.8.1
|
||||
react: 19.0.0-rc-45804af1-20241021
|
||||
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-markdown@10.1.0(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021):
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@ -10302,6 +10389,14 @@ snapshots:
|
||||
react: 19.0.0-rc-45804af1-20241021
|
||||
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
|
||||
|
||||
react-rnd@10.5.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
|
||||
dependencies:
|
||||
re-resizable: 6.11.2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||
react: 19.0.0-rc-45804af1-20241021
|
||||
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
|
||||
react-draggable: 4.4.6(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||
tslib: 2.6.2
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
@ -10716,6 +10811,8 @@ snapshots:
|
||||
|
||||
ts-dedent@2.2.0: {}
|
||||
|
||||
tslib@2.6.2: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.19.3:
|
||||
@ -10725,9 +10822,9 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
tunnel-rat@0.1.2(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021):
|
||||
tunnel-rat@0.1.2(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021):
|
||||
dependencies:
|
||||
zustand: 4.5.7(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021)
|
||||
zustand: 4.5.7(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
@ -11005,16 +11102,18 @@ snapshots:
|
||||
|
||||
zod@4.1.12: {}
|
||||
|
||||
zustand@4.5.7(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021):
|
||||
zustand@4.5.7(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021):
|
||||
dependencies:
|
||||
use-sync-external-store: 1.6.0(react@19.0.0-rc-45804af1-20241021)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
immer: 11.1.3
|
||||
react: 19.0.0-rc-45804af1-20241021
|
||||
|
||||
zustand@5.0.9(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021)):
|
||||
zustand@5.0.4(@types/react@18.3.18)(immer@11.1.3)(react@19.0.0-rc-45804af1-20241021)(use-sync-external-store@1.6.0(react@19.0.0-rc-45804af1-20241021)):
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
immer: 11.1.3
|
||||
react: 19.0.0-rc-45804af1-20241021
|
||||
use-sync-external-store: 1.6.0(react@19.0.0-rc-45804af1-20241021)
|
||||
|
||||
|
||||
260
services/canvas/index.ts
Normal file
260
services/canvas/index.ts
Normal file
@ -0,0 +1,260 @@
|
||||
// 画布服务层
|
||||
import type { CanvasProject, CanvasState, CanvasNode } from '@/types/canvas';
|
||||
|
||||
// API 响应类型
|
||||
interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 画布服务
|
||||
export const canvasService = {
|
||||
// 获取画布列表
|
||||
async getProjects(): Promise<ApiResponse<CanvasProject[]>> {
|
||||
try {
|
||||
const response = await fetch('/api/canvas');
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '获取画布列表失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// 获取单个画布
|
||||
async getProject(id: string): Promise<ApiResponse<CanvasProject>> {
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/${id}`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '获取画布失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// 创建画布
|
||||
async createProject(project: Partial<CanvasProject>): Promise<ApiResponse<CanvasProject>> {
|
||||
try {
|
||||
const response = await fetch('/api/canvas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(project),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '创建画布失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// 更新画布
|
||||
async updateProject(id: string, updates: Partial<CanvasProject>): Promise<ApiResponse<CanvasProject>> {
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '更新画布失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// 删除画布
|
||||
async deleteProject(id: string): Promise<ApiResponse> {
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '删除画布失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// 保存画布状态
|
||||
async saveCanvasState(id: string, canvasData: CanvasState): Promise<ApiResponse> {
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ canvasData }),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '保存画布状态失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// 自动保存(防抖)
|
||||
createAutoSave(projectId: string, delay = 2000) {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
return (canvasData: CanvasState) => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(async () => {
|
||||
await this.saveCanvasState(projectId, canvasData);
|
||||
}, delay);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// 图片生成服务
|
||||
export const imageGenerationService = {
|
||||
// AI 生成图片
|
||||
async generateImage(params: {
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
model?: string;
|
||||
aspectRatio?: string;
|
||||
imageCount?: number;
|
||||
referenceImage?: string;
|
||||
}): Promise<ApiResponse<{ images: Array<{ thumbnailUrl: string; originalUrl: string }>; model?: string; prompt?: string }>> {
|
||||
try {
|
||||
const response = await fetch('/api/canvas/generate/image', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '生成图片失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// 图片抠图
|
||||
async removeBackground(imageUrl: string): Promise<ApiResponse<{ url: string }>> {
|
||||
try {
|
||||
const response = await fetch('/api/rmbg', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageUrl }),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '抠图失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// 图片增强
|
||||
async enhanceImage(imageUrl: string): Promise<ApiResponse<{ url: string }>> {
|
||||
try {
|
||||
const response = await fetch('/api/enhance', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageUrl }),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '图片增强失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// 图片扩展
|
||||
async extendImage(imageUrl: string, direction?: string): Promise<ApiResponse<{ url: string }>> {
|
||||
try {
|
||||
const response = await fetch('/api/extend', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageUrl, direction }),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '图片扩展失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// 尺寸调整
|
||||
async resizeImage(imageUrl: string, width: number, height: number): Promise<ApiResponse<{ url: string }>> {
|
||||
try {
|
||||
const response = await fetch('/api/resize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ imageUrl, width, height }),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '尺寸调整失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// 角度编辑
|
||||
async editAngle(params: {
|
||||
imageUrl: string;
|
||||
horizontalAngle: number;
|
||||
verticalAngle: number;
|
||||
zoom: number;
|
||||
}): Promise<ApiResponse<{ url: string }>> {
|
||||
try {
|
||||
const response = await fetch('/api/angle-edit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '角度编辑失败' };
|
||||
}
|
||||
},
|
||||
|
||||
// 场景裂变
|
||||
async sceneFusion(params: {
|
||||
productImage: string;
|
||||
backgroundImages: string[];
|
||||
productName?: string;
|
||||
}): Promise<ApiResponse<{ urls: string[] }>> {
|
||||
try {
|
||||
const response = await fetch('/api/scene-fusion', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '场景裂变失败' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 视频生成服务
|
||||
export const videoGenerationService = {
|
||||
// 生成视频
|
||||
async generateVideo(params: {
|
||||
prompt: string;
|
||||
model: string;
|
||||
referenceImage?: string;
|
||||
duration?: number;
|
||||
}): Promise<ApiResponse<{ url: string }>> {
|
||||
try {
|
||||
const response = await fetch('/api/canvas/generate/video', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return { success: false, error: '生成视频失败' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
canvas: canvasService,
|
||||
image: imageGenerationService,
|
||||
video: videoGenerationService,
|
||||
};
|
||||
397
store/canvas/index.ts
Normal file
397
store/canvas/index.ts
Normal file
@ -0,0 +1,397 @@
|
||||
import { create } from 'zustand';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import type {
|
||||
CanvasNode,
|
||||
CanvasEdge,
|
||||
CanvasState,
|
||||
Viewport,
|
||||
ToolType,
|
||||
Position,
|
||||
Size,
|
||||
NodeType,
|
||||
} from '@/types/canvas';
|
||||
|
||||
// 生成唯一 ID
|
||||
const generateId = () => crypto.randomUUID();
|
||||
|
||||
// 默认视口
|
||||
const defaultViewport: Viewport = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom: 1,
|
||||
};
|
||||
|
||||
// Store 状态接口
|
||||
interface CanvasStore {
|
||||
// 项目信息
|
||||
projectId: string | null;
|
||||
projectTitle: string;
|
||||
|
||||
// 画布状态
|
||||
nodes: CanvasNode[];
|
||||
edges: CanvasEdge[];
|
||||
selectedNodeIds: string[];
|
||||
viewport: Viewport;
|
||||
tool: ToolType;
|
||||
|
||||
// 历史记录(用于撤销/重做)
|
||||
history: CanvasState[];
|
||||
historyIndex: number;
|
||||
|
||||
// UI 状态
|
||||
isPanelOpen: boolean;
|
||||
isGenerating: boolean;
|
||||
|
||||
// 项目操作
|
||||
setProject: (id: string | null, title?: string) => void;
|
||||
setProjectTitle: (title: string) => void;
|
||||
|
||||
// 节点操作
|
||||
addNode: (node: Omit<CanvasNode, 'id' | 'createdAt' | 'updatedAt'>) => string;
|
||||
updateNode: (id: string, updates: Partial<CanvasNode>) => void;
|
||||
deleteNode: (id: string) => void;
|
||||
deleteSelectedNodes: () => void;
|
||||
|
||||
// 选择操作
|
||||
selectNode: (id: string, multi?: boolean) => void;
|
||||
selectNodes: (ids: string[]) => void;
|
||||
clearSelection: () => void;
|
||||
selectAll: () => void;
|
||||
|
||||
// 边操作
|
||||
addEdge: (edge: Omit<CanvasEdge, 'id'>) => string;
|
||||
deleteEdge: (id: string) => void;
|
||||
|
||||
// 视口操作
|
||||
setViewport: (viewport: Partial<Viewport>) => void;
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
zoomTo: (zoom: number) => void;
|
||||
fitToScreen: () => void;
|
||||
centerNode: (nodeId: string) => void;
|
||||
|
||||
// 工具操作
|
||||
setTool: (tool: ToolType) => void;
|
||||
|
||||
// 历史操作
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
pushHistory: () => void;
|
||||
|
||||
// UI 操作
|
||||
togglePanel: () => void;
|
||||
setGenerating: (isGenerating: boolean) => void;
|
||||
|
||||
// 批量操作
|
||||
loadCanvas: (state: Partial<CanvasState>) => void;
|
||||
clearCanvas: () => void;
|
||||
getCanvasState: () => CanvasState;
|
||||
|
||||
// 节点位置操作
|
||||
moveNode: (id: string, position: Position) => void;
|
||||
resizeNode: (id: string, size: Size) => void;
|
||||
|
||||
// 辅助方法
|
||||
getNode: (id: string) => CanvasNode | undefined;
|
||||
getSelectedNodes: () => CanvasNode[];
|
||||
getNodesByType: (type: NodeType) => CanvasNode[];
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
immer((set, get) => ({
|
||||
// 初始状态
|
||||
projectId: null,
|
||||
projectTitle: '未命名画布',
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodeIds: [],
|
||||
viewport: defaultViewport,
|
||||
tool: 'select',
|
||||
history: [],
|
||||
historyIndex: -1,
|
||||
isPanelOpen: true,
|
||||
isGenerating: false,
|
||||
|
||||
// 项目操作
|
||||
setProject: (id, title) => set((state) => {
|
||||
state.projectId = id;
|
||||
if (title) state.projectTitle = title;
|
||||
}),
|
||||
|
||||
setProjectTitle: (title) => set((state) => {
|
||||
state.projectTitle = title;
|
||||
}),
|
||||
|
||||
// 节点操作
|
||||
addNode: (nodeData) => {
|
||||
const id = generateId();
|
||||
const now = new Date();
|
||||
const node: CanvasNode = {
|
||||
...nodeData,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
state.nodes.push(node);
|
||||
});
|
||||
|
||||
// 添加到历史
|
||||
get().pushHistory();
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
updateNode: (id, updates) => set((state) => {
|
||||
const index = state.nodes.findIndex(n => n.id === id);
|
||||
if (index !== -1) {
|
||||
state.nodes[index] = {
|
||||
...state.nodes[index],
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
deleteNode: (id) => {
|
||||
set((state) => {
|
||||
state.nodes = state.nodes.filter(n => n.id !== id);
|
||||
state.edges = state.edges.filter(e => e.source !== id && e.target !== id);
|
||||
state.selectedNodeIds = state.selectedNodeIds.filter(nid => nid !== id);
|
||||
});
|
||||
get().pushHistory();
|
||||
},
|
||||
|
||||
deleteSelectedNodes: () => {
|
||||
const { selectedNodeIds } = get();
|
||||
set((state) => {
|
||||
state.nodes = state.nodes.filter(n => !selectedNodeIds.includes(n.id));
|
||||
state.edges = state.edges.filter(
|
||||
e => !selectedNodeIds.includes(e.source) && !selectedNodeIds.includes(e.target)
|
||||
);
|
||||
state.selectedNodeIds = [];
|
||||
});
|
||||
get().pushHistory();
|
||||
},
|
||||
|
||||
// 选择操作
|
||||
selectNode: (id, multi = false) => set((state) => {
|
||||
if (multi) {
|
||||
const index = state.selectedNodeIds.indexOf(id);
|
||||
if (index === -1) {
|
||||
state.selectedNodeIds.push(id);
|
||||
} else {
|
||||
state.selectedNodeIds.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
state.selectedNodeIds = [id];
|
||||
}
|
||||
}),
|
||||
|
||||
selectNodes: (ids) => set((state) => {
|
||||
state.selectedNodeIds = ids;
|
||||
}),
|
||||
|
||||
clearSelection: () => set((state) => {
|
||||
state.selectedNodeIds = [];
|
||||
}),
|
||||
|
||||
selectAll: () => set((state) => {
|
||||
state.selectedNodeIds = state.nodes.map(n => n.id);
|
||||
}),
|
||||
|
||||
// 边操作
|
||||
addEdge: (edgeData) => {
|
||||
const id = generateId();
|
||||
const edge: CanvasEdge = { ...edgeData, id };
|
||||
|
||||
set((state) => {
|
||||
// 检查是否已存在相同的边
|
||||
const exists = state.edges.some(
|
||||
e => e.source === edge.source && e.target === edge.target
|
||||
);
|
||||
if (!exists) {
|
||||
state.edges.push(edge);
|
||||
}
|
||||
});
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
deleteEdge: (id) => set((state) => {
|
||||
state.edges = state.edges.filter(e => e.id !== id);
|
||||
}),
|
||||
|
||||
// 视口操作
|
||||
setViewport: (viewport) => set((state) => {
|
||||
state.viewport = { ...state.viewport, ...viewport };
|
||||
}),
|
||||
|
||||
zoomIn: () => set((state) => {
|
||||
state.viewport.zoom = Math.min(state.viewport.zoom * 1.2, 4);
|
||||
}),
|
||||
|
||||
zoomOut: () => set((state) => {
|
||||
state.viewport.zoom = Math.max(state.viewport.zoom / 1.2, 0.1);
|
||||
}),
|
||||
|
||||
zoomTo: (zoom) => set((state) => {
|
||||
state.viewport.zoom = Math.max(0.1, Math.min(zoom, 4));
|
||||
}),
|
||||
|
||||
fitToScreen: () => set((state) => {
|
||||
// TODO: 计算所有节点的边界并调整视口
|
||||
state.viewport = { x: 0, y: 0, zoom: 1 };
|
||||
}),
|
||||
|
||||
centerNode: (nodeId) => {
|
||||
const node = get().getNode(nodeId);
|
||||
if (node) {
|
||||
set((state) => {
|
||||
state.viewport.x = -node.position.x - node.size.width / 2;
|
||||
state.viewport.y = -node.position.y - node.size.height / 2;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 工具操作
|
||||
setTool: (tool) => set((state) => {
|
||||
state.tool = tool;
|
||||
}),
|
||||
|
||||
// 历史操作
|
||||
undo: () => set((state) => {
|
||||
if (state.historyIndex > 0) {
|
||||
state.historyIndex -= 1;
|
||||
const prevState = state.history[state.historyIndex];
|
||||
state.nodes = prevState.nodes;
|
||||
state.edges = prevState.edges;
|
||||
state.selectedNodeIds = prevState.selectedNodeIds;
|
||||
}
|
||||
}),
|
||||
|
||||
redo: () => set((state) => {
|
||||
if (state.historyIndex < state.history.length - 1) {
|
||||
state.historyIndex += 1;
|
||||
const nextState = state.history[state.historyIndex];
|
||||
state.nodes = nextState.nodes;
|
||||
state.edges = nextState.edges;
|
||||
state.selectedNodeIds = nextState.selectedNodeIds;
|
||||
}
|
||||
}),
|
||||
|
||||
pushHistory: () => set((state) => {
|
||||
const currentState: CanvasState = {
|
||||
nodes: JSON.parse(JSON.stringify(state.nodes)),
|
||||
edges: JSON.parse(JSON.stringify(state.edges)),
|
||||
selectedNodeIds: [...state.selectedNodeIds],
|
||||
viewport: { ...state.viewport },
|
||||
tool: state.tool,
|
||||
};
|
||||
|
||||
// 移除当前位置之后的历史
|
||||
state.history = state.history.slice(0, state.historyIndex + 1);
|
||||
state.history.push(currentState);
|
||||
state.historyIndex = state.history.length - 1;
|
||||
|
||||
// 限制历史记录数量
|
||||
if (state.history.length > 50) {
|
||||
state.history = state.history.slice(-50);
|
||||
state.historyIndex = state.history.length - 1;
|
||||
}
|
||||
}),
|
||||
|
||||
// UI 操作
|
||||
togglePanel: () => set((state) => {
|
||||
state.isPanelOpen = !state.isPanelOpen;
|
||||
}),
|
||||
|
||||
setGenerating: (isGenerating) => set((state) => {
|
||||
state.isGenerating = isGenerating;
|
||||
}),
|
||||
|
||||
// 批量操作
|
||||
loadCanvas: (canvasState) => set((state) => {
|
||||
if (canvasState.nodes) state.nodes = canvasState.nodes;
|
||||
if (canvasState.edges) state.edges = canvasState.edges;
|
||||
if (canvasState.selectedNodeIds) state.selectedNodeIds = canvasState.selectedNodeIds;
|
||||
if (canvasState.viewport) state.viewport = canvasState.viewport;
|
||||
if (canvasState.tool) state.tool = canvasState.tool;
|
||||
}),
|
||||
|
||||
clearCanvas: () => set((state) => {
|
||||
state.nodes = [];
|
||||
state.edges = [];
|
||||
state.selectedNodeIds = [];
|
||||
state.viewport = defaultViewport;
|
||||
state.history = [];
|
||||
state.historyIndex = -1;
|
||||
}),
|
||||
|
||||
getCanvasState: () => {
|
||||
const state = get();
|
||||
return {
|
||||
nodes: state.nodes,
|
||||
edges: state.edges,
|
||||
selectedNodeIds: state.selectedNodeIds,
|
||||
viewport: state.viewport,
|
||||
tool: state.tool,
|
||||
};
|
||||
},
|
||||
|
||||
// 节点位置操作
|
||||
moveNode: (id, position) => set((state) => {
|
||||
const node = state.nodes.find(n => n.id === id);
|
||||
if (node) {
|
||||
node.position = position;
|
||||
node.updatedAt = new Date();
|
||||
}
|
||||
}),
|
||||
|
||||
resizeNode: (id, size) => set((state) => {
|
||||
const node = state.nodes.find(n => n.id === id);
|
||||
if (node) {
|
||||
node.size = size;
|
||||
node.updatedAt = new Date();
|
||||
}
|
||||
}),
|
||||
|
||||
// 辅助方法
|
||||
getNode: (id) => get().nodes.find(n => n.id === id),
|
||||
|
||||
getSelectedNodes: () => {
|
||||
const { nodes, selectedNodeIds } = get();
|
||||
return nodes.filter(n => selectedNodeIds.includes(n.id));
|
||||
},
|
||||
|
||||
getNodesByType: (type) => get().nodes.filter(n => n.type === type),
|
||||
})),
|
||||
{
|
||||
name: 'canvas-storage',
|
||||
partialize: (state) => ({
|
||||
// 只持久化部分状态
|
||||
projectId: state.projectId,
|
||||
projectTitle: state.projectTitle,
|
||||
}),
|
||||
}
|
||||
),
|
||||
{ name: 'canvas-store' }
|
||||
)
|
||||
);
|
||||
|
||||
// 导出选择器
|
||||
export const canvasSelectors = {
|
||||
nodes: (state: CanvasStore) => state.nodes,
|
||||
edges: (state: CanvasStore) => state.edges,
|
||||
selectedNodeIds: (state: CanvasStore) => state.selectedNodeIds,
|
||||
viewport: (state: CanvasStore) => state.viewport,
|
||||
tool: (state: CanvasStore) => state.tool,
|
||||
isGenerating: (state: CanvasStore) => state.isGenerating,
|
||||
hasSelection: (state: CanvasStore) => state.selectedNodeIds.length > 0,
|
||||
canUndo: (state: CanvasStore) => state.historyIndex > 0,
|
||||
canRedo: (state: CanvasStore) => state.historyIndex < state.history.length - 1,
|
||||
};
|
||||
162
types/canvas/index.ts
Normal file
162
types/canvas/index.ts
Normal file
@ -0,0 +1,162 @@
|
||||
// 画布节点类型定义
|
||||
|
||||
export type NodeType = 'message' | 'image' | 'video' | 'text' | 'scene-fusion';
|
||||
export type NodeStatus = 'pending' | 'generating' | 'completed' | 'failed';
|
||||
export type ToolType = 'select' | 'pan' | 'draw' | 'text';
|
||||
|
||||
// 画布节点位置
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// 画布节点尺寸
|
||||
export interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// 视口状态
|
||||
export interface Viewport {
|
||||
x: number;
|
||||
y: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
// 消息节点数据
|
||||
export interface MessageNodeData {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
// 图片节点数据
|
||||
export interface ImageNodeData {
|
||||
src: string;
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
status: NodeStatus;
|
||||
metadata?: Record<string, any>;
|
||||
// 图片编辑相关
|
||||
originalSrc?: string;
|
||||
editHistory?: ImageEditAction[];
|
||||
}
|
||||
|
||||
// 视频节点数据
|
||||
export interface VideoNodeData {
|
||||
src: string;
|
||||
thumbnail?: string;
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
status: NodeStatus;
|
||||
duration?: number;
|
||||
progress?: number; // 生成进度百分比
|
||||
coverImage?: string; // 用于生成视频的封面图
|
||||
}
|
||||
|
||||
// 文本节点数据
|
||||
export interface TextNodeData {
|
||||
content: string;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
fontWeight?: string;
|
||||
}
|
||||
|
||||
// 场景裂变节点数据
|
||||
export interface SceneFusionNodeData {
|
||||
productImage: string;
|
||||
backgroundImages: string[];
|
||||
productName?: string;
|
||||
results: string[];
|
||||
status: NodeStatus;
|
||||
}
|
||||
|
||||
// 画布节点
|
||||
export interface CanvasNode {
|
||||
id: string;
|
||||
type: NodeType;
|
||||
position: Position;
|
||||
size: Size;
|
||||
data: MessageNodeData | ImageNodeData | VideoNodeData | TextNodeData | SceneFusionNodeData;
|
||||
connections: string[]; // 连接的节点 ID
|
||||
parentId?: string; // 父节点 ID(用于对话上下文)
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// 画布连接线
|
||||
export interface CanvasEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
type?: 'default' | 'conversation' | 'reference';
|
||||
}
|
||||
|
||||
// 画布状态
|
||||
export interface CanvasState {
|
||||
nodes: CanvasNode[];
|
||||
edges: CanvasEdge[];
|
||||
selectedNodeIds: string[];
|
||||
viewport: Viewport;
|
||||
tool: ToolType;
|
||||
}
|
||||
|
||||
// 画布项目
|
||||
export interface CanvasProject {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
canvasData: CanvasState;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// 附件类型
|
||||
export interface Attachment {
|
||||
type: 'image' | 'file';
|
||||
url: string;
|
||||
name?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
// 图片编辑操作
|
||||
export interface ImageEditAction {
|
||||
type: 'rmbg' | 'enhance' | 'extend' | 'resize' | 'angle-edit' | 'scene-fusion' | 'annotate';
|
||||
timestamp: Date;
|
||||
params?: Record<string, any>;
|
||||
resultUrl?: string;
|
||||
}
|
||||
|
||||
// 图片操作菜单项
|
||||
export interface ImageAction {
|
||||
label: string;
|
||||
action: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
api?: string;
|
||||
component?: React.ComponentType<any>;
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
// AI 生成参数
|
||||
export interface GenerationParams {
|
||||
prompt: string;
|
||||
model: string;
|
||||
negativePrompt?: string;
|
||||
size?: string;
|
||||
style?: string;
|
||||
referenceImage?: string;
|
||||
// 图片生成特有
|
||||
aspectRatio?: string;
|
||||
// 视频生成特有
|
||||
duration?: number;
|
||||
fps?: number;
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface CanvasApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user