Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 109 additions & 45 deletions client/src/components/Canvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const SHAPE_TYPE = {
PEN: "pen",
CIRCLE: "circle",
ERASER: "eraser",
IMAGE: 'image',
};

export const Canvas = () => {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
}, []);
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -622,18 +640,29 @@ 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);
return;
}

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);
Expand Down Expand Up @@ -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" };
Expand All @@ -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;
}
Expand All @@ -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) => {
Expand All @@ -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') {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -1053,6 +1116,7 @@ export const Canvas = () => {
onToolChange={handleToolChange}
onClear={handleClear}
onExport={handleExport}
onImageUpload={handleImageUpload}
/>

{joined ? (
Expand Down
40 changes: 38 additions & 2 deletions client/src/components/Toolbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = [
{
Expand Down Expand Up @@ -123,6 +140,25 @@ export const Toolbar = ({ activeTool, onToolChange, onClear, onExport }) => {
</DropdownMenu>

<Separator orientation="vertical" className="h-8 mx-1" />
<input
type="file"
accept="image/*"
ref={imageInputRef}
style={{ display: "none" }}
onChange={handleImageSelect}
/>
<Button
variant="ghost"
size="icon"
onClick={() => imageInputRef.current?.click()}
className="h-10 w-10 transition-all duration-200 hover:bg-secondary active:scale-95"
aria-label="Upload image"
>
<ImagePlus className="h-5 w-5" />
</Button>

<Separator orientation="vertical" className="h-8 mx-1" />

<Button
variant="ghost"
size="icon"
Expand Down
Loading