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:
And here's what I expect to have:
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>
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.
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