Skip to content
Draft
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
162 changes: 137 additions & 25 deletions web/LED-Wall-Website/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from "react";
import React, { useState, useRef } from "react";
import styles from "../Styles.module.css";
import uploadFile from "./Upload.tsx";
import { useDispatch, useSelector } from "react-redux";
Expand All @@ -11,33 +11,67 @@ type Props = {
sizeMultiplier: number;
};

//Canvas that holds all elements and allows drag and drop of image files onto it
const ZOOM_IN_SCALE = 2.5;

function Canvas(props: Props) {
const dispatch = useDispatch();
const configState = useSelector((state: RootState) => state.config);

//Sets the file and calls upload with the current mouse location
const [scale, setScale] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const isPanning = useRef(false);
const panStart = useRef({ x: 0, y: 0 });
const panOrigin = useRef({ x: 0, y: 0 });

const isZoomedIn = scale > 1;

function handleZoomIn() {
setScale(ZOOM_IN_SCALE);
setPan({ x: 0, y: 0 });
}

function handleZoomOut() {
setScale(1);
setPan({ x: 0, y: 0 });
}

// Only pan when the user clicks directly on the canvas background,
// not when clicking on a child element (image/text).
function handleMouseDown(e: React.MouseEvent<HTMLDivElement>) {
if (!isZoomedIn) return;
// e.target is the exact element clicked; e.currentTarget is this div.
// If they differ, the click landed on a child (an Element), so don't pan.
if (e.target !== e.currentTarget) return;
if (e.button !== 0) return;
isPanning.current = true;
panStart.current = { x: e.clientX, y: e.clientY };
panOrigin.current = { x: pan.x, y: pan.y };
}

function handleMouseMove(e: React.MouseEvent<HTMLDivElement>) {
if (!isPanning.current) return;
const dx = e.clientX - panStart.current.x;
const dy = e.clientY - panStart.current.y;
setPan({ x: panOrigin.current.x + dx, y: panOrigin.current.y + dy });
}

function handleMouseUp() {
isPanning.current = false;
}

function handleMouseLeave() {
isPanning.current = false;
}

async function handleDrop(e: React.DragEvent) {
e.preventDefault();
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) {
return;
}
//Gets the canvas position
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
const canvasRect = e.currentTarget.getBoundingClientRect();
//Calculates position relative to canvas
let relativeX = e.clientX - canvasRect.left;
relativeX = Math.trunc(relativeX / props.sizeMultiplier);
let relativeY = e.clientY - canvasRect.top;
relativeY = Math.trunc(relativeY / props.sizeMultiplier);
uploadFile(
[relativeX, relativeY],
e.dataTransfer.files[0],
dispatch,
configState
);
const relativeX = Math.trunc((e.clientX - canvasRect.left) / props.sizeMultiplier);
const relativeY = Math.trunc((e.clientY - canvasRect.top) / props.sizeMultiplier);
uploadFile([relativeX, relativeY], e.dataTransfer.files[0], dispatch, configState);
}

//Prevents browser not allowing dragging the image
function handleDragOver(e: React.DragEvent) {
e.preventDefault();
}
Expand Down Expand Up @@ -75,18 +109,96 @@ function Canvas(props: Props) {
}

return (
// Outer viewport — fixed size, clips overflow, holds border
<div
className={styles.canvas}
onDrop={(e) => handleDrop(e)}
onDragOver={(e) => handleDragOver(e)}
style={{
cursor: "grab",
position: "fixed",
top: "53%",
left: "50%",
transform: "translate(-50%, -50%)",
width: props.canvasDimensions[0],
height: props.canvasDimensions[1],
border: "4px solid rgb(29, 41, 58)",
overflow: "hidden",
}}
>
{configState.elements.map(createJSXElement)}
{/* Zoom buttons — sit above the canvas, stopPropagation so they don't trigger pan */}
{props.canvasDimensions[1] > 0 && (
<div
style={{
position: "absolute",
bottom: "10px",
left: "10px",
display: "flex",
alignItems: "center",
gap: "6px",
zIndex: 200,
backgroundColor: "rgba(29,41,58,0.75)",
borderRadius: "6px",
padding: "4px 8px",
}}
onMouseDown={(e) => e.stopPropagation()}
>
<button
onClick={handleZoomOut}
disabled={!isZoomedIn}
style={{
left: "unset",
transform: "none",
margin: 0,
padding: "2px 10px",
opacity: isZoomedIn ? 1 : 0.45,
}}
title="Fit to screen"
>
</button>
<span style={{ color: "whitesmoke", fontSize: "13px", minWidth: "36px", textAlign: "center", userSelect: "none" }}>
{isZoomedIn ? `Zoom: ${ZOOM_IN_SCALE}×` : "Zoom: 1×"}
</span>
<button
onClick={handleZoomIn}
disabled={isZoomedIn}
style={{
left: "unset",
transform: "none",
margin: 0,
padding: "2px 10px",
opacity: isZoomedIn ? 0.45 : 1,
}}
title="Zoom in"
>
+
</button>
</div>
)}

{/* Inner canvas — receives scale+pan transform, listens for background pan drags */}
<div
className={styles.canvas}
onDrop={handleDrop}
onDragOver={handleDragOver}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
style={{
position: "absolute",
top: 0,
left: 0,
border: "none",
cursor: isZoomedIn ? "grab" : "default",
width: props.canvasDimensions[0],
height: props.canvasDimensions[1],
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
transformOrigin: "top left",
transition: isPanning.current ? "none" : "transform 200ms ease",
}}
>
{configState.elements.map(createJSXElement)}
</div>
</div>
);
}
export default Canvas;

export default Canvas;