How to transform the canvas objects while keeping opposite side fixed?

So my idea is to make some simple canvas editor.
My current problem is I can’t resize my object properly.
Currently it’s being resized from it’s center and not from the edge I’m dragging.

Here’s what I expect to have:
Animation.gif
My code:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Canvas Object Editor</title>
    <style>
        body { display: flex; font-family: Arial, sans-serif; }
        #canvasContainer {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
        }
        canvas { border: 1px solid black; }

        .transform-handle{
            width: 8px;
            height: 8px;
            background-color: lightblue;
            position: absolute;
            cursor: grab;
            z-index: 1000;
            border: 1px solid #007BFF;
            border-radius: 2px;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
            transform: translate(-50%, -50%);
        }
        .rotate-handle {
            width: 8px;
            height: 8px;
            background-color: rgb(225, 173, 230);
            position: absolute;
            cursor: grab;
            z-index: 1000;
            border: 1px solid #ff00aa;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
            transform: translate(-50%, -50%);
        }
    </style>
</head>
<body>

    <div id="canvasContainer">
        <canvas id="editorCanvas" width="800" height="700"></canvas>
    </div>

    <div id="top-left-handle" class="transform-handle"></div>
    <div id="top-right-handle" class="transform-handle"></div>
    <div id="bottom-left-handle" class="transform-handle"></div>
    <div id="bottom-right-handle" class="transform-handle"></div>
    <div id="middle-left-handle" class="transform-handle"></div>
    <div id="middle-right-handle" class="transform-handle"></div>
    <div id="middle-top-handle" class="transform-handle"></div>
    <div id="middle-bottom-handle" class="transform-handle"></div>

    <div id="rotate-top-left-handle" class="rotate-handle"></div>
    <div id="rotate-top-right-handle" class="rotate-handle"></div>
    <div id="rotate-bottom-left-handle" class="rotate-handle"></div>
    <div id="rotate-bottom-right-handle" class="rotate-handle"></div>

    <script>
        const canvas = document.getElementById("editorCanvas");
        const ctx = canvas.getContext("2d");
        const canvasContainer = document.getElementById("canvasContainer");

        let isDragging = false;
        let offsetX, offsetY;
        let isResizing = false;
        let isRotating = false;
        let currentHandle = null;
        let initialMouseX, initialMouseY, initialWidth, initialHeight, initialAngle;

        let objects = [
            { id: 1, name: "Object 1", x: 50, y: 50, width: 100, height: 100, angle: 0, opacity: 1, layer: 0, color: "#FFC080", behaviours: {} },
            { id: 2, name: "Object 2", x: 250, y: 150, width: 80, height: 80, angle: 45, opacity: 1, layer: 1, color: "#FF9900", behaviours: {} },
            { id: 3, name: "Object 3", x: 500, y: 120, width: 50, height: 60, angle: 80, opacity: 1, layer: 2, color: "#FFD700", behaviours: {} }
        ];

        var selectedObject = null;

        function drawObjects() {
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            objects.sort((a, b) => a.layer - b.layer).forEach(drawObject);
        }

        function drawObject(obj) {
            ctx.save(); // Save the canvas state before any transformations
            
            ctx.globalAlpha = obj.opacity;

            // Move the canvas to the center of the object, then rotate
            ctx.translate(obj.x, obj.y); // Use top-left as the origin, not center
            ctx.translate(obj.width / 2, obj.height / 2); // Shift to the center
            ctx.rotate((obj.angle * Math.PI) / 180); // Apply rotation

            // Now, draw the object centered on the (0, 0) point (its center)
            ctx.fillStyle = obj.color;
            ctx.fillRect(-obj.width / 2, -obj.height / 2, obj.width, obj.height);

            // Draw the center point for reference
            ctx.fillStyle = "black";
            ctx.beginPath();
            ctx.arc(0, 0, 2, 0, 2 * Math.PI);
            ctx.fill();
            
            // Draw the size text
            ctx.fillStyle = "black";
            ctx.font = "12px Arial";
            ctx.textAlign = "center";
            ctx.textBaseline = "middle";
            ctx.fillText(`${Math.round(obj.width)}x${Math.round(obj.height)}`, 0, obj.height / 2 + 15);

            // Restore canvas state after drawing the object
            ctx.restore();

            // Now for the outline, ensure it's drawn at the correct location, accounting for the transformation
            if (obj === selectedObject && isResizing) {
                ctx.save(); // Save the current state

                ctx.translate(obj.x, obj.y);
                ctx.translate(obj.width / 2, obj.height / 2); // Move to center
                ctx.rotate((obj.angle * Math.PI) / 180);

                // Outline of the object (square outline)
                ctx.beginPath();
                ctx.moveTo(-obj.width / 2, -obj.height / 2);
                ctx.lineTo(obj.width / 2, -obj.height / 2);
                ctx.lineTo(obj.width / 2, obj.height / 2);
                ctx.lineTo(-obj.width / 2, obj.height / 2);
                ctx.closePath();
                ctx.stroke();

                ctx.restore(); // Restore state for normal drawing behavior
            }
        }

        function updateHandles() {
            if (selectedObject) {
                const canvasRect = canvas.getBoundingClientRect();
                const cx = selectedObject.x + selectedObject.width / 2;
                const cy = selectedObject.y + selectedObject.height / 2;
                const width = selectedObject.width;
                const height = selectedObject.height;
                const angle = selectedObject.angle;
                const angleRad = angle * Math.PI / 180;

                const handles = [
                    { id: 'top-left-handle', dx: -width / 2, dy: -height / 2 },
                    { id: 'top-right-handle', dx: width / 2, dy: -height / 2 },
                    { id: 'bottom-left-handle', dx: -width / 2, dy: height / 2 },
                    { id: 'bottom-right-handle', dx: width / 2, dy: height / 2 },
                    { id: 'middle-left-handle', dx: -width / 2, dy: 0 },
                    { id: 'middle-right-handle', dx: width / 2, dy: 0 },
                    { id: 'middle-top-handle', dx: 0, dy: -height / 2 },
                    { id: 'middle-bottom-handle', dx: 0, dy: height / 2 },
                    { id: 'rotate-top-left-handle', dx: -width / 2 - 10, dy: -height / 2 - 10 },
                    { id: 'rotate-top-right-handle', dx: width / 2 + 10, dy: -height / 2 - 10 },
                    { id: 'rotate-bottom-left-handle', dx: -width / 2 - 10, dy: height / 2 + 10 },
                    { id: 'rotate-bottom-right-handle', dx: width / 2 + 10, dy: height / 2 + 10 }
                ];

                handles.forEach(handle => {
                    const element = document.getElementById(handle.id);
                    const rotatedX = handle.dx * Math.cos(angleRad) - handle.dy * Math.sin(angleRad);
                    const rotatedY = handle.dx * Math.sin(angleRad) + handle.dy * Math.cos(angleRad);
                    const x = cx + rotatedX;
                    const y = cy + rotatedY;

                    element.style.left = `${x + canvasRect.left}px`;
                    element.style.top = `${y + canvasRect.top}px`;
                    element.style.display = 'block';
                });
            }
        }

        function rotatePoint(x, y, cx, cy, angle) {
            const rad = angle * Math.PI / 180;
            const cos = Math.cos(rad);
            const sin = Math.sin(rad);
            return {
                x: (x - cx) * cos - (y - cy) * sin + cx,
                y: (x - cx) * sin + (y - cy) * cos + cy
            };
        }
        function unrotatePoint(x, y, cx, cy, angle) {
            return rotatePoint(x, y, cx, cy, -angle);
        }

        function getCanvasMousePosition(event) {
            const rect = canvas.getBoundingClientRect();
            return {
                x: event.clientX - rect.left,
                y: event.clientY - rect.top
            };
        }

        // SELECT OBJECT
        canvas.addEventListener("mousedown", (event) => {
            const pos = getCanvasMousePosition(event);
            
            const objectsUnderCursor = objects.filter(obj => {
                const centerX = obj.x + obj.width / 2;
                const centerY = obj.y + obj.height / 2;
                
                // Transform cursor position relative to object's center and rotation
                const dx = pos.x - centerX;
                const dy = pos.y - centerY;
                const angle = -obj.angle * Math.PI / 180;
                
                const rotatedX = dx * Math.cos(angle) - dy * Math.sin(angle);
                const rotatedY = dx * Math.sin(angle) + dy * Math.cos(angle);
                
                // Check if the rotated point is within the object's bounds
                const absWidth = Math.abs(obj.width);
                const absHeight = Math.abs(obj.height);
                return Math.abs(rotatedX) <= absWidth / 2 && Math.abs(rotatedY) <= absHeight / 2;
            });

            selectedObject = objectsUnderCursor.reduce((highest, current) => {
                return current.layer > highest.layer ? current : highest;
            }, { layer: -999 });

            if (selectedObject && selectedObject.layer !== -999) {
                updateHandles();

                offsetX = pos.x - selectedObject.x;
                offsetY = pos.y - selectedObject.y;
                isDragging = true;
            } else {
                selectedObject = null;
                // console.log('selectedObject = null');
                // Hide handles when no object is selected
                const handles = document.querySelectorAll('.transform-handle, .rotate-handle');
                handles.forEach(handle => {
                    handle.style.display = 'none';
                });
            }
        });

        canvas.addEventListener("mousemove", (event) => {
            if (isDragging && selectedObject) {
                const pos = getCanvasMousePosition(event);
                selectedObject.x = pos.x - offsetX;
                selectedObject.y = pos.y - offsetY;
                updateHandles();
            }
        });

        function onHandleMouseDown(event, type) {
            event.preventDefault();
            event.stopPropagation();

            if (!selectedObject) return;

            currentHandle = event.target.id;
            const pos = getCanvasMousePosition(event);

            if (currentHandle.startsWith('rotate-')) {
                isRotating = true;
                const center = {
                    x: selectedObject.x + selectedObject.width / 2,
                    y: selectedObject.y + selectedObject.height / 2
                };
                initialAngle = Math.atan2(pos.y - center.y, pos.x - center.x) * 180 / Math.PI - selectedObject.angle;
            } else {
                isResizing = true;
                initialMouseX = pos.x;
                initialMouseY = pos.y;
                initialWidth = selectedObject.width;
                initialHeight = selectedObject.height;
                initialX = selectedObject.x;
                initialY = selectedObject.y;
            }
        }

        // RESIZE
        window.addEventListener('mousemove', (event) => {
            if (!selectedObject) return;

            const pos = getCanvasMousePosition(event);
            const center = {
                x: selectedObject.x + selectedObject.width / 2,
                y: selectedObject.y + selectedObject.height / 2
            };

            if (isResizing) {
                // Convert mouse coordinates to object's local space
                const angleRad = -selectedObject.angle * Math.PI / 180;
                const dx = pos.x - center.x;
                const dy = pos.y - center.y;
                
                // Get rotated mouse position
                const rotatedX = dx * Math.cos(angleRad) - dy * Math.sin(angleRad);
                const rotatedY = dx * Math.sin(angleRad) + dy * Math.cos(angleRad);

                // Get initial rotated position
                const initialDx = initialMouseX - center.x;
                const initialDy = initialMouseY - center.y;
                const initialRotatedX = initialDx * Math.cos(angleRad) - initialDy * Math.sin(angleRad);
                const initialRotatedY = initialDx * Math.sin(angleRad) + initialDy * Math.cos(angleRad);

                // Calculate deltas in rotated space
                const deltaX = rotatedX - initialRotatedX;
                const deltaY = rotatedY - initialRotatedY;

                let newWidth = selectedObject.width;
                let newHeight = selectedObject.height;
                let newX = selectedObject.x;
                let newY = selectedObject.y;


                switch (currentHandle) {
                    case 'bottom-right-handle':
                        newWidth = initialWidth + deltaX * 2;
                        newHeight = initialHeight + deltaY * 2;
                        break;

                    case 'bottom-left-handle':
                        newWidth = initialWidth - deltaX * 2;
                        newHeight = initialHeight + deltaY * 2;
                        break;

                    case 'top-right-handle':
                        newWidth = initialWidth + deltaX * 2;
                        newHeight = initialHeight - deltaY * 2;
                        break;

                    case 'top-left-handle':
                        newWidth = initialWidth - deltaX * 2;
                        newHeight = initialHeight - deltaY * 2;
                        break;

                    case 'middle-right-handle':
                        newWidth = initialWidth + deltaX * 2;
                        break;

                    case 'middle-left-handle':
                        newWidth = initialWidth - deltaX * 2;
                        break;

                    case 'middle-top-handle':
                        newHeight = initialHeight - deltaY * 2;
                        break;

                    case 'middle-bottom-handle':
                        newHeight = initialHeight + deltaY * 2;
                        break;
                }

                // Calculate new center position
                const widthDiff = newWidth - initialWidth;
                const heightDiff = newHeight - initialHeight;
                
                // Update the object's position to maintain its center
                newX = center.x - newWidth / 2;
                newY = center.y - newHeight / 2;

                // Apply the changes
                selectedObject.width = newWidth;
                selectedObject.height = newHeight;
                selectedObject.x = newX;
                selectedObject.y = newY;

            } else if (isRotating) {
                const angle = Math.atan2(pos.y - center.y, pos.x - center.x) * 180 / Math.PI - initialAngle;
                selectedObject.angle = angle;
            }

            updateHandles();
        });

        window.addEventListener('mouseup', () => {
            isDragging = false;
            isResizing = false;
            isRotating = false;
        });

        const handles = document.querySelectorAll('.transform-handle, .rotate-handle');
        handles.forEach(handle => {
            handle.addEventListener('mousedown', (event) => onHandleMouseDown(event, handle.id === 'rotate-handle' ? 'rotate' : 'resize'));
            // Initially hide handles
            handle.style.display = 'none';
        });

        function editorLoop() {
            drawObjects();
            if (selectedObject) {
                handles.forEach(handle => handle.style.display = 'block');
                updateHandles();
            }
            requestAnimationFrame(editorLoop);
        }

        editorLoop();
    </script>
