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>
|
<div>
|
||||||
<CardTitle>存储配置</CardTitle>
|
<CardTitle>存储配置</CardTitle>
|
||||||
<CardDescription>配置 S3 兼容对象存储</CardDescription>
|
<CardDescription>配置 S3 兼容对象存储</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={showStorageDialog} onOpenChange={setShowStorageDialog}>
|
<Dialog open={showStorageDialog} onOpenChange={setShowStorageDialog}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button onClick={() => { resetStorageForm(); setShowStorageDialog(true); }}>
|
<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 { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
@ -254,6 +254,19 @@ export function TopNavbar({
|
|||||||
<span className="text-sm font-medium">图片编辑</span>
|
<span className="text-sm font-medium">图片编辑</span>
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="ghost"
|
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,
|
"when": 1765109627149,
|
||||||
"tag": "0027_add_default_theme",
|
"tag": "0027_add_default_theme",
|
||||||
"breakpoints": true
|
"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 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",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"framer-motion": "^11.3.19",
|
"framer-motion": "^11.18.2",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
|
"immer": "^11.1.3",
|
||||||
"katex": "^0.16.25",
|
"katex": "^0.16.25",
|
||||||
"lucide-react": "^0.446.0",
|
"lucide-react": "^0.446.0",
|
||||||
"matter-js": "^0.20.0",
|
"matter-js": "^0.20.0",
|
||||||
@ -89,6 +90,7 @@
|
|||||||
"react-data-grid": "7.0.0-beta.47",
|
"react-data-grid": "7.0.0-beta.47",
|
||||||
"react-dom": "19.0.0-rc-45804af1-20241021",
|
"react-dom": "19.0.0-rc-45804af1-20241021",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
|
"react-rnd": "^10.5.2",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
"redis": "^5.0.0",
|
"redis": "^5.0.0",
|
||||||
"resumable-stream": "^2.0.0",
|
"resumable-stream": "^2.0.0",
|
||||||
@ -105,7 +107,8 @@
|
|||||||
"tokenlens": "1.3.0",
|
"tokenlens": "1.3.0",
|
||||||
"use-stick-to-bottom": "^1.1.1",
|
"use-stick-to-bottom": "^1.1.1",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76",
|
||||||
|
"zustand": "5.0.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.2",
|
"@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)
|
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':
|
'@react-three/drei':
|
||||||
specifier: ^10.7.7
|
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':
|
'@react-three/fiber':
|
||||||
specifier: ^9.5.0
|
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':
|
'@types/form-data':
|
||||||
specifier: ^2.5.2
|
specifier: ^2.5.2
|
||||||
version: 2.5.2
|
version: 2.5.2
|
||||||
@ -147,11 +147,14 @@ importers:
|
|||||||
specifier: ^4.0.5
|
specifier: ^4.0.5
|
||||||
version: 4.0.5
|
version: 4.0.5
|
||||||
framer-motion:
|
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)
|
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:
|
geist:
|
||||||
specifier: ^1.3.1
|
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))
|
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:
|
katex:
|
||||||
specifier: ^0.16.25
|
specifier: ^0.16.25
|
||||||
version: 0.16.25
|
version: 0.16.25
|
||||||
@ -221,6 +224,9 @@ importers:
|
|||||||
react-resizable-panels:
|
react-resizable-panels:
|
||||||
specifier: ^2.1.7
|
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)
|
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:
|
react-syntax-highlighter:
|
||||||
specifier: ^15.6.6
|
specifier: ^15.6.6
|
||||||
version: 15.6.6(react@19.0.0-rc-45804af1-20241021)
|
version: 15.6.6(react@19.0.0-rc-45804af1-20241021)
|
||||||
@ -272,6 +278,9 @@ importers:
|
|||||||
zod:
|
zod:
|
||||||
specifier: ^3.25.76
|
specifier: ^3.25.76
|
||||||
version: 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:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.2.2
|
specifier: 2.2.2
|
||||||
@ -3079,6 +3088,7 @@ packages:
|
|||||||
'@vercel/postgres@0.10.0':
|
'@vercel/postgres@0.10.0':
|
||||||
resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==}
|
resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==}
|
||||||
engines: {node: '>=18.14'}
|
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':
|
'@vitest/expect@3.2.4':
|
||||||
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
|
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
|
||||||
@ -3260,6 +3270,10 @@ packages:
|
|||||||
client-only@0.0.1:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
|
clsx@1.2.1:
|
||||||
|
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
clsx@2.1.1:
|
clsx@2.1.1:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -3935,6 +3949,9 @@ packages:
|
|||||||
immediate@3.0.6:
|
immediate@3.0.6:
|
||||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||||
|
|
||||||
|
immer@11.1.3:
|
||||||
|
resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==}
|
||||||
|
|
||||||
import-in-the-middle@1.15.0:
|
import-in-the-middle@1.15.0:
|
||||||
resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==}
|
resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==}
|
||||||
|
|
||||||
@ -4002,6 +4019,9 @@ packages:
|
|||||||
jose@6.1.3:
|
jose@6.1.3:
|
||||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||||
|
|
||||||
|
js-tokens@4.0.0:
|
||||||
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
js-tokens@9.0.1:
|
js-tokens@9.0.1:
|
||||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||||
|
|
||||||
@ -4132,6 +4152,10 @@ packages:
|
|||||||
longest-streak@3.1.0:
|
longest-streak@3.1.0:
|
||||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
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:
|
loupe@3.2.1:
|
||||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
@ -4418,6 +4442,10 @@ packages:
|
|||||||
oauth4webapi@3.3.1:
|
oauth4webapi@3.3.1:
|
||||||
resolution: {integrity: sha512-ZwX7UqYrP3Lr+Glhca3a1/nF2jqf7VVyJfhGuW5JtrfDUxt0u+IoBPzFjZ2dd7PJGkdM6CFPVVYzuDYKHv101A==}
|
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:
|
obuf@1.1.2:
|
||||||
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
|
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
|
||||||
|
|
||||||
@ -4587,6 +4615,9 @@ packages:
|
|||||||
promise-worker-transferable@1.0.4:
|
promise-worker-transferable@1.0.4:
|
||||||
resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==}
|
resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==}
|
||||||
|
|
||||||
|
prop-types@15.8.1:
|
||||||
|
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||||
|
|
||||||
property-information@5.6.0:
|
property-information@5.6.0:
|
||||||
resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==}
|
resolution: {integrity: sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==}
|
||||||
|
|
||||||
@ -4665,6 +4696,12 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
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:
|
react-data-grid@7.0.0-beta.47:
|
||||||
resolution: {integrity: sha512-28kjsmwQGD/9RXYC50zn5Zv/SQMhBBoSvG5seq0fM8XXi9TZ0zr9Z5T3YJqLwcEtoNzTOq3y0njkmdujGkIwQQ==}
|
resolution: {integrity: sha512-28kjsmwQGD/9RXYC50zn5Zv/SQMhBBoSvG5seq0fM8XXi9TZ0zr9Z5T3YJqLwcEtoNzTOq3y0njkmdujGkIwQQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -4676,6 +4713,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: 19.0.0-rc-45804af1-20241021
|
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:
|
react-markdown@10.1.0:
|
||||||
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
|
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -4708,6 +4754,12 @@ packages:
|
|||||||
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
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-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:
|
react-style-singleton@2.2.3:
|
||||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -5050,6 +5102,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
|
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
|
||||||
engines: {node: '>=6.10'}
|
engines: {node: '>=6.10'}
|
||||||
|
|
||||||
|
tslib@2.6.2:
|
||||||
|
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@ -5330,8 +5385,8 @@ packages:
|
|||||||
react:
|
react:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
zustand@5.0.9:
|
zustand@5.0.4:
|
||||||
resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==}
|
resolution: {integrity: sha512-39VFTN5InDtMd28ZhjLyuTnlytDr9HfwO512Ai4I8ZABCoyAj4F1+sr7sD1jP/+p7k77Iko0Pb5NhgBFDCX0kQ==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': '>=18.0.0'
|
'@types/react': '>=18.0.0'
|
||||||
@ -7407,12 +7462,12 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/rect@1.1.1': {}
|
'@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:
|
dependencies:
|
||||||
'@babel/runtime': 7.28.4
|
'@babel/runtime': 7.28.4
|
||||||
'@mediapipe/tasks-vision': 0.10.17
|
'@mediapipe/tasks-vision': 0.10.17
|
||||||
'@monogrid/gainmap-js': 3.4.0(three@0.182.0)
|
'@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)
|
'@use-gesture/react': 10.3.1(react@19.0.0-rc-45804af1-20241021)
|
||||||
camera-controls: 3.1.2(three@0.182.0)
|
camera-controls: 3.1.2(three@0.182.0)
|
||||||
cross-env: 7.0.3
|
cross-env: 7.0.3
|
||||||
@ -7429,10 +7484,10 @@ snapshots:
|
|||||||
three-mesh-bvh: 0.8.3(three@0.182.0)
|
three-mesh-bvh: 0.8.3(three@0.182.0)
|
||||||
three-stdlib: 2.36.1(three@0.182.0)
|
three-stdlib: 2.36.1(three@0.182.0)
|
||||||
troika-three-text: 0.52.4(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)
|
use-sync-external-store: 1.6.0(react@19.0.0-rc-45804af1-20241021)
|
||||||
utility-types: 3.11.0
|
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:
|
optionalDependencies:
|
||||||
react-dom: 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)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@ -7440,7 +7495,7 @@ snapshots:
|
|||||||
- '@types/three'
|
- '@types/three'
|
||||||
- immer
|
- 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:
|
dependencies:
|
||||||
'@babel/runtime': 7.28.4
|
'@babel/runtime': 7.28.4
|
||||||
'@types/webxr': 0.5.24
|
'@types/webxr': 0.5.24
|
||||||
@ -7453,7 +7508,7 @@ snapshots:
|
|||||||
suspend-react: 0.1.3(react@19.0.0-rc-45804af1-20241021)
|
suspend-react: 0.1.3(react@19.0.0-rc-45804af1-20241021)
|
||||||
three: 0.182.0
|
three: 0.182.0
|
||||||
use-sync-external-store: 1.6.0(react@19.0.0-rc-45804af1-20241021)
|
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:
|
optionalDependencies:
|
||||||
react-dom: 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)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@ -8497,6 +8552,8 @@ snapshots:
|
|||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
|
clsx@1.2.1: {}
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
cluster-key-slot@1.1.2: {}
|
cluster-key-slot@1.1.2: {}
|
||||||
@ -9203,6 +9260,8 @@ snapshots:
|
|||||||
|
|
||||||
immediate@3.0.6: {}
|
immediate@3.0.6: {}
|
||||||
|
|
||||||
|
immer@11.1.3: {}
|
||||||
|
|
||||||
import-in-the-middle@1.15.0:
|
import-in-the-middle@1.15.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
@ -9261,6 +9320,8 @@ snapshots:
|
|||||||
|
|
||||||
jose@6.1.3: {}
|
jose@6.1.3: {}
|
||||||
|
|
||||||
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-tokens@9.0.1: {}
|
js-tokens@9.0.1: {}
|
||||||
|
|
||||||
json-schema@0.4.0: {}
|
json-schema@0.4.0: {}
|
||||||
@ -9364,6 +9425,10 @@ snapshots:
|
|||||||
|
|
||||||
longest-streak@3.1.0: {}
|
longest-streak@3.1.0: {}
|
||||||
|
|
||||||
|
loose-envify@1.4.0:
|
||||||
|
dependencies:
|
||||||
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
loupe@3.2.1: {}
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
lowlight@1.20.0:
|
lowlight@1.20.0:
|
||||||
@ -9883,6 +9948,8 @@ snapshots:
|
|||||||
|
|
||||||
oauth4webapi@3.3.1: {}
|
oauth4webapi@3.3.1: {}
|
||||||
|
|
||||||
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
obuf@1.1.2:
|
obuf@1.1.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -10067,6 +10134,12 @@ snapshots:
|
|||||||
is-promise: 2.2.2
|
is-promise: 2.2.2
|
||||||
lie: 3.3.0
|
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:
|
property-information@5.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xtend: 4.0.2
|
xtend: 4.0.2
|
||||||
@ -10249,6 +10322,11 @@ snapshots:
|
|||||||
'@types/react': 18.3.18
|
'@types/react': 18.3.18
|
||||||
'@types/react-dom': 18.3.5(@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):
|
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:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
@ -10260,6 +10338,15 @@ snapshots:
|
|||||||
react: 19.0.0-rc-45804af1-20241021
|
react: 19.0.0-rc-45804af1-20241021
|
||||||
scheduler: 0.25.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):
|
react-markdown@10.1.0(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
@ -10302,6 +10389,14 @@ snapshots:
|
|||||||
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-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):
|
react-style-singleton@2.2.3(@types/react@18.3.18)(react@19.0.0-rc-45804af1-20241021):
|
||||||
dependencies:
|
dependencies:
|
||||||
get-nonce: 1.0.1
|
get-nonce: 1.0.1
|
||||||
@ -10716,6 +10811,8 @@ snapshots:
|
|||||||
|
|
||||||
ts-dedent@2.2.0: {}
|
ts-dedent@2.2.0: {}
|
||||||
|
|
||||||
|
tslib@2.6.2: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
tsx@4.19.3:
|
tsx@4.19.3:
|
||||||
@ -10725,9 +10822,9 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
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:
|
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:
|
transitivePeerDependencies:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
- immer
|
- immer
|
||||||
@ -11005,16 +11102,18 @@ snapshots:
|
|||||||
|
|
||||||
zod@4.1.12: {}
|
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:
|
dependencies:
|
||||||
use-sync-external-store: 1.6.0(react@19.0.0-rc-45804af1-20241021)
|
use-sync-external-store: 1.6.0(react@19.0.0-rc-45804af1-20241021)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.18
|
'@types/react': 18.3.18
|
||||||
|
immer: 11.1.3
|
||||||
react: 19.0.0-rc-45804af1-20241021
|
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:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.18
|
'@types/react': 18.3.18
|
||||||
|
immer: 11.1.3
|
||||||
react: 19.0.0-rc-45804af1-20241021
|
react: 19.0.0-rc-45804af1-20241021
|
||||||
use-sync-external-store: 1.6.0(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