application [wireframe-renderer-web] view page [pages/canvas] development

This commit is contained in:
NVWA Code Agent
2025-12-17 08:24:57 +00:00
parent f2a98a3881
commit 7304ae0c4b
4 changed files with 1387 additions and 0 deletions

View File

@@ -0,0 +1,546 @@
import { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Upload, Download, Paintbrush, Square, Circle, Minus, Type, Trash2, Play, Save, Folder } from "lucide-react";
import { skill, entities, auth } from "@/lib/nvwa";
type Tool = "pen" | "rectangle" | "circle" | "line" | "text" | "eraser";
interface DrawingElement {
type: Tool;
x: number;
y: number;
width?: number;
height?: number;
x2?: number;
y2?: number;
text?: string;
color?: string;
strokeWidth?: number;
}
export default function CanvasPage() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [tool, setTool] = useState<Tool>("pen");
const [isDrawing, setIsDrawing] = useState(false);
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
const [description, setDescription] = useState("");
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [elements, setElements] = useState<DrawingElement[]>([]);
const [currentProject, setCurrentProject] = useState<any>(null);
const [currentWireframe, setCurrentWireframe] = useState<any>(null);
const [projects, setProjects] = useState<any[]>([]);
const [wireframeName, setWireframeName] = useState("Untitled Wireframe");
const [isSaving, setIsSaving] = useState(false);
// Canvas drawing functions
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setStartPos({ x, y });
setIsDrawing(true);
if (tool === "pen") {
// For pen, we add points continuously
const newElement: DrawingElement = {
type: "pen",
x,
y,
color: "#000000",
strokeWidth: 2
};
setElements(prev => [...prev, newElement]);
}
};
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (tool === "pen") {
// Draw freehand line
ctx.strokeStyle = "#000000";
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(startPos.x, startPos.y);
ctx.lineTo(x, y);
ctx.stroke();
// Update last element with current position
setElements(prev => {
const newElements = [...prev];
const lastElement = newElements[newElements.length - 1];
if (lastElement && lastElement.type === "pen") {
lastElement.x2 = x;
lastElement.y2 = y;
}
return newElements;
});
setStartPos({ x, y });
} else {
// Preview shape while drawing
redrawCanvas();
ctx.strokeStyle = "#000000";
ctx.lineWidth = 2;
switch (tool) {
case "rectangle":
ctx.strokeRect(startPos.x, startPos.y, x - startPos.x, y - startPos.y);
break;
case "circle":
const radius = Math.sqrt(Math.pow(x - startPos.x, 2) + Math.pow(y - startPos.y, 2));
ctx.beginPath();
ctx.arc(startPos.x, startPos.y, radius, 0, 2 * Math.PI);
ctx.stroke();
break;
case "line":
ctx.beginPath();
ctx.moveTo(startPos.x, startPos.y);
ctx.lineTo(x, y);
ctx.stroke();
break;
}
}
};
const stopDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing || !canvasRef.current) return;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (tool !== "pen") {
const newElement: DrawingElement = {
type: tool,
x: startPos.x,
y: startPos.y,
x2: x,
y2: y,
color: "#000000",
strokeWidth: 2
};
setElements(prev => [...prev, newElement]);
}
setIsDrawing(false);
redrawCanvas();
};
const redrawCanvas = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Redraw all elements
elements.forEach(element => {
ctx.strokeStyle = element.color || "#000000";
ctx.lineWidth = element.strokeWidth || 2;
switch (element.type) {
case "rectangle":
if (element.x2 && element.y2) {
ctx.strokeRect(element.x, element.y, element.x2 - element.x, element.y2 - element.y);
}
break;
case "circle":
if (element.x2 && element.y2) {
const radius = Math.sqrt(Math.pow(element.x2 - element.x, 2) + Math.pow(element.y2 - element.y, 2));
ctx.beginPath();
ctx.arc(element.x, element.y, radius, 0, 2 * Math.PI);
ctx.stroke();
}
break;
case "line":
if (element.x2 && element.y2) {
ctx.beginPath();
ctx.moveTo(element.x, element.y);
ctx.lineTo(element.x2, element.y2);
ctx.stroke();
}
break;
}
});
};
useEffect(() => {
redrawCanvas();
}, [elements]);
useEffect(() => {
loadProjectsAndWireframe();
}, []);
const loadProjectsAndWireframe = async () => {
try {
const user = await auth.currentUser();
if (!user) return;
// Load projects
const { data: userProjects } = await entities
.from("nvwa_project")
.select("*")
.eq("user_id", user.id);
if (userProjects && userProjects.length > 0) {
setProjects(userProjects);
// Use first project or create new one
const project = userProjects[0];
setCurrentProject(project);
// Load latest wireframe for this project
const { data: wireframes } = await entities
.from("nvwa_wireframe")
.select("*")
.eq("project_id", project.id)
.order("updated_at", { ascending: false })
.limit(1);
if (wireframes && wireframes.length > 0) {
const wireframe = wireframes[0];
setCurrentWireframe(wireframe);
setWireframeName(wireframe.name);
setDescription(wireframe.description || "");
// Load canvas data
if (wireframe.canvas_data) {
setElements(wireframe.canvas_data);
}
}
} else {
// Create default project
const { data: newProject } = await entities
.from("nvwa_project")
.insert({
name: "Default Project",
description: "Default wireframe project",
user_id: user.id
})
.select()
.single();
if (newProject) {
setCurrentProject(newProject);
setProjects([newProject]);
}
}
} catch (error) {
console.error("Failed to load projects:", error);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (canvas && ctx) {
// Resize canvas to fit image
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
}
};
img.src = event.target?.result as string;
};
reader.readAsDataURL(file);
};
const generateImage = async () => {
if (!canvasRef.current || !description.trim()) return;
// Ensure wireframe is saved before generation
if (!currentWireframe) {
await saveWireframe();
if (!currentWireframe) return; // Still no wireframe, maybe save failed
}
setIsGenerating(true);
try {
// Convert canvas to base64
const canvas = canvasRef.current;
const dataUrl = canvas.toDataURL("image/png");
// Use DoodlePainting skill
const result = await skill.execute("DoodlePainting", {
sketch: dataUrl,
prompt: description,
style: "modern",
structureStrength: 0.8
});
if (result && result.imageUrls && result.imageUrls.length > 0) {
const generatedUrl = result.imageUrls[0];
setGeneratedImageUrl(generatedUrl);
// Save generation to database
await entities
.from("nvwa_generation")
.insert({
wireframe_id: currentWireframe.id,
prompt: description,
status: "completed",
generated_image_url: generatedUrl
});
}
} catch (error) {
console.error("Generation failed:", error);
alert("生成失败,请重试");
// Save failed generation
if (currentWireframe) {
await entities
.from("nvwa_generation")
.insert({
wireframe_id: currentWireframe.id,
prompt: description,
status: "failed",
error_message: error instanceof Error ? error.message : "Unknown error"
});
}
} finally {
setIsGenerating(false);
}
};
const downloadImage = () => {
if (!generatedImageUrl) return;
const link = document.createElement("a");
link.href = generatedImageUrl;
link.download = "wireframe-render.png";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const saveWireframe = async () => {
if (!currentProject) return;
setIsSaving(true);
try {
const canvasData = elements;
if (currentWireframe) {
// Update existing wireframe
await entities
.from("nvwa_wireframe")
.update({
name: wireframeName,
canvas_data: canvasData,
description: description
})
.eq("id", currentWireframe.id);
} else {
// Create new wireframe
const { data: newWireframe } = await entities
.from("nvwa_wireframe")
.insert({
project_id: currentProject.id,
name: wireframeName,
canvas_data: canvasData,
description: description
})
.select()
.single();
if (newWireframe) {
setCurrentWireframe(newWireframe);
}
}
} catch (error) {
console.error("Failed to save wireframe:", error);
alert("保存失败,请重试");
} finally {
setIsSaving(false);
}
};
const clearCanvas = () => {
setElements([]);
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (canvas && ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
};
return (
<div className="flex h-screen bg-background">
{/* Toolbar */}
<div className="w-16 bg-muted p-2 flex flex-col gap-2">
<Button
variant={tool === "pen" ? "default" : "ghost"}
size="sm"
onClick={() => setTool("pen")}
title="Pen"
>
<Paintbrush className="w-4 h-4" />
</Button>
<Button
variant={tool === "rectangle" ? "default" : "ghost"}
size="sm"
onClick={() => setTool("rectangle")}
title="Rectangle"
>
<Square className="w-4 h-4" />
</Button>
<Button
variant={tool === "circle" ? "default" : "ghost"}
size="sm"
onClick={() => setTool("circle")}
title="Circle"
>
<Circle className="w-4 h-4" />
</Button>
<Button
variant={tool === "line" ? "default" : "ghost"}
size="sm"
onClick={() => setTool("line")}
title="Line"
>
<Minus className="w-4 h-4" />
</Button>
<Separator />
<Button
variant="ghost"
size="sm"
onClick={clearCanvas}
title="Clear"
>
<Trash2 className="w-4 h-4" />
</Button>
<Label htmlFor="file-upload" className="cursor-pointer">
<Button variant="ghost" size="sm" asChild>
<span title="Upload">
<Upload className="w-4 h-4" />
</span>
</Button>
</Label>
<Input
id="file-upload"
type="file"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
/>
</div>
{/* Canvas */}
<div className="flex-1 p-4">
<canvas
ref={canvasRef}
width={800}
height={600}
className="border border-border bg-white cursor-crosshair"
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
/>
</div>
{/* Sidebar */}
<div className="w-80 bg-muted p-4 flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>Project & Wireframe</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div>
<Label htmlFor="project-name">Project</Label>
<Input
id="project-name"
value={currentProject?.name || ""}
disabled
placeholder="No project selected"
/>
</div>
<div>
<Label htmlFor="wireframe-name">Wireframe Name</Label>
<Input
id="wireframe-name"
value={wireframeName}
onChange={(e) => setWireframeName(e.target.value)}
placeholder="Wireframe name"
/>
</div>
<Button onClick={saveWireframe} disabled={isSaving} size="sm">
<Save className="w-4 h-4 mr-2" />
{isSaving ? "Saving..." : "Save"}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Description</CardTitle>
</CardHeader>
<CardContent>
<Textarea
placeholder="Describe your wireframe design..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</CardContent>
</Card>
<Button
onClick={generateImage}
disabled={isGenerating || !description.trim()}
className="w-full"
>
<Play className="w-4 h-4 mr-2" />
{isGenerating ? "Generating..." : "Generate Image"}
</Button>
{generatedImageUrl && (
<Card>
<CardHeader>
<CardTitle>Generated Image</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<img
src={generatedImageUrl}
alt="Generated"
className="w-full rounded border"
/>
<Button onClick={downloadImage} variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" />
Download
</Button>
</CardContent>
</Card>
)}
</div>
</div>
);
}