</body>
</html>

What I’ve tried:
Implemented resizing using handles:
I added multiple resize handles around the object.
Each handle adjusts the width and height when dragged.

Handled rotation separately:
Rotation is applied using additional handles positioned around the object.
The angle is updated based on the cursor’s position relative to the object’s center.

Adjusted resizing calculations:
I tried updating width and height based on mouse movement.
However, resizing currently happens from the center instead of the edge being dragged.

Attempted coordinate transformations:
I used trigonometric functions to account for rotation.
The issue persists - handles do not behave as expected when resizing.

Maintained object selection state:
Only the selected object should show resize/rotate handles.
Clicking outside should deselect the object.

Despite these attempts, I am struggling to get the resizing to happen correctly from the dragged edge, rather than resizing the object symmetrically from the center.

If i’m grabbing the bottom-right handle, why are you touching the x and y coordinate? A square isnt defined from its center, it’s defined by its top-left corner, X and Y, and a Width and Height. (Not to be confused with a Circle, which is defined from its center)

You need to anchor your changes to the opposite corner of the handle that is currently being manipulated.

If i’m grabbing the bottom-right handle, the X and Y coordinate of the top-left corner shouldnt change, only the Width and Height should change. (Think of X and Y as “the top left” and Width and Height as “the bottom right”)

