Skip to content

Commit 604c554

Browse files
feat: add image upload functionality to canvas and toolbar
1 parent e9c0f9f commit 604c554

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 = () => {
@@ -55,6 +56,30 @@ export const Canvas = () => {
5556
// placeholder
5657
};
5758

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+
};
80+
81+
82+
5883
// --- Helpers ---
5984
const getWorldPoint = (e) => {
6085
const canvas = canvasRef.current;
@@ -109,6 +134,14 @@ export const Canvas = () => {
109134
const maxY = Math.max(...shape.path.map(p => p.y));
110135
return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
111136
}
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+
112145

113146
return null;
114147
}, []);
@@ -165,7 +198,7 @@ export const Canvas = () => {
165198
if (isSelected) {
166199
// draw purple glow under the stroke for visibility
167200
ctx.save();
168-
ctx.lineWidth = shape.width + 4;
201+
ctx.lineWidth = shape.type === SHAPE_TYPE.IMAGE ? 4 : shape.width + 4;
169202
ctx.strokeStyle = "rgba(76,29,149,1)";
170203
switch (shape.type) {
171204
case SHAPE_TYPE.LINE:
@@ -218,7 +251,7 @@ export const Canvas = () => {
218251

219252
// then draw the actual shape on top
220253
ctx.strokeStyle = shape.color;
221-
ctx.lineWidth = shape.width;
254+
ctx.lineWidth = shape.type === SHAPE_TYPE.IMAGE ? 1 : shape.width;
222255
}
223256

224257
switch (shape.type) {
@@ -490,6 +523,15 @@ export const Canvas = () => {
490523
ctx.globalAlpha = 1;
491524
}
492525
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+
}
493535
default:
494536
break;
495537
}
@@ -520,12 +562,15 @@ export const Canvas = () => {
520562
const ctx = canvas?.getContext("2d");
521563
if (!ctx) return;
522564

523-
// clear and reset transform
524-
ctx.setTransform(1, 0, 0, 1, 0, 0);
525-
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;
526571

527-
// apply pan & zoom
528-
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);
529574
ctx.scale(scale, scale);
530575

531576
// draw non-selected shapes first
@@ -595,18 +640,29 @@ export const Canvas = () => {
595640
if (handle && hitShape && hitShape.id === selectedShapeId) {
596641
// begin resize
597642
const origShape = JSON.parse(JSON.stringify(hitShape));
643+
if (hitShape.type === SHAPE_TYPE.IMAGE) origShape.image = hitShape.image;
598644
const origBBox = getShapeBBox(origShape);
599645
manipulationMode.current = { mode: 'resize', dir: handle.dir, origShape, origBBox };
600646
setIsDrawing(true);
601647
return;
602648
}
603649

604650
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+
}
605661
setSelectedShapeId(hitShape.id);
606662
setIsDrawing(true);
607-
manipulationMode.current = { mode: "move" };
663+
manipulationMode.current = { mode: "pending-move" };
608664
setActiveColor(hitShape.color);
609-
setStrokeWidth(hitShape.width);
665+
if (hitShape.type === SHAPE_TYPE.PEN) setStrokeWidth(hitShape.width);
610666
} else {
611667
setSelectedShapeId(null);
612668
setIsDrawing(false);
@@ -638,7 +694,7 @@ export const Canvas = () => {
638694

639695

640696
// creation tools
641-
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) {
642698
setSelectedShapeId(null);
643699
setIsDrawing(true);
644700
manipulationMode.current = { mode: "create" };
@@ -659,6 +715,15 @@ export const Canvas = () => {
659715
newShape.brush = brushType || "solid";
660716
newShape._seed = Math.floor(Math.random() * 0xffffffff);
661717
}
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+
}
662727
if (activeTool === SHAPE_TYPE.CIRCLE) {
663728
newShape.radius = 0;
664729
}
@@ -675,8 +740,13 @@ export const Canvas = () => {
675740
if (!shape) return false;
676741
const bbox = getShapeBBox(shape);
677742
if (!bbox) return false;
678-
const tol = shape.width + 6;
679-
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+
);
680750
};
681751

682752
const draw = (e) => {
@@ -695,6 +765,24 @@ export const Canvas = () => {
695765
}
696766

697767
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+
}
698786

699787
// MOVE
700788
if (activeTool === 'select' && selectedShapeId && manipulationMode.current && manipulationMode.current.mode === 'move') {
@@ -726,8 +814,9 @@ export const Canvas = () => {
726814
const shapeIndex = shapes.findIndex((s) => s.id === selectedShapeId);
727815
if (shapeIndex === -1) return;
728816

729-
const newShapes = [...shapes];
730-
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;
731820

732821
// We'll compute a new bounding box keeping the opposite corner fixed depending on dir
733822
let { minX, minY, maxX, maxY } = origBBox;
@@ -846,14 +935,15 @@ export const Canvas = () => {
846935
setIsPointerDown(false);
847936
if (!isDrawing) return;
848937
setIsDrawing(false);
938+
const prevMode = manipulationMode.current?.mode;
849939
newShapeId.current = null;
850940
manipulationMode.current = null;
851-
if (manipulationMode.current?.mode === "erase") {
852-
const ctx = canvasRef.current.getContext("2d");
853-
ctx.globalCompositeOperation = "source-over";
854-
}
941+
if (prevMode === "erase") {
942+
const ctx = canvasRef.current?.getContext("2d");
943+
if (ctx) ctx.globalCompositeOperation = "source-over";
944+
}
855945

856-
};
946+
};
857947

858948
// delete
859949
const handleDeleteSelectedShape = useCallback(() => {
@@ -1026,6 +1116,7 @@ export const Canvas = () => {
10261116
onToolChange={handleToolChange}
10271117
onClear={handleClear}
10281118
onExport={handleExport}
1119+
onImageUpload={handleImageUpload}
10291120
/>
10301121

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