Search code examples
javascripthtml5-canvastransformation

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 have:
Animation2.gif

And here's what I expect to have: Animation.gif
My code: JSFiddle

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();
#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%);
}
<div id="canvasContainer">
  <canvas id="editorCanvas" width="600" height="300"></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>


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.


Solution

  • The starting point of the calculations to find the formulae that would achieve the intended behaviour is the fact, described even from the title of the post and apparent in the animation for the desired effect in the original post, that for each resize handle, the fixed corner or edge should be the one opposed to the one that is moved: e.g., if the bottom-right handle is used (to resize from the bottom-right corner), the fixed point should be the top-left corner; for the middle-right handle the left edge should remain unchanged.

    So the calculation is about preserving the coordinates of the respective corners (or the coordinate of the respective edge); it is in fact a matter of simple trigonometric calculation.

    Let's consider the bottom-right corner and start from the already computed (albeit the factor of 2 appears to be wrong in the original code):

    selectedObject.width = initialWidth + deltaX;
    selectedObject.height = initialHeight + deltaY;
    

    The coordinates of the top-left corner (that should not be changed) are (this is just for mathematical computation, not effective code):

    // initially (Eqs 1):
    xTL = initialX;
    yTL = initialY;
    
    // after deltaX x deltaY change (Eqs 2):
    xTL = selectedObject.x - deltaX/2 * Math.cos(angleRad) - deltaY/2 * Math.sin(angleRad);
    yTL = selectedObject.y + deltaX/2 * Math.sin(angleRad) - deltaY/2 * Math.cos(angleRad);
    

    Taking into account that xTL and yTL don't change, (Eqs 1) and (Eqs 2) allow us to calculate selectedObject.x and selectedObject.y (now actual code):

    selectedObject.x = initialX + deltaX/2 * Math.cos(angleRad) + deltaY/2 * Math.sin(angleRad);
    selectedObject.y = initialY - deltaX/2 * Math.sin(angleRad) + deltaY/2 * Math.cos(angleRad);
    

    Similar formulae can be computed for the other corners, while the fixed edges are particular cases where only one coordinate changes.

    However, all cases can covered by a unique set of formulae, if we use two sign variables: sign_x and sign_y - I used the following convention: the value of the sign variable is 1 if by moving the handle towards the fixed point the respective coordinate (x for sign_x and y for sign_y) increases; -1 if by moving the handle towards the fixed point the respective coordinate decreases; and 0 if the coordinate is not involved in the calculation (for the edges). Otherwise sign_x is 1 for left handles, and -1 for right handles, while sign_y is 1 for top handles and -1 for bottom handles.

    With these, we get the final piece of code:

    if (isResizing) {
       // ... code from the original post computin `deltaX` and `deltaY`
       let sign_x = 0, sign_y = 0;
       switch (currentHandle) {
          case 'bottom-right-handle':
             // from bottom-right corner towards the fixed point: top-left corner
             sign_x = -1; sign_y = -1;
             break;
          
          case 'bottom-left-handle':
             // from bottom-left corner towards the fixed point: top-right corner
             sign_x = 1; sign_y = -1;
             break;
          
          case 'top-right-handle':
             // from top-right corner towards the fixed point: bottom-left corner
             sign_x = -1; sign_y = 1;
             break;
          
          case 'top-left-handle':
             // from top-left corner towards the fixed bottom-right corner
             sign_x = 1; sign_y = 1;
             break;
          
          case 'middle-right-handle':
             // from right edge towards the fixed left one (sign_y remains 0)
             sign_x = -1;
             deltaY = 0; // ignore perpendicular cursor motion
             break;
          
          case 'middle-left-handle':
             // from left edge towards the fixed right one
             sign_x = 1;
             deltaY = 0; 
             break;
          
          case 'middle-top-handle':
             // from top edge towards the fixed bottom one
             sign_y = 1;
             deltaX = 0; // ignore perpendicular cursor motion
             break;
          
          case 'middle-bottom-handle':
             // from bottom edge towards the fixed top one
             sign_y = -1;
             deltaX = 0; // ignore perpendicular cursor motion
             break;
       }
       
       selectedObject.width = initialWidth - sign_x * deltaX;
       selectedObject.height = initialHeight - sign_y * deltaY;
       selectedObject.x = initialX + sign_x * deltaX/2 + deltaX/2 * Math.cos(angleRad) + deltaY/2 * Math.sin(angleRad);
       selectedObject.y = initialY + sign_y * deltaY/2 - deltaX/2 * Math.sin(angleRad) + deltaY/2 * Math.cos(angleRad);
    }
    

    The original snippet with this change:

    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, initialX, initialY;
    
    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
          let deltaX = rotatedX - initialRotatedX;
          let deltaY = rotatedY - initialRotatedY;
          
          let sign_x = 0, sign_y = 0;
          switch(currentHandle){
             case 'bottom-right-handle':
                // from bottom-right corner towards the fixed point: top-left corner
                sign_x = -1;
                sign_y = -1;
                break;
             
             case 'bottom-left-handle':
                // from bottom-left corner towards the fixed point: top-right corner
                sign_x = 1;
                sign_y = -1;
                break;
             
             case 'top-right-handle':
                // from top-right corner towards the fixed point: bottom-left corner
                sign_x = -1;
                sign_y = 1;
                break;
             
             case 'top-left-handle':
                // from top-left corner towards the fixed bottom-right corner
                sign_x = 1;
                sign_y = 1;
                break;
             
             case 'middle-right-handle':
                // from right edge towards the fixed left one (sign_y remains 0)
                sign_x = -1; 
                deltaY = 0; // ignore perpendicular cursor motion
                break;
             
             case 'middle-left-handle':
                // from left edge towards the fixed right one
                sign_x = 1;
                deltaY = 0; 
                break;
             
             case 'middle-top-handle':
                // from top edge towards the fixed bottom one
                sign_y = 1;
                deltaX = 0; 
                break;
             
             case 'middle-bottom-handle':
                // from bottom edge towards the fixed top one
                sign_y = -1;
                deltaX = 0; 
                break;
          }
          
          selectedObject.width = initialWidth - sign_x * deltaX;
          selectedObject.height = initialHeight - sign_y * deltaY;
          selectedObject.x = initialX + sign_x * deltaX / 2 + deltaX / 2 * Math.cos(angleRad) + deltaY / 2 * Math.sin(angleRad);
          selectedObject.y = initialY + sign_y * deltaY / 2 - deltaX / 2 * Math.sin(angleRad) + deltaY / 2 * Math.cos(angleRad);
          
          
       }
       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();
    #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%);
    }
    <div id="canvasContainer">
      <canvas id="editorCanvas" width="600" height="300"></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>

    also as a fork of the OP jsFiddle.


    As suggested by @dxUser I tested the achieved behaviour in a special case, as a small additional feature: to maintain the aspect ratio of the rectangle if the shift key is pressed while resizing corners.

    This means that either the effect of user movement of deltaX is used and the value of deltaY is adjusted such that the aspect ratio remains the same, or the other way around. Which of the these two cases is to be applied depends on which one gets the smallest newSize/initialSize factor.

    Here's the relevant piece of code (replacing the final four lines in the last piece of code):

       let newWidth = initialWidth - sign_x * deltaX;
       let newHeight = initialHeight - sign_y * deltaY;
    
       if(event.shiftKey && sign_x !== 0 && sign_y !== 0){
          if(newWidth/initialWidth < newHeight/initialHeight){
             newHeight = newWidth/initialWidth * initialHeight;
             deltaY = sign_y * (initialHeight - newHeight);
          }
          else{
             newWidth = newHeight/initialHeight * initialWidth;
             deltaX = sign_x * (initialWidth - newWidth);
          }
       }
    
       selectedObject.width = newWidth;
       selectedObject.height = newHeight;
       selectedObject.x = initialX + sign_x * deltaX / 2 + deltaX / 2 * Math.cos(angleRad) + deltaY / 2 * Math.sin(angleRad);
       selectedObject.y = initialY + sign_y * deltaY / 2 - deltaX / 2 * Math.sin(angleRad) + deltaY / 2 * Math.cos(angleRad);
    

    the full code in an edit to the previous jsFiddle