If i’m grabbing the bottom-left handle, the Y coordinate of the top left corner shouldnt change, but everything else might.

If i’m grabbing the top-right handle, the X coordinate of the top-left corner shouldnt change, but everything else might.

If i’m grabbing the top-left handle, all 4 values will change, but they will change with respect to the current bottom-right corner.

If i’m grabbing the middle:
Top: the Y coordinate and height change.
Right: The Width changes.
Left: The X coordinate and width change.
Bottom: The Height changes.

2 Likes

I fixed some code but I have one more issue now.
Here’s the updated version: JSFiddle
The problem is when the object is rotated (let’s say to 45 degrees), and i’m trying to resize it, it’s not being resized correctly. It’s changing not only it’s size but also it’s position in a weird way (just check the link).

I mean, it works perfect when object is not rotated.

Any thoughts?

By the way, here’s what I’ve updated:

switch (currentHandle) {
    case 'bottom-right-handle':
        newWidth = initialWidth + deltaX;
        newHeight = initialHeight + deltaY;
        break;

    case 'bottom-left-handle':
        newWidth = initialWidth - deltaX;
        newHeight = initialHeight + deltaY;
        newX = initialX + deltaX; // Adjust X position
        break;

    case 'top-right-handle':
        newWidth = initialWidth + deltaX;
        newHeight = initialHeight - deltaY;
        newY = initialY + deltaY; // Adjust Y position
        break;

    case 'top-left-handle':
        newWidth = initialWidth - deltaX;
        newHeight = initialHeight - deltaY;
        newX = initialX + deltaX; // Adjust X
        newY = initialY + deltaY; // Adjust Y
        break;

    case 'middle-right-handle':
        newWidth = initialWidth + deltaX;
        break;

    case 'middle-left-handle':
        newWidth = initialWidth - deltaX;
        newX = initialX + deltaX; // Adjust X
        break;

    case 'middle-top-handle':
        newHeight = initialHeight - deltaY;
        newY = initialY + deltaY; // Adjust Y
        break;

    case 'middle-bottom-handle':
        newHeight = initialHeight + deltaY;
        break;
}

So welcome to the second layer of complexity that you’ve given yourself :wink:

If the object is rotated, all of the calculations you do have to account for the fact that the “bottom right” handle is no longer in the bottom-right location.

pos is an absolute reference to the mouse’s position, but the “bottom right” handle of a rotated object isnt in the same spatial orientation as your maths for x and y and width and height.

EDIT: Pictures, Marc. Pictures help.