Spaces:
Sleeping
Sleeping
| 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; | |