diff --git a/client/src/components/Canvas.jsx b/client/src/components/Canvas.jsx index 10b2e93..62abed2 100644 --- a/client/src/components/Canvas.jsx +++ b/client/src/components/Canvas.jsx @@ -15,6 +15,7 @@ const SHAPE_TYPE = { PEN: "pen", CIRCLE: "circle", ERASER: "eraser", + IMAGE: 'image', }; export const Canvas = () => { @@ -52,35 +53,32 @@ export const Canvas = () => { const [hoveredHandle, setHoveredHandle] = useState(null); // { id, dir } const handleLogout = async () => { - try { - const token = localStorage.getItem("token"); - if (!token) { - toast.error("You are not logged in."); - return; - } + // placeholder + }; - const res = await fetch("http://localhost:3000/api/auth/logout", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token }), - }); +const handleImageUpload = (file) => { + if (!file) return; + const reader = new FileReader(); + reader.onload = (event) => { + const img = new Image(); + img.onload = () => { + const newShape = { + id: Date.now().toString(), + type: SHAPE_TYPE.IMAGE, + image: img, + start: { x: 100, y: 100 }, + end: { x: 100 + img.width, y: 100 + img.height }, + width: img.width, + height: img.height, + }; + setShapes((prev) => [...prev, newShape]); + }; + img.src = event.target.result; + }; + reader.readAsDataURL(file); +}; - if (!res.ok) { - const data = await res.json().catch(() => ({})); - toast.error(data.message || "Logout failed"); - return; - } - // Clear auth state and redirect - localStorage.removeItem("token"); - setIsLoggedIn(false); - toast.success("Logged out successfully"); - window.location.href = "/"; - } catch (err) { - console.error("Logout error:", err); - toast.error("Logout failed. Please try again."); - } - }; // --- Helpers --- const getWorldPoint = (e) => { @@ -136,6 +134,14 @@ export const Canvas = () => { const maxY = Math.max(...shape.path.map(p => p.y)); return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }; } + if (shape.type === SHAPE_TYPE.IMAGE) { + const minX = Math.min(shape.start.x, shape.end.x); + const maxX = Math.max(shape.start.x, shape.end.x); + const minY = Math.min(shape.start.y, shape.end.y); + const maxY = Math.max(shape.start.y, shape.end.y); + return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }; +} + return null; }, []); @@ -192,7 +198,7 @@ export const Canvas = () => { if (isSelected) { // draw purple glow under the stroke for visibility ctx.save(); - ctx.lineWidth = shape.width + 4; + ctx.lineWidth = shape.type === SHAPE_TYPE.IMAGE ? 4 : shape.width + 4; ctx.strokeStyle = "rgba(76,29,149,1)"; switch (shape.type) { case SHAPE_TYPE.LINE: @@ -245,7 +251,7 @@ export const Canvas = () => { // then draw the actual shape on top ctx.strokeStyle = shape.color; - ctx.lineWidth = shape.width; + ctx.lineWidth = shape.type === SHAPE_TYPE.IMAGE ? 1 : shape.width; } switch (shape.type) { @@ -517,6 +523,15 @@ export const Canvas = () => { ctx.globalAlpha = 1; } break; + case SHAPE_TYPE.IMAGE: { + const { image, start, end } = shape; + if (image) { + const width = end.x - start.x; + const height = end.y - start.y; + ctx.drawImage(image, start.x, start.y, width, height); + } + break; + } default: break; } @@ -547,12 +562,15 @@ export const Canvas = () => { const ctx = canvas?.getContext("2d"); if (!ctx) return; - // clear and reset transform - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.clearRect(0, 0, canvas.width, canvas.height); + // clear and reset transform + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 1; + ctx.shadowBlur = 0; - // apply pan & zoom - ctx.setTransform(1, 0, 0, 1, offset.x, offset.y); + // apply pan & zoom + ctx.setTransform(1, 0, 0, 1, offset.x, offset.y); ctx.scale(scale, scale); // draw non-selected shapes first @@ -622,6 +640,7 @@ export const Canvas = () => { if (handle && hitShape && hitShape.id === selectedShapeId) { // begin resize const origShape = JSON.parse(JSON.stringify(hitShape)); + if (hitShape.type === SHAPE_TYPE.IMAGE) origShape.image = hitShape.image; const origBBox = getShapeBBox(origShape); manipulationMode.current = { mode: 'resize', dir: handle.dir, origShape, origBBox }; setIsDrawing(true); @@ -629,11 +648,21 @@ export const Canvas = () => { } if (hitShape) { + try { + const _ctx = canvasRef.current?.getContext('2d'); + if (_ctx) { + _ctx.globalCompositeOperation = 'source-over'; + _ctx.globalAlpha = 1; + _ctx.shadowBlur = 0; + } + } catch (err) { + console.debug('[canvas] composite reset failed', err); + } setSelectedShapeId(hitShape.id); setIsDrawing(true); - manipulationMode.current = { mode: "move" }; + manipulationMode.current = { mode: "pending-move" }; setActiveColor(hitShape.color); - setStrokeWidth(hitShape.width); + if (hitShape.type === SHAPE_TYPE.PEN) setStrokeWidth(hitShape.width); } else { setSelectedShapeId(null); setIsDrawing(false); @@ -665,7 +694,7 @@ export const Canvas = () => { // creation tools - if (Object.values(SHAPE_TYPE).includes(activeTool) || activeTool.startsWith('brush-')) { +if ((Object.values(SHAPE_TYPE).includes(activeTool) || activeTool.startsWith('brush-')) && activeTool !== SHAPE_TYPE.IMAGE) { setSelectedShapeId(null); setIsDrawing(true); manipulationMode.current = { mode: "create" }; @@ -686,6 +715,15 @@ export const Canvas = () => { newShape.brush = brushType || "solid"; newShape._seed = Math.floor(Math.random() * 0xffffffff); } + if (activeTool === SHAPE_TYPE.IMAGE) { + const hitShape = shapes.slice().reverse().find((shape) => isPointInShape(worldPoint, shape)); + if (hitShape && hitShape.type === SHAPE_TYPE.IMAGE) { + // Just select, don't draw a new one + setSelectedShapeId(hitShape.id); + setIsDrawing(false); + return; + } +} if (activeTool === SHAPE_TYPE.CIRCLE) { newShape.radius = 0; } @@ -702,8 +740,13 @@ export const Canvas = () => { if (!shape) return false; const bbox = getShapeBBox(shape); if (!bbox) return false; - const tol = shape.width + 6; - return (point.x >= bbox.minX - tol && point.x <= bbox.maxX + tol && point.y >= bbox.minY - tol && point.y <= bbox.maxY + tol); + const tol = shape.type === SHAPE_TYPE.IMAGE ? 8 : (shape.width || 0) + 6; + return ( + point.x >= bbox.minX - tol && + point.x <= bbox.maxX + tol && + point.y >= bbox.minY - tol && + point.y <= bbox.maxY + tol + ); }; const draw = (e) => { @@ -722,6 +765,24 @@ export const Canvas = () => { } if (!isDrawing) return; + if ( + activeTool === 'select' && + selectedShapeId && + manipulationMode.current && + manipulationMode.current.mode === 'pending-move' + ) { + const dx0 = worldPoint.x - pointerStart.current.x; + const dy0 = worldPoint.y - pointerStart.current.y; + const distSq0 = dx0 * dx0 + dy0 * dy0; + const threshold = 4 * 4; // squared threshold in world coords + if (distSq0 > threshold) { + // begin move: set pointerStart so subsequent deltas work from here + manipulationMode.current.mode = 'move'; + pointerStart.current = worldPoint; + } else { + return; + } + } // MOVE if (activeTool === 'select' && selectedShapeId && manipulationMode.current && manipulationMode.current.mode === 'move') { @@ -753,8 +814,9 @@ export const Canvas = () => { const shapeIndex = shapes.findIndex((s) => s.id === selectedShapeId); if (shapeIndex === -1) return; - const newShapes = [...shapes]; - const sh = JSON.parse(JSON.stringify(origShape)); + const newShapes = [...shapes]; + const sh = JSON.parse(JSON.stringify(origShape)); + if (origShape && origShape.type === SHAPE_TYPE.IMAGE) sh.image = origShape.image; // We'll compute a new bounding box keeping the opposite corner fixed depending on dir let { minX, minY, maxX, maxY } = origBBox; @@ -873,14 +935,15 @@ export const Canvas = () => { setIsPointerDown(false); if (!isDrawing) return; setIsDrawing(false); + const prevMode = manipulationMode.current?.mode; newShapeId.current = null; manipulationMode.current = null; - if (manipulationMode.current?.mode === "erase") { - const ctx = canvasRef.current.getContext("2d"); - ctx.globalCompositeOperation = "source-over"; -} + if (prevMode === "erase") { + const ctx = canvasRef.current?.getContext("2d"); + if (ctx) ctx.globalCompositeOperation = "source-over"; + } - }; +}; // delete const handleDeleteSelectedShape = useCallback(() => { @@ -1053,6 +1116,7 @@ export const Canvas = () => { onToolChange={handleToolChange} onClear={handleClear} onExport={handleExport} + onImageUpload={handleImageUpload} /> {joined ? ( diff --git a/client/src/components/Toolbar.jsx b/client/src/components/Toolbar.jsx index b910c03..dcbd3da 100644 --- a/client/src/components/Toolbar.jsx +++ b/client/src/components/Toolbar.jsx @@ -11,13 +11,23 @@ import { FileType, Brush, SquareDashed, // New import for the area select tool + ImagePlus, } from "lucide-react"; import { cn } from "../lib/utils"; import { Button } from "./ui/Button"; import { Separator } from "./ui/Separator"; import { DropdownMenu, DropdownMenuItem } from "./ui/DropdownMenu"; +import { useRef } from "react"; + +export const Toolbar = ({ + activeTool, + onToolChange, + onClear, + onExport, + onImageUpload, +}) => { + const imageInputRef = useRef(null); -export const Toolbar = ({ activeTool, onToolChange, onClear, onExport }) => { const tools = [ { type: "select", icon: MousePointer2 }, { type: "area-select", icon: SquareDashed }, // New Area Select Tool @@ -30,7 +40,14 @@ export const Toolbar = ({ activeTool, onToolChange, onClear, onExport }) => { const handleExport = (format) => { onExport(format); - } + }; + + const handleImageSelect = (e) => { + const file = e.target.files?.[0]; + if (file && onImageUpload) { + onImageUpload(file); + } + }; const brushTypes = [ { @@ -123,6 +140,25 @@ export const Toolbar = ({ activeTool, onToolChange, onClear, onExport }) => { + + + + +