VirtualLabo / src /components /lab /FabricLabCanvas.tsx
rinogeek's picture
Initial commit: Virtual Labo Chimique - Docker deployment
538d81e
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from "react";
import { Canvas as FabricCanvas, FabricObject, Rect, Circle, Textbox } from "fabric";
import * as fabric from "fabric";
import { substances } from "@/data/substances";
import { toast } from "sonner";
import { Trash2, RotateCcw, Thermometer, Download, Maximize } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import TextAnnotation from "./TextAnnotation";
interface FabricLabCanvasProps {
selectedItem: string | null;
onMix: (substanceIds: string[]) => void;
canvasData: string | null;
onCanvasChange: (data: string) => void;
temperatureC: number;
onTemperatureChange: (value: number) => void;
}
const FabricLabCanvas = forwardRef<
{ getSnapshot: () => string | null; loadSnapshot: (data: string) => void },
FabricLabCanvasProps
>(({ selectedItem, onMix, canvasData, onCanvasChange, temperatureC, onTemperatureChange }, ref) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [fabricCanvas, setFabricCanvas] = useState<FabricCanvas | null>(null);
const [selectedObject, setSelectedObject] = useState<FabricObject | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const isSavingRef = useRef(false);
const containerRef = useRef<HTMLDivElement>(null);
// Expose methods to parent via ref
useImperativeHandle(ref, () => ({
getSnapshot: () => {
if (!fabricCanvas) return null;
return JSON.stringify(fabricCanvas.toJSON(["substanceId"] as any));
},
loadSnapshot: (data: string) => {
if (!fabricCanvas) return;
try {
const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
isSavingRef.current = true;
fabricCanvas.loadFromJSON(parsedData).then(() => {
fabricCanvas.renderAll();
isSavingRef.current = false;
}).catch(() => {
isSavingRef.current = false;
});
} catch (error) {
console.error("Error loading snapshot:", error);
}
}
}));
// Convert Tailwind color classes to HSL
const getHSLFromTailwind = (colorClass: string): string => {
const colorMap: Record<string, string> = {
'text-red-600': '0 84% 45%',
'text-red-700': '0 74% 42%',
'text-red-500': '0 84% 60%',
'text-yellow-700': '43 96% 35%',
'text-yellow-600': '38 92% 50%',
'text-orange-600': '27 96% 51%',
'text-orange-700': '16 90% 48%',
'text-blue-600': '221 83% 53%',
'text-blue-700': '224 76% 48%',
'text-blue-500': '217 91% 60%',
'text-cyan-600': '188 94% 37%',
'text-cyan-500': '189 94% 43%',
'text-gray-600': '220 9% 46%',
'text-gray-500': '220 9% 64%',
'text-gray-400': '220 9% 82%',
'text-stone-600': '25 5% 45%',
'text-stone-700': '24 6% 38%',
'text-slate-700': '215 16% 47%',
'text-slate-600': '215 14% 34%',
'text-slate-400': '215 16% 77%',
'text-pink-600': '328 86% 70%',
'text-purple-600': '271 81% 56%',
'text-amber-600': '32 95% 44%',
'text-amber-500': '38 92% 50%',
'text-sky-600': '200 98% 39%',
'text-sky-400': '199 89% 48%',
'text-lime-600': '78 61% 40%',
'text-indigo-400': '243 75% 69%',
};
return colorMap[colorClass] || '220 15% 30%';
};
// Initialize canvas
useEffect(() => {
if (!canvasRef.current) return;
const canvas = new FabricCanvas(canvasRef.current, {
width: 800,
height: 500,
backgroundColor: "hsl(220, 20%, 98%)",
preserveObjectStacking: true,
});
// Grid background
const gridSize = 40;
for (let i = 0; i < canvas.width! / gridSize; i++) {
canvas.add(
new Rect({
left: i * gridSize,
top: 0,
width: 1,
height: canvas.height!,
fill: "hsl(220, 15%, 88%)",
selectable: false,
evented: false,
opacity: 0.3,
})
);
}
for (let i = 0; i < canvas.height! / gridSize; i++) {
canvas.add(
new Rect({
left: 0,
top: i * gridSize,
width: canvas.width!,
height: 1,
fill: "hsl(220, 15%, 88%)",
selectable: false,
evented: false,
opacity: 0.3,
})
);
}
// Load saved canvas data if available
if (canvasData) {
try {
canvas.loadFromJSON(canvasData, () => {
canvas.renderAll();
// Migration: older saved canvases didn't serialize substanceId.
const changed = hydrateSubstanceIdsFromLabels(canvas);
if (changed) {
const json = JSON.stringify(canvas.toJSON(["substanceId"] as any));
onCanvasChange(json);
}
});
} catch (error) {
console.error("Error loading canvas data:", error);
}
}
setFabricCanvas(canvas);
// Selection events
canvas.on("selection:created", (e) => {
setSelectedObject(e.selected?.[0] || null);
});
canvas.on("selection:updated", (e) => {
setSelectedObject(e.selected?.[0] || null);
});
canvas.on("selection:cleared", () => {
setSelectedObject(null);
});
// Save canvas state on modifications
canvas.on("object:modified", () => {
if (!isSavingRef.current) saveCanvasState(canvas);
});
canvas.on("object:added", () => {
if (!isSavingRef.current) saveCanvasState(canvas);
});
canvas.on("object:removed", () => {
if (!isSavingRef.current) saveCanvasState(canvas);
});
return () => {
canvas.dispose();
};
}, []);
const saveCanvasState = (canvas: FabricCanvas) => {
isSavingRef.current = true;
const json = JSON.stringify(canvas.toJSON(["substanceId"] as any));
onCanvasChange(json);
setTimeout(() => {
isSavingRef.current = false;
}, 100);
};
// Reload canvas when canvasData changes (for undo/redo)
useEffect(() => {
if (!fabricCanvas || !canvasData || isSavingRef.current) return;
try {
isSavingRef.current = true;
fabricCanvas.loadFromJSON(canvasData, () => {
fabricCanvas.renderAll();
const changed = hydrateSubstanceIdsFromLabels(fabricCanvas);
isSavingRef.current = false;
if (changed) saveCanvasState(fabricCanvas);
});
} catch (error) {
console.error("Error loading canvas data:", error);
isSavingRef.current = false;
}
}, [canvasData, fabricCanvas]);
const getSubstanceIdsOnCanvas = (canvas: FabricCanvas) => {
const ids = new Set<string>();
canvas.getObjects().forEach((obj: any) => {
const id = obj?.substanceId ?? obj?.get?.("substanceId");
if (typeof id === "string" && id) ids.add(id);
});
return Array.from(ids);
};
const hydrateSubstanceIdsFromLabels = (canvas: FabricCanvas) => {
const selectableRects = canvas.getObjects().filter((obj: any) => obj instanceof Rect && obj.selectable);
const formulaLabels = canvas.getObjects().filter((obj: any) => {
return (
obj instanceof Textbox &&
obj.selectable === false &&
obj.evented === false &&
obj.fontFamily === "monospace" &&
obj.fontSize === 18
);
}) as any[];
const formulaToId = new Map<string, string>();
substances.forEach((s) => formulaToId.set(s.formula, s.id));
let changed = false;
for (const label of formulaLabels) {
const formula = typeof label.text === "string" ? label.text.trim() : "";
const id = formulaToId.get(formula);
if (!id) continue;
const nearest = selectableRects
.filter((r: any) => !(r.substanceId ?? r.get?.("substanceId")))
.map((r: any) => {
const rx = (r.left ?? 0) - (label.left ?? 0);
const ry = (r.top ?? 0) - (label.top ?? 0);
// Prefer containers slightly above the label; penalize far ones.
const score = Math.abs(rx) + Math.abs(ry + 110);
return { r, score };
})
.sort((a, b) => a.score - b.score)[0]?.r;
if (nearest) {
nearest.set?.("substanceId", id);
(nearest as any).substanceId = id;
changed = true;
}
}
return changed;
};
// Helper to create simple container shape
const createContainer = (substance: any, left: number, top: number) => {
const colorMatch = substance.color.match(/\d+/g);
const h = colorMatch?.[0] || "200";
const s = colorMatch?.[1] || "70";
const l = colorMatch?.[2] || "50";
// Simple rounded rectangle with gradient
const container = new Rect({
left: left,
top: top,
width: 80,
height: 100,
fill: new fabric.Gradient({
type: "linear",
coords: { x1: 0, y1: 0, x2: 0, y2: 100 },
colorStops: [
{ offset: 0, color: `hsla(${h}, ${s}%, ${parseInt(l) + 10}%, 0.7)` },
{ offset: 1, color: `hsla(${h}, ${s}%, ${parseInt(l) - 10}%, 0.9)` },
],
}),
stroke: `hsl(${h}, ${s}%, ${l}%)`,
strokeWidth: 3,
rx: 10,
ry: 10,
selectable: true,
shadow: new fabric.Shadow({
color: "rgba(0,0,0,0.2)",
blur: 15,
offsetX: 3,
offsetY: 3,
}),
});
container.set("substanceId", substance.id);
return { main: container, width: 80, height: 100 };
};
// Add substance to canvas
const addSubstance = (substanceId: string) => {
if (!fabricCanvas) return;
const substance = substances.find((s) => s.id === substanceId);
if (!substance) return;
const left = 20;
const top = 20;
// Batch adds (container + labels) into a single history state.
isSavingRef.current = true;
const container = createContainer(substance, left, top);
// Add container to canvas
fabricCanvas.add(container.main);
// Create labels with proper HSL colors
const formulaColor = getHSLFromTailwind(substance.color);
const label = new Textbox(substance.formula, {
left: left + container.width / 2,
top: top + container.height + 5,
width: container.width + 20,
fontSize: 18,
fontWeight: "bold",
fontFamily: "monospace",
fill: `hsl(${formulaColor})`,
textAlign: "center",
selectable: false,
evented: false,
});
const nameLabel = new Textbox(substance.name, {
left: left + container.width / 2,
top: top + container.height + 28,
width: container.width + 20,
fontSize: 10,
fontWeight: "600",
fontFamily: "sans-serif",
fill: "hsl(220, 15%, 20%)",
textAlign: "center",
selectable: false,
evented: false,
});
fabricCanvas.add(label);
fabricCanvas.add(nameLabel);
// Link labels to main container movement
container.main.on("moving", () => {
label.set({
left: container.main.left! + container.width / 2,
top: container.main.top! + container.height + 5,
});
nameLabel.set({
left: container.main.left! + container.width / 2,
top: container.main.top! + container.height + 28,
});
});
fabricCanvas.setActiveObject(container.main);
fabricCanvas.renderAll();
isSavingRef.current = false;
saveCanvasState(fabricCanvas);
// Consider it a "new mix" when at least 2 distinct substances are present.
const ids = getSubstanceIdsOnCanvas(fabricCanvas);
if (ids.length >= 2) onMix(ids);
toast.success(`${substance.name} ajouté au plan de travail`);
};
// Handle drop from inventory
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.key === "Delete" || e.key === "Backspace") && selectedObject) {
handleDeleteSelected();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedObject, fabricCanvas]);
const handleDeleteSelected = () => {
if (!fabricCanvas || !selectedObject) return;
// Find and remove associated objects (all parts of the container + labels)
const objects = fabricCanvas.getObjects();
const toRemove = [selectedObject];
objects.forEach((obj) => {
if (
(obj instanceof Textbox || obj instanceof Circle || obj instanceof Rect) &&
Math.abs(obj.left! - selectedObject.left!) < 200 &&
Math.abs(obj.top! - selectedObject.top!) < 200 &&
obj !== selectedObject
) {
toRemove.push(obj);
}
});
isSavingRef.current = true;
toRemove.forEach((obj) => fabricCanvas.remove(obj));
fabricCanvas.discardActiveObject();
fabricCanvas.renderAll();
setSelectedObject(null);
isSavingRef.current = false;
saveCanvasState(fabricCanvas);
toast.info("Objet supprimé");
};
const handleClearCanvas = () => {
if (!fabricCanvas) return;
isSavingRef.current = true;
fabricCanvas.clear();
fabricCanvas.backgroundColor = "hsl(220, 20%, 98%)";
// Re-add grid
const gridSize = 40;
for (let i = 0; i < fabricCanvas.width! / gridSize; i++) {
fabricCanvas.add(
new Rect({
left: i * gridSize,
top: 0,
width: 1,
height: fabricCanvas.height!,
fill: "hsl(220, 15%, 88%)",
selectable: false,
evented: false,
opacity: 0.3,
})
);
}
for (let i = 0; i < fabricCanvas.height! / gridSize; i++) {
fabricCanvas.add(
new Rect({
left: 0,
top: i * gridSize,
width: fabricCanvas.width!,
height: 1,
fill: "hsl(220, 15%, 88%)",
selectable: false,
evented: false,
opacity: 0.3,
})
);
}
fabricCanvas.renderAll();
isSavingRef.current = false;
saveCanvasState(fabricCanvas);
toast.success("Plan de travail réinitialisé");
};
const handleExportPNG = () => {
if (!fabricCanvas) return;
const dataURL = fabricCanvas.toDataURL({
format: 'png',
quality: 1,
multiplier: 2, // Higher resolution
});
const link = document.createElement('a');
link.download = `plan-de-travail-${Date.now()}.png`;
link.href = dataURL;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success("Image PNG exportée");
};
const handleExportSVG = () => {
if (!fabricCanvas) return;
const svg = fabricCanvas.toSVG();
const blob = new Blob([svg], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = `plan-de-travail-${Date.now()}.svg`;
link.href = url;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Image SVG exportée");
};
const handleToggleFullscreen = () => {
if (!containerRef.current) return;
if (!document.fullscreenElement) {
containerRef.current.requestFullscreen().then(() => {
setIsFullscreen(true);
toast.success("Mode plein écran activé");
}).catch((err) => {
toast.error("Impossible d'activer le plein écran");
});
} else {
document.exitFullscreen().then(() => {
setIsFullscreen(false);
toast.success("Mode plein écran désactivé");
});
}
};
// Listen for fullscreen changes
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
const handleAddTextNote = (text: string) => {
if (!fabricCanvas) return;
isSavingRef.current = true;
const textBox = new Textbox(text, {
left: 100,
top: 100,
width: 200,
fontSize: 14,
fill: '#1a1a1a',
backgroundColor: '#fef3c7',
padding: 10,
borderColor: '#f59e0b',
cornerColor: '#f59e0b',
cornerStyle: 'circle',
transparentCorners: false,
});
fabricCanvas.add(textBox);
fabricCanvas.setActiveObject(textBox);
fabricCanvas.renderAll();
isSavingRef.current = false;
saveCanvasState(fabricCanvas);
toast.success("Note ajoutée");
};
return (
<div ref={containerRef} className="relative">
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2 mb-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDeleteSelected}
disabled={!selectedObject}
className="h-8"
>
<Trash2 className="w-3.5 h-3.5 mr-1.5" />
<span className="text-xs">Supprimer</span>
</Button>
<Button variant="outline" size="sm" onClick={handleClearCanvas} className="h-8">
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
<span className="text-xs">Réinitialiser</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Download className="w-3.5 h-3.5 mr-1.5" />
<span className="text-xs">Exporter</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={handleExportPNG}>
<Download className="w-4 h-4 mr-2" />
Exporter en PNG
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportSVG}>
<Download className="w-4 h-4 mr-2" />
Exporter en SVG
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={handleToggleFullscreen} className="h-8">
<Maximize className="w-3.5 h-3.5 mr-1.5" />
<span className="text-xs">{isFullscreen ? "Quitter" : "Plein écran"}</span>
</Button>
<TextAnnotation onAdd={handleAddTextNote} />
</div>
{/* Temperature Control */}
<div className="flex items-center gap-2 bg-card border border-border rounded-lg px-2.5 py-1 shadow-sm h-8">
<Thermometer className="w-3.5 h-3.5 text-primary" />
<Slider
value={[temperatureC]}
onValueChange={(v) => onTemperatureChange(v[0])}
min={-50}
max={200}
step={5}
className="w-20"
/>
<span className="text-xs font-semibold text-foreground min-w-[40px] tabular-nums">
{temperatureC}°C
</span>
</div>
{selectedItem && (
<Button
onClick={() => addSubstance(selectedItem)}
size="sm"
className="shadow-float animate-float h-8 ml-auto"
>
<span className="text-xs">+ Ajouter</span>
</Button>
)}
</div>
{/* Canvas */}
<div className="border-2 border-border rounded-xl overflow-hidden shadow-elegant bg-lab-surface">
<canvas ref={canvasRef} />
</div>
{/* Instructions */}
<div className="mt-3 text-sm text-muted-foreground flex items-center justify-center gap-6">
<span>🖱️ Glisser pour déplacer</span>
<span>🔄 Coins pour redimensionner</span>
<span>⌫ Suppr pour effacer</span>
</div>
</div>
);
});
export default FabricLabCanvas;