Skip to content

Commit fb5c4cf

Browse files
feat: add image upload functionality to canvas and toolbar
1 parent a9a432b commit fb5c4cf

File tree

2 files changed

+147
-47
lines changed

2 files changed

+147
-47
lines changed

client/src/components/Canvas.jsx

Lines changed: 109 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const SHAPE_TYPE = {
1515
PEN: "pen",
1616
CIRCLE: "circle",
1717
ERASER: "eraser",
18+
IMAGE: 'image',
1819
};
1920

2021
export const Canvas = () => {
@@ -52,35 +53,32 @@ export const Canvas = () => {
5253
const [hoveredHandle, setHoveredHandle] = useState(null); // { id, dir }
5354

5455
const handleLogout = async () => {
55-
try {
56-
const token = localStorage.getItem("token");
57-
if (!token) {
58-
toast.error("You are not logged in.");
59-
return;
60-
}
56+
// placeholder
57+
};
6158

62-
const res = await fetch("http://localhost:3000/api/auth/logout", {
63-
method: "POST",
64-
headers: { "Content-Type": "application/json" },
65-
body: JSON.stringify({ token }),
66-
});
59+
const handleImageUpload = (file) => {
60+
if (!file) return;
61+
const reader = new FileReader();
62+
reader.onload = (event) => {
63+
const img = new Image();
64+
img.onload = () => {
65+
const newShape = {
66+
id: Date.now().toString(),
67+
type: SHAPE_TYPE.IMAGE,
68+
image: img,
69+
start: { x: 100, y: 100 },
70+
end: { x: 100 + img.width, y: 100 + img.height },
71+
width: img.width,
72+
height: img.height,
73+
};
74+
setShapes((prev) => [...prev, newShape]);
75+
};
76+
img.src = event.target.result;
77+
};
78+
reader.readAsDataURL(file);
79+
};
6780

68-
if (!res.ok) {
69-
const data = await res.json().catch(() => ({}));
70-
toast.error(data.message || "Logout failed");
71-
return;
72-
}
7381

74-
// Clear auth state and redirect
75-
localStorage.removeItem("token");
76-
setIsLoggedIn(false);
77-
toast.success("Logged out successfully");
78-
window.location.href = "/";
79-
} catch (err) {
80-
console.error("Logout error:", err);
81-
toast.error("Logout failed. Please try again.");
82-
}
83-
};
8482

8583
// --- Helpers ---
8684
const getWorldPoint = (e) => {
@@ -136,6 +134,14 @@ export const Canvas = () => {
136134
const maxY = Math.max(...shape.path.map(p => p.y));
137135
return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
138136
}
137+
if (shape.type === SHAPE_TYPE.IMAGE) {
138+
const minX = Math.min(shape.start.x, shape.end.x);
139+
const maxX = Math.max(shape.start.x, shape.end.x);
140+
const minY = Math.min(shape.start.y, shape.end.y);
141+
const maxY = Math.max(shape.start.y, shape.end.y);
142+
return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
143+
}
144+
139145

140146
return null;
141147
}, []);
@@ -192,7 +198,7 @@ export const Canvas = () => {
192198
if (isSelected) {
193199
// draw purple glow under the stroke for visibility
194200
ctx.save();
195-
ctx.lineWidth = shape.width + 4;
201+
ctx.lineWidth = shape.type === SHAPE_TYPE.IMAGE ? 4 : shape.width + 4;
196202
ctx.strokeStyle = "rgba(76,29,149,1)";
197203
switch (shape.type) {
198204
case SHAPE_TYPE.LINE:
@@ -245,7 +251,7 @@ export const Canvas = () => {
245251

246252
// then draw the actual shape on top
247253
ctx.strokeStyle = shape.color;
248-
ctx.lineWidth = shape.width;
254+
ctx.lineWidth = shape.type === SHAPE_TYPE.IMAGE ? 1 : shape.width;
249255
}
250256

251257
switch (shape.type) {
@@ -517,6 +523,15 @@ export const Canvas = () => {
517523
ctx.globalAlpha = 1;
518524
}
519525
break;
526+
case SHAPE_TYPE.IMAGE: {
527+
const { image, start, end } = shape;
528+
if (image) {
529+
const width = end.x - start.x;
530+
const height = end.y - start.y;
531+
ctx.drawImage(image, start.x, start.y, width, height);
532+
}
533+
break;
534+
}
520535
default:
521536
break;
522537
}
@@ -547,12 +562,15 @@ export const Canvas = () => {
547562
const ctx = canvas?.getContext("2d");
548563
if (!ctx) return;
549564

550-
// clear and reset transform
551-
ctx.setTransform(1, 0, 0, 1, 0, 0);
552-
ctx.clearRect(0, 0, canvas.width, canvas.height);
565+
// clear and reset transform
566+
ctx.setTransform(1, 0, 0, 1, 0, 0);
567+
ctx.clearRect(0, 0, canvas.width, canvas.height);
568+
ctx.globalCompositeOperation = "source-over";
569+
ctx.globalAlpha = 1;
570+
ctx.shadowBlur = 0;
553571

554-
// apply pan & zoom
555-
ctx.setTransform(1, 0, 0, 1, offset.x, offset.y);
572+
// apply pan & zoom
573+
ctx.setTransform(1, 0, 0, 1, offset.x, offset.y);
556574
ctx.scale(scale, scale);
557575

558576
// draw non-selected shapes first
@@ -622,18 +640,29 @@ export const Canvas = () => {
622640
if (handle && hitShape && hitShape.id === selectedShapeId) {
623641
// begin resize
624642
const origShape = JSON.parse(JSON.stringify(hitShape));
643+
if (hitShape.type === SHAPE_TYPE.IMAGE) origShape.image = hitShape.image;
625644
const origBBox = getShapeBBox(origShape);
626645
manipulationMode.current = { mode: 'resize', dir: handle.dir, origShape, origBBox };
627646
setIsDrawing(true);
628647
return;
629648
}
630649

631650
if (hitShape) {
651+
try {
652+
const _ctx = canvasRef.current?.getContext('2d');
653+
if (_ctx) {
654+
_ctx.globalCompositeOperation = 'source-over';
655+
_ctx.globalAlpha = 1;
656+
_ctx.shadowBlur = 0;
657+
}
658+
} catch (err) {
659+
console.debug('[canvas] composite reset failed', err);
660+
}
632661
setSelectedShapeId(hitShape.id);
633662
setIsDrawing(true);
634-
manipulationMode.current = { mode: "move" };
663+
manipulationMode.current = { mode: "pending-move" };
635664
setActiveColor(hitShape.color);
636-
setStrokeWidth(hitShape.width);
665+
if (hitShape.type === SHAPE_TYPE.PEN) setStrokeWidth(hitShape.width);
637666
} else {
638667
setSelectedShapeId(null);
639668
setIsDrawing(false);
@@ -665,7 +694,7 @@ export const Canvas = () => {
665694

666695

667696
// creation tools
668-
if (Object.values(SHAPE_TYPE).includes(activeTool) || activeTool.startsWith('brush-')) {
697+
if ((Object.values(SHAPE_TYPE).includes(activeTool) || activeTool.startsWith('brush-')) && activeTool !== SHAPE_TYPE.IMAGE) {
669698
setSelectedShapeId(null);
670699
setIsDrawing(true);
671700
manipulationMode.current = { mode: "create" };
@@ -686,6 +715,15 @@ export const Canvas = () => {
686715
newShape.brush = brushType || "solid";
687716
newShape._seed = Math.floor(Math.random() * 0xffffffff);
688717
}
718+
if (activeTool === SHAPE_TYPE.IMAGE) {
719+
const hitShape = shapes.slice().reverse().find((shape) => isPointInShape(worldPoint, shape));
720+
if (hitShape && hitShape.type === SHAPE_TYPE.IMAGE) {
721+
// Just select, don't draw a new one
722+
setSelectedShapeId(hitShape.id);
723+
setIsDrawing(false);
724+
return;
725+
}
726+
}
689727
if (activeTool === SHAPE_TYPE.CIRCLE) {
690728
newShape.radius = 0;
691729
}
@@ -702,8 +740,13 @@ export const Canvas = () => {
702740
if (!shape) return false;
703741
const bbox = getShapeBBox(shape);
704742
if (!bbox) return false;
705-
const tol = shape.width + 6;
706-
return (point.x >= bbox.minX - tol && point.x <= bbox.maxX + tol && point.y >= bbox.minY - tol && point.y <= bbox.maxY + tol);
743+
const tol = shape.type === SHAPE_TYPE.IMAGE ? 8 : (shape.width || 0) + 6;
744+
return (
745+
point.x >= bbox.minX - tol &&
746+
point.x <= bbox.maxX + tol &&
747+
point.y >= bbox.minY - tol &&
748+
point.y <= bbox.maxY + tol
749+
);
707750
};
708751

709752
const draw = (e) => {
@@ -722,6 +765,24 @@ export const Canvas = () => {
722765
}
723766

724767
if (!isDrawing) return;
768+
if (
769+
activeTool === 'select' &&
770+
selectedShapeId &&
771+
manipulationMode.current &&
772+
manipulationMode.current.mode === 'pending-move'
773+
) {
774+
const dx0 = worldPoint.x - pointerStart.current.x;
775+
const dy0 = worldPoint.y - pointerStart.current.y;
776+
const distSq0 = dx0 * dx0 + dy0 * dy0;
777+
const threshold = 4 * 4; // squared threshold in world coords
778+
if (distSq0 > threshold) {
779+
// begin move: set pointerStart so subsequent deltas work from here
780+
manipulationMode.current.mode = 'move';
781+
pointerStart.current = worldPoint;
782+
} else {
783+
return;
784+
}
785+
}
725786

726787
// MOVE
727788
if (activeTool === 'select' && selectedShapeId && manipulationMode.current && manipulationMode.current.mode === 'move') {
@@ -753,8 +814,9 @@ export const Canvas = () => {
753814
const shapeIndex = shapes.findIndex((s) => s.id === selectedShapeId);
754815
if (shapeIndex === -1) return;
755816

756-
const newShapes = [...shapes];
757-
const sh = JSON.parse(JSON.stringify(origShape));
817+
const newShapes = [...shapes];
818+
const sh = JSON.parse(JSON.stringify(origShape));
819+
if (origShape && origShape.type === SHAPE_TYPE.IMAGE) sh.image = origShape.image;
758820

759821
// We'll compute a new bounding box keeping the opposite corner fixed depending on dir
760822
let { minX, minY, maxX, maxY } = origBBox;
@@ -873,14 +935,15 @@ export const Canvas = () => {
873935
setIsPointerDown(false);
874936
if (!isDrawing) return;
875937
setIsDrawing(false);
938+
const prevMode = manipulationMode.current?.mode;
876939
newShapeId.current = null;
877940
manipulationMode.current = null;
878-
if (manipulationMode.current?.mode === "erase") {
879-
const ctx = canvasRef.current.getContext("2d");
880-
ctx.globalCompositeOperation = "source-over";
881-
}
941+
if (prevMode === "erase") {
942+
const ctx = canvasRef.current?.getContext("2d");
943+
if (ctx) ctx.globalCompositeOperation = "source-over";
944+
}
882945

883-
};
946+
};
884947

885948
// delete
886949
const handleDeleteSelectedShape = useCallback(() => {
@@ -1053,6 +1116,7 @@ export const Canvas = () => {
10531116
onToolChange={handleToolChange}
10541117
onClear={handleClear}
10551118
onExport={handleExport}
1119+
onImageUpload={handleImageUpload}
10561120
/>
10571121

10581122
{joined ? (

client/src/components/Toolbar.jsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,23 @@ import {
1111
FileType,
1212
Brush,
1313
SquareDashed, // New import for the area select tool
14+
ImagePlus,
1415
} from "lucide-react";
1516
import { cn } from "../lib/utils";
1617
import { Button } from "./ui/Button";
1718
import { Separator } from "./ui/Separator";
1819
import { DropdownMenu, DropdownMenuItem } from "./ui/DropdownMenu";
20+
import { useRef } from "react";
21+
22+
export const Toolbar = ({
23+
activeTool,
24+
onToolChange,
25+
onClear,
26+
onExport,
27+
onImageUpload,
28+
}) => {
29+
const imageInputRef = useRef(null);
1930

20-
export const Toolbar = ({ activeTool, onToolChange, onClear, onExport }) => {
2131
const tools = [
2232
{ type: "select", icon: MousePointer2 },
2333
{ type: "area-select", icon: SquareDashed }, // New Area Select Tool
@@ -30,7 +40,14 @@ export const Toolbar = ({ activeTool, onToolChange, onClear, onExport }) => {
3040

3141
const handleExport = (format) => {
3242
onExport(format);
33-
}
43+
};
44+
45+
const handleImageSelect = (e) => {
46+
const file = e.target.files?.[0];
47+
if (file && onImageUpload) {
48+
onImageUpload(file);
49+
}
50+
};
3451

3552
const brushTypes = [
3653
{
@@ -123,6 +140,25 @@ export const Toolbar = ({ activeTool, onToolChange, onClear, onExport }) => {
123140
</DropdownMenu>
124141

125142
<Separator orientation="vertical" className="h-8 mx-1" />
143+
<input
144+
type="file"
145+
accept="image/*"
146+
ref={imageInputRef}
147+
style={{ display: "none" }}
148+
onChange={handleImageSelect}
149+
/>
150+
<Button
151+
variant="ghost"
152+
size="icon"
153+
onClick={() => imageInputRef.current?.click()}
154+
className="h-10 w-10 transition-all duration-200 hover:bg-secondary active:scale-95"
155+
aria-label="Upload image"
156+
>
157+
<ImagePlus className="h-5 w-5" />
158+
</Button>
159+
160+
<Separator orientation="vertical" className="h-8 mx-1" />
161+
126162
<Button
127163
variant="ghost"
128164
size="icon"

0 commit comments

Comments
 (0)