Skip to content

Commit e747427

Browse files
authored
Merge pull request #60 from Shreyanshi210205/image_upload
feat: add image upload functionality to canvas and toolbar
2 parents 30e8eb8 + 604c554 commit e747427

File tree

2 files changed

+148
-21
lines changed

2 files changed

+148
-21
lines changed

client/src/components/Canvas.jsx

Lines changed: 110 additions & 19 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 = () => {
@@ -82,6 +83,30 @@ export const Canvas = () => {
8283
}
8384
};
8485

86+
const handleImageUpload = (file) => {
87+
if (!file) return;
88+
const reader = new FileReader();
89+
reader.onload = (event) => {
90+
const img = new Image();
91+
img.onload = () => {
92+
const newShape = {
93+
id: Date.now().toString(),
94+
type: SHAPE_TYPE.IMAGE,
95+
image: img,
96+
start: { x: 100, y: 100 },
97+
end: { x: 100 + img.width, y: 100 + img.height },
98+
width: img.width,
99+
height: img.height,
100+
};
101+
setShapes((prev) => [...prev, newShape]);
102+
};
103+
img.src = event.target.result;
104+
};
105+
reader.readAsDataURL(file);
106+
};
107+
108+
109+
85110
// --- Helpers ---
86111
const getWorldPoint = (e) => {
87112
const canvas = canvasRef.current;
@@ -136,6 +161,14 @@ export const Canvas = () => {
136161
const maxY = Math.max(...shape.path.map(p => p.y));
137162
return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
138163
}
164+
if (shape.type === SHAPE_TYPE.IMAGE) {
165+
const minX = Math.min(shape.start.x, shape.end.x);
166+
const maxX = Math.max(shape.start.x, shape.end.x);
167+
const minY = Math.min(shape.start.y, shape.end.y);
168+
const maxY = Math.max(shape.start.y, shape.end.y);
169+
return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
170+
}
171+
139172

140173
return null;
141174
}, []);
@@ -192,7 +225,7 @@ export const Canvas = () => {
192225
if (isSelected) {
193226
// draw purple glow under the stroke for visibility
194227
ctx.save();
195-
ctx.lineWidth = shape.width + 4;
228+
ctx.lineWidth = shape.type === SHAPE_TYPE.IMAGE ? 4 : shape.width + 4;
196229
ctx.strokeStyle = "rgba(76,29,149,1)";
197230
switch (shape.type) {
198231
case SHAPE_TYPE.LINE:
@@ -245,7 +278,7 @@ export const Canvas = () => {
245278

246279
// then draw the actual shape on top
247280
ctx.strokeStyle = shape.color;
248-
ctx.lineWidth = shape.width;
281+
ctx.lineWidth = shape.type === SHAPE_TYPE.IMAGE ? 1 : shape.width;
249282
}
250283

251284
switch (shape.type) {
@@ -517,6 +550,15 @@ export const Canvas = () => {
517550
ctx.globalAlpha = 1;
518551
}
519552
break;
553+
case SHAPE_TYPE.IMAGE: {
554+
const { image, start, end } = shape;
555+
if (image) {
556+
const width = end.x - start.x;
557+
const height = end.y - start.y;
558+
ctx.drawImage(image, start.x, start.y, width, height);
559+
}
560+
break;
561+
}
520562
default:
521563
break;
522564
}
@@ -547,12 +589,15 @@ export const Canvas = () => {
547589
const ctx = canvas?.getContext("2d");
548590
if (!ctx) return;
549591

550-
// clear and reset transform
551-
ctx.setTransform(1, 0, 0, 1, 0, 0);
552-
ctx.clearRect(0, 0, canvas.width, canvas.height);
592+
// clear and reset transform
593+
ctx.setTransform(1, 0, 0, 1, 0, 0);
594+
ctx.clearRect(0, 0, canvas.width, canvas.height);
595+
ctx.globalCompositeOperation = "source-over";
596+
ctx.globalAlpha = 1;
597+
ctx.shadowBlur = 0;
553598

554-
// apply pan & zoom
555-
ctx.setTransform(1, 0, 0, 1, offset.x, offset.y);
599+
// apply pan & zoom
600+
ctx.setTransform(1, 0, 0, 1, offset.x, offset.y);
556601
ctx.scale(scale, scale);
557602

558603
// draw non-selected shapes first
@@ -622,18 +667,29 @@ export const Canvas = () => {
622667
if (handle && hitShape && hitShape.id === selectedShapeId) {
623668
// begin resize
624669
const origShape = JSON.parse(JSON.stringify(hitShape));
670+
if (hitShape.type === SHAPE_TYPE.IMAGE) origShape.image = hitShape.image;
625671
const origBBox = getShapeBBox(origShape);
626672
manipulationMode.current = { mode: 'resize', dir: handle.dir, origShape, origBBox };
627673
setIsDrawing(true);
628674
return;
629675
}
630676

631677
if (hitShape) {
678+
try {
679+
const _ctx = canvasRef.current?.getContext('2d');
680+
if (_ctx) {
681+
_ctx.globalCompositeOperation = 'source-over';
682+
_ctx.globalAlpha = 1;
683+
_ctx.shadowBlur = 0;
684+
}
685+
} catch (err) {
686+
console.debug('[canvas] composite reset failed', err);
687+
}
632688
setSelectedShapeId(hitShape.id);
633689
setIsDrawing(true);
634-
manipulationMode.current = { mode: "move" };
690+
manipulationMode.current = { mode: "pending-move" };
635691
setActiveColor(hitShape.color);
636-
setStrokeWidth(hitShape.width);
692+
if (hitShape.type === SHAPE_TYPE.PEN) setStrokeWidth(hitShape.width);
637693
} else {
638694
setSelectedShapeId(null);
639695
setIsDrawing(false);
@@ -665,7 +721,7 @@ export const Canvas = () => {
665721

666722

667723
// creation tools
668-
if (Object.values(SHAPE_TYPE).includes(activeTool) || activeTool.startsWith('brush-')) {
724+
if ((Object.values(SHAPE_TYPE).includes(activeTool) || activeTool.startsWith('brush-')) && activeTool !== SHAPE_TYPE.IMAGE) {
669725
setSelectedShapeId(null);
670726
setIsDrawing(true);
671727
manipulationMode.current = { mode: "create" };
@@ -686,6 +742,15 @@ export const Canvas = () => {
686742
newShape.brush = brushType || "solid";
687743
newShape._seed = Math.floor(Math.random() * 0xffffffff);
688744
}
745+
if (activeTool === SHAPE_TYPE.IMAGE) {
746+
const hitShape = shapes.slice().reverse().find((shape) => isPointInShape(worldPoint, shape));
747+
if (hitShape && hitShape.type === SHAPE_TYPE.IMAGE) {
748+
// Just select, don't draw a new one
749+
setSelectedShapeId(hitShape.id);
750+
setIsDrawing(false);
751+
return;
752+
}
753+
}
689754
if (activeTool === SHAPE_TYPE.CIRCLE) {
690755
newShape.radius = 0;
691756
}
@@ -702,8 +767,13 @@ export const Canvas = () => {
702767
if (!shape) return false;
703768
const bbox = getShapeBBox(shape);
704769
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);
770+
const tol = shape.type === SHAPE_TYPE.IMAGE ? 8 : (shape.width || 0) + 6;
771+
return (
772+
point.x >= bbox.minX - tol &&
773+
point.x <= bbox.maxX + tol &&
774+
point.y >= bbox.minY - tol &&
775+
point.y <= bbox.maxY + tol
776+
);
707777
};
708778

709779
const draw = (e) => {
@@ -722,6 +792,24 @@ export const Canvas = () => {
722792
}
723793

724794
if (!isDrawing) return;
795+
if (
796+
activeTool === 'select' &&
797+
selectedShapeId &&
798+
manipulationMode.current &&
799+
manipulationMode.current.mode === 'pending-move'
800+
) {
801+
const dx0 = worldPoint.x - pointerStart.current.x;
802+
const dy0 = worldPoint.y - pointerStart.current.y;
803+
const distSq0 = dx0 * dx0 + dy0 * dy0;
804+
const threshold = 4 * 4; // squared threshold in world coords
805+
if (distSq0 > threshold) {
806+
// begin move: set pointerStart so subsequent deltas work from here
807+
manipulationMode.current.mode = 'move';
808+
pointerStart.current = worldPoint;
809+
} else {
810+
return;
811+
}
812+
}
725813

726814
// MOVE
727815
if (activeTool === 'select' && selectedShapeId && manipulationMode.current && manipulationMode.current.mode === 'move') {
@@ -753,8 +841,9 @@ export const Canvas = () => {
753841
const shapeIndex = shapes.findIndex((s) => s.id === selectedShapeId);
754842
if (shapeIndex === -1) return;
755843

756-
const newShapes = [...shapes];
757-
const sh = JSON.parse(JSON.stringify(origShape));
844+
const newShapes = [...shapes];
845+
const sh = JSON.parse(JSON.stringify(origShape));
846+
if (origShape && origShape.type === SHAPE_TYPE.IMAGE) sh.image = origShape.image;
758847

759848
// We'll compute a new bounding box keeping the opposite corner fixed depending on dir
760849
let { minX, minY, maxX, maxY } = origBBox;
@@ -873,14 +962,15 @@ export const Canvas = () => {
873962
setIsPointerDown(false);
874963
if (!isDrawing) return;
875964
setIsDrawing(false);
965+
const prevMode = manipulationMode.current?.mode;
876966
newShapeId.current = null;
877967
manipulationMode.current = null;
878-
if (manipulationMode.current?.mode === "erase") {
879-
const ctx = canvasRef.current.getContext("2d");
880-
ctx.globalCompositeOperation = "source-over";
881-
}
968+
if (prevMode === "erase") {
969+
const ctx = canvasRef.current?.getContext("2d");
970+
if (ctx) ctx.globalCompositeOperation = "source-over";
971+
}
882972

883-
};
973+
};
884974

885975
// delete
886976
const handleDeleteSelectedShape = useCallback(() => {
@@ -1053,6 +1143,7 @@ export const Canvas = () => {
10531143
onToolChange={handleToolChange}
10541144
onClear={handleClear}
10551145
onExport={handleExport}
1146+
onImageUpload={handleImageUpload}
10561147
/>
10571148

10581149
{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)