Magnetism for:
Just a tutorial: the following code is not a React-ready code nor even a fully functional code. I adapted here the magnetism code from my own app, but it is missing some basic code about (among others) mouse events
Px vs %: Everything here is done in px, but it can be adapted easily to work in % inside the Pitchy Habillage. You just need to adapt some coords, like here I will use “canvas.width” and “canvas.height” to indicate the “max possible value” in every direction. In your case is just 100, who stays for “100%”.
And the magnetism tolerance should be adapted to the preview state. Keeping the same % of tolerance correspond to more mouse move px in fullscreen mode.
const containerWidth = canvas.width // 100 if you are working in %
const containerHeight = canvas.height // 100 if you are working in %
Decimals: At the end I will put some Math utils I used during this tutorial, and I use a
decimals
var to choose how much to round the results. If you are working in %, you should keep at least 4 decimals
const decimals = 1 // 1 if px, 4 or more if %
Different actions need different coords to which magnetise, based on the value they update.
And we also define here the magnetism tolerance.
const magnetismCoords = {
x: [], // center X
y: [], // center Y
l: [], // left side
t: [], // top side
r: [], // right side
b: [], // bottom side
}
const rotationMagnetismStep = 45 // degrees
const tolerances = {
dragAndResize: 10, // px || % of the container
rotation: 2.5, // degrees
}
From now on I will assume that all informations about all draggable elements are formatted like this:
const draggableElements = [
{
id: string,
active: boolean,
x: number,
y: number,
width: number,
height: number,
rotation: number,
},
{ ... },
...
]
Disclaimer: Inside the Pitchy Habillage the notion of ‘element is active’ corresponds to the question if the element is currently visible in the preview
And that we will use a local var to enable or disable the ‘resize mode’, and keep the current selected element id :
let resizeMode = false
let magnetismIsActive = true
let currentSelectedId = 'uuid1' // string or false
const canGoOutside = true
// if canGoOutside === true --> elements can go 50% outside the container in each direction (their center is always inside the container)
// if canGoOutside === false --> elements can not go outside the container
Considering that some magnetismCoords
depend on the selected element width, and that they
mush ignore the selected element coords (to not magnetise the dragged element to its previous position), we need to update magnetismCoords
every time that:
To do so, we create a function that does just that, and that can be used the same way in every situation:
const updateMagnetismCoordsIfNeeded = () => {
cleanMagnetismCoords()
if (resizeMode && currentSelectedId) {
const selectedElement = draggableElements.find(e => e.id === currentSelectedId)
if (selectedElement && selectedElement.active) {
const elementHasRotation = !!selectedElement.rotation
magnetismCoords.x = getMagnetismCoordXForElement(currentSelectedId)
magnetismCoords.y = getMagnetismCoordYForElement(currentSelectedId)
if (!elementHasRotation) { // this version does not support left right top bottom allignement for rotated elements
magnetismCoords.l = getMagnetismCoordsLeftForElement(currentSelectedId)
magnetismCoords.t = getMagnetismCoordsTopForElement(currentSelectedId)
magnetismCoords.r = getMagnetismCoordsRightForElement(currentSelectedId)
magnetismCoords.b = getMagnetismCoordsBottomForElement(currentSelectedId)
}
}
}
}
const cleanMagnetismCoords = () => {
magnetismCoords.x = []
magnetismCoords.y = []
magnetismCoords.l = []
magnetismCoords.t = []
magnetismCoords.r = []
magnetismCoords.b = []
}
Each coord will be formed by two values.
The first is the actual value to magnetise and to assign to the element coord.
The second value is the coord where to display the dotted line.
This is necessary because drag and drop changes center x and y values, but we want to display the dotted line where the two elements are touching.
(if it is false, no line is displayed)
So each group of coords (x, y, l, t, r, b) will be an array of arrays:
magnetismCoords.x = [
[ 123, 160 ],
[ 321, 125 ],
[ 200, false ],
]
And under the hood:
const getMagnetismCoordsXForElement = (elementId) => {
// Returns magnetism coords for element centerX position (during drag and drop).
const draggedElement = elements.find(e => e.id === elementId)
const draggedElementHasRotation = !!draggedElement.rotation
const coords = []
coords.push([round(containerWidth / 2, decimals), containerWidth / 2]) // dragged element centerX with container centerX
if (!draggedElementHasRotation) { // this version does not support left right top bottom allignement for rotated elements
coords.push([round(draggedElement.width / 2, decimals), false]) // dragged element centerX with container left side
coords.push([round(containerWidth - draggedElement.width / 2, decimals), false]) // dragged element centerX with container right side
coords.push([round(containerWidth / 2 + draggedElement.width / 2, decimals), containerWidth / 2]) // dragged element left side with container centerX
coords.push([round(containerWidth / 2 - draggedElement.width / 2, decimals), containerWidth / 2]) // dragged element right side with container centerX
}
draggableElements
.filter(e => e.active && e.id !== elementId)
.forEach(e => {
const elementHasRotation = !!e.rotation
coords.push([e.x, e.x]) // dragged element centerX with others elements centerX
if (!draggedElementHasRotation && !elementHasRotation) { // this version does not support left right top bottom allignement for rotated elements
const elementLeft = round(e.x - (e.width / 2), decimals)
const elementRight = round(e.x + (e.width / 2), decimals)
if (elementLeft > 0) { // if element left side is currently visible
coords.push([round(elementLeft + (draggedElement.width / 2), 1), elementLeft]) // dragged element left side with others elements left side
coords.push([round(elementLeft - (draggedElement.width / 2), 1), elementLeft]) // dragged element right side with others elements left side
}
if (elementRight < containerWidth) { // if element right side is currently visible
coords.push([round(elementRight + (draggedElement.width / 2), 1), elementRight]) // dragged element left side with others elements right side
coords.push([round(elementRight - (draggedElement.width / 2), 1), elementRight]) // dragged element right side with others elements right side
}
}
})
return coords
}
const getMagnetismCoordsYForElement = (elementId) => {
// Returns magnetism coords for element centerY position (during drag and drop).
const draggedElement = elements.find(e => e.id === elementId)
const draggedElementHasRotation = !!draggedElement.rotation
ʼʼ
if (!draggedElementHasRotation) { // this version does not support left right top bottom allignement for rotated elements
coords.push([round(draggedElement.height / 2, decimals), false]) // dragged element centerY with container top side
coords.push([round(containerHeight - draggedElement.height / 2, decimals), false]) // dragged element centerY with container bottom side
coords.push([round(containerHeight / 2 + draggedElement.height / 2, decimals), containerHeight / 2]) // dragged element left side with container centerY
coords.push([round(containerHeight / 2 - draggedElement.height / 2, decimals), containerHeight / 2]) // dragged element right side with container centerY
}
draggableElements
.filter(e => e.active && e.id !== elementId)
.forEach(e => {
const elementHasRotation = !!e.rotation
coords.push([e.y, e.y]) // dragged element centerY with others elements centerY
if (!draggedElementHasRotation && !elementHasRotation) { // this version does not support left right top bottom allignement for rotated elements
const elementTop = round(e.y - (e.height / 2), decimals)
const elementBottom = round(e.y + (e.height / 2), decimals)
if (elementTop > 0) { // if element top side is currently visible
coords.push([round(elementTop + (draggedElement.height / 2), 1), elementTop]) // dragged element top side with others elements top side
coords.push([round(elementTop - (draggedElement.height / 2), 1), elementTop]) // dragged element bottom side with others elements top side
}
if (elementBottom < containerHeight) { // if element bottom side is currently visible
coords.push([round(elementBottom + (draggedElement.height / 2), 1), elementBottom]) // dragged element top side with others elements bottom side
coords.push([round(elementBottom - (draggedElement.height / 2), 1), elementBottom]) // dragged element bottom side with others elements bottom side
}
}
})
return coords
}
const getMagnetismCoordsLeftForElement = (elementId) => {
// Returns magnetism coords for dragged element left side (during resize)
const coords = []
coords.push([0, false]) // dragged element left side with container left side
coords.push([round(containerWidth / 2, 1), containerWidth / 2]) // dragged element left side with container centerX
draggableElements
.filter(e => e.active && e.id !== elementId)
.forEach(e => {
const elementLeft = round(e.x - (e.width / 2), decimals)
const elementRight = round(e.x + (e.width / 2), decimals)
if (elementLeft > 0) { // if element left side is currently visible
coords.push([elementLeft, elementLeft]) // dragged element left side with others elements left side
}
if (elementRight < containerWidth) { // if element right side is currently visible
coords.push([elementRight, elementRight]) // dragged element left side with others elements right side
}
})
return coords
}
const getMagnetismCoordsRightForElement = (elementId) => {
// Returns magnetism coords for dragged element right side (during resize)
const coords = []
coords.push([containerWidth, false]) // dragged element right side with container right side
coords.push([round(containerWidth / 2, 1), containerWidth / 2]) // dragged element right side with container centerX
draggableElements
.filter(e => e.active && e.id !== elementId)
.forEach(e => {
const elementLeft = round(e.x - (e.width / 2), decimals)
const elementRight = round(e.x + (e.width / 2), decimals)
})
if (elementLeft > 0) { // if element left side is currently visible
coords.push([elementLeft, elementLeft]) // dragged element right side with others elements left side
}
if (elementRight < containerWidth - 1) { // if element right side is currently visible
coords.push([elementRight, elementRight]) // dragged element left side with others elements right side
}
}
})
return coords
}
const getMagnetismCoordsTopForElement = (elementId) => {
// Returns magnetism coords for dragged element top side (during resize)
const coords = []
coords.push([0, false]) // dragged element top side with container top side
coords.push([round(containerHeight / 2, 1), containerHeight / 2]) // dragged element top side with container centerY
draggableElements
.filter(e => e.active && e.id !== elementId)
.forEach(e => {
const elementTop = round(e.y - (e.height / 2), decimals)
const elementBottom = round(e.y + (e.height / 2), decimals)
if (elementTop > 0) { // if element top side is currently visible
coords.push([elementTop, elementTop]) // dragged element top side with others elements top side
}
if (elementBottom < containerHeight) { // if element bottom side is currently visible
coords.push([elementBottom, elementBottom]) // dragged element top side with others elements bottom side
}
})
return coords
}
const getMagnetismCoordsBottomForElement = (elementId) => {
// Returns magnetism coords for dragged element bottom side (during resize)
const coords = []
coords.push([containerHeight, false]) // dragged element bottom side with container bottom side
coords.push([round(containerHeight / 2, 1), containerHeight / 2]) // dragged element bottom side with container centerY
draggableElements
.filter(e => e.active && e.id !== elementId)
.forEach(e => {
const elementTop = round(e.y - (e.height / 2), decimals)
const elementBottom = round(e.y + (e.height / 2), decimals)
if (elementTop > 0) { // if element top side is currently visible
coords.push([elementTop, elementTop]) // dragged element bottom side with others elements top side
}
if (elementBottom < containerHeight) { // if element bottom side is currently visible
coords.push([elementBottom, elementBottom]) // dragged element bottom side with others elements bottom side
}
})
return coords
}
Note that
getMagnetismCoordLeftForElement
andgetMagnetismCoordRightForElement
have a lot of code in common; as well asgetMagnetismCoordTopForElement
withgetMagnetismCoordBottomForElement
.
I choose to keep the code this way to have the ability to add more comments about every case. You could optimize them if you want.
Now we can update magnetism coords every time it is needed:
Adapt this code to your app:
const onElementSelect = (elementId) => {
currentSelectedId = elementId
updateMagnetismCoordsIfNeeded() // <--
}
const onToggleActiveElement = (elementId) => {
const element = draggableElements.find(e => e.id = elementId)
element.active = !element.active
updateMagnetismCoordsIfNeeded() // <--
}
const onToggleResizeMode = () => {
resizeMode = !resizeMode
updateMagnetismCoordsIfNeeded() // <--
}
Adapt this code to your app
let dragStartX = -1, dragStartY = -1
let elementCoordXAtMouseDown = -1, elementCoordYAtMouseDown = -1
const onElementMouseDown = (event, elementId) => {
const element = draggableElements.find(e => e.id === elementId)
if (currentSelectedId !== elementId) {
onElementSelect(elementId)
}
dragStartX = event.clientX
dragStartY = event.clientY
elementCoordXAtMouseDown = element.x
elementCoordYAtMouseDown = element.y
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseEnd)
}
const onMouseMove = (event) => {
const element = draggableElements.find(e => e.id === currentSelectedId)
const dragDeltaX = event.clientX - dragStartX
const dragDeltaY = event.clientY - dragStartY
let newElementX = elementCoordXAtMouseDown + dragDeltaX
let newElementY = elementCoordYAtMouseDown + dragDeltaY
let magnetismLineX, magnetismLineY
if (magnetismIsActive) {
[newElementX, magnetismLineX] = findValueWithMagnetism(newElementX, magnetismCoords.x, tolerances.dragAndResize);
[newElementY, magnetismLineY] = findValueWithMagnetism(newElementY, magnetismCoords.y, tolerances.dragAndResize);
}
if (canGoOutside) {
newElementX = getNumberInBetween(0, newElementX, containerWidth, decimals)
newElementY = getNumberInBetween(0, newElementY, containerHeight, decimals)
} else {
newElementX = getNumberInBetween(element.width / 2, newElementX, containerWidth - (element.width / 2), decimals)
newElementY = getNumberInBetween(element.height / 2, newElementY, containerHeight - (element.height / 2), decimals)
}
const element = draggableElements.find(e => e.id === elementId)
element.x = newElementX
element.y = newElementY
/*
TODO in your app code logic:
- if (magnetismLineX) --> show vertical dotted line
- else ==> hide vertical dotted line
- if (magnetismLineY) --> show horizontal dotted line
- else ==> hide horizontal dotted line
- update element.x = newElementX and element.y = newElementY
*/
}
const onMouseEnd = (event) => {
updateMagnetismCoordsIfNeeded()
/*
TODO in your app code logic:
- hide vertical and horizontal dotted lines
*/
}
Making a good ratio resize while taking into consideration the element rotation is a big task by itself, so it deserves its own chapter.
After done that, we will add magnetism on top.
Habillage: This next code should replace what I had done in
getDotsDragHandlers()
insidepages/editing/components/ResizingDots/utils.js
const DEFAULT_MOVE_WAITING_TIME = 0 // ms
const ELEMENT_MIN_SIDE = 10 // element side min % of the container
export const getResizeBulletsHandlers = (
TL, TR, BR, BL,
T, R, B, L, RT,
C, // container
canGoOutside,
elementCurrentState,
onStart, onChange, onEnd,
waitingTime = DEFAULT_MOVE_WAITING_TIME,
) => {
const containerRect = {}
let dragEnabled = true
let lastSavedState
let initialRotation, onTouchMove
let startDotX, startDotY
let oppositeX, oppositeY
let bulletCoordXAtMouseDown, bulletCoordYAtMouseDown
let contentCenterX, contentCenterY
let resizeAngleCosFraction, resizeAngleSinFraction
const handleResizeStart = (e, point, oppositePoint, selectedResize) => {
preventDefault(e)
onStart()
containerRect = getDomRect(C)
const [mouseCurrentX, mouseCurrentY] = getMouseCoordsInsideContainer(e)
const initRadians = convertAngleDegreesToRadians(elementCurrentState.rotation)
resizeAngleCosFraction = Math.cos(initRadians)
resizeAngleSinFraction = Math.sin(initRadians);
[startDotX, startDotY] = getDotCoordsInsideContainer(point);
[oppositeX, oppositeY] = getDotCoordsInsideContainer(oppositePoint);
[bulletCoordXAtMouseDown, bulletCoordYAtMouseDown] = getPointProjectionOnLine(startDotX, startDotY, oppositeX, oppositeY, mouseCurrentX, mouseCurrentY)
if (bulletCoordXAtMouseDown === false || bulletCoordYAtMouseDown === false) {
bulletCoordXAtMouseDown = mouseCurrentX
bulletCoordYAtMouseDown = mouseCurrentY
}
onTouchMove = selectedResize
document.addEventListener('mousemove', onTouchMove)
document.addEventListener('mouseup', onTouchEnd)
}
const handleResizeChange = (willMoveLeftSide, willMoveTopSide, willMoveCenterX, willMoveCenterY, ratioResize, e) => {
preventDefault(e)
if (!dragEnabled) return
let [mouseCurrentX, mouseCurrentY] = getMouseCoordsInsideContainer(e)
if (!canGoOutside) {
[mouseCurrentX, mouseCurrentY] = adjustCoordsIn
}
let bulletCurrentCoordX = mouseCurrentX
let bulletCurrentCoordY = mouseCurrentY
if (ratioResize) {
[bulletCurrentCoordX, bulletCurrentCoordY] = getPointProjectionOnLine(startDotX, startDotY, oppositeX, oppositeY, mouseCurrentX, mouseCurrentY)
if (bulletCurrentCoordX === false || bulletCurrentCoordY === false) return
}
const bulletDragX = (bulletCurrentCoordX - bulletCoordXAtMouseDown)
const bulletDragY = (bulletCurrentCoordY - bulletCoordYAtMouseDown)
const bulletDragXConsideringRotation = resizeAngleCosFraction * bulletDragX + resizeAngleSinFraction * bulletDragY
const bulletDragYConsideringRotation = resizeAngleCosFraction * bulletDragY - resizeAngleSinFraction * bulletDragX
let newW = elementCurrentState.width
let newH = elementCurrentState.height
let newX = elementCurrentState.x
let newY = elementCurrentState.y
if (willMoveCenterX) {
if (willMoveLeftSide) {
newW = elementCurrentState.width - bulletDragXConsideringRotation
} else {
newW = elementCurrentState.width + bulletDragXConsideringRotation
}
newX += 0.5 * bulletDragXConsideringRotation * resizeAngleCosFraction
newY += 0.5 * bulletDragXConsideringRotation * resizeAngleSinFraction
}
if (willMoveCenterY) {
if (willMoveTopSide) {
newH = elementCurrentState.height - bulletDragYConsideringRotation
} else {
newH = elementCurrentState.height + bulletDragYConsideringRotation
}
newX -= 0.5 * bulletDragYConsideringRotation * resizeAngleSinFraction
newY += 0.5 * bulletDragYConsideringRotation * resizeAngleCosFraction
}
update({ x: newX, y: newY, w: newW, h: newH }, willMoveLeftSide, willMoveTopSide, willMoveCenterY && !willMoveLeftSide, willMoveCenterY && !willMoveTopSide, false)
}
const handleRotationStart = (e) => {
preventDefault(e)
onStart()
containerRect = getDomRect(C)
const [tlX, tlY] = getDotCoordsInsideContainer(TL)
const [brX, brY] = getDotCoordsInsideContainer(BR);
[contentCenterX, contentCenterY] = getMiddlePointCoords(tlX, tlY, brX, brY)
const [mouseCurrentX, mouseCurrentY] = getMouseCoordsInsideContainer(e)
initialRotation = getAngleDegreesBetweenTwoPoints(contentCenterX, contentCenterY, mouseCurrentX, mouseCurrentY)
onTouchMove = handleRotationChange
document.addEventListener('mousemove', onTouchMove)
document.addEventListener('mouseup', onTouchEnd)
}
const handleRotationChange = (e) => {
preventDefault(e)
if (!dragEnabled) return
const [mouseCurrentX, mouseCurrentY] = getMouseCoordsInsideContainer(e)
const deltaRotation = initialRotation - getAngleDegreesBetweenTwoPoints(contentCenterX, contentCenterY, mouseCurrentX, mouseCurrentY)
update({ r: round(elementCurrentState.rotation - deltaRotation, 2) }, false, false, false, false, true)
}
const onTouchEnd = (e) => {
preventDefault(e)
onEnd(lastSavedState || false)
document.removeEventListener('mousemove', onTouchMove)
document.removeEventListener('mouseup', onTouchEnd)
lastSavedState = initialRotation = onTouchMove = startDotX = startDotY = oppositeX = oppositeY = bulletCoordXAtMouseDown = bulletCoordYAtMouseDown = contentCenterX = contentCenterY = resizeAngleCosFraction = resizeAngleSinFraction = undefined
dragEnabled = true
}
const enable = () => dragEnabled = true
const update = (updatedStateKeys, changedLeftSide, changedTopSide, changedRightSide, changedBottomSide, changedRotatiom) => {
const newState = {
...elementCurrentState,
...updatedStateKeys,
}
if (newState.w < ELEMENT_MIN_SIDE || newState.h < ELEMENT_MIN_SIDE) return
newState.x = round(newState.x, decimals)
newState.y = round(newState.y, decimals)
newState.w = round(newState.w, decimals)
newState.h = round(newState.h, decimals)
newState.r = round(newState.r, decimals)
dragEnabled = (waitingTime === 0)
if (!isEqual(newState, lastSavedState)) {
lastSavedState = newState
onChange(newState, changedLeftSide, changedTopSide, changedRightSide, changedBottomSide, changedRotatiom)
}
dragEnabled === false && setTimeout(enable, waitingTime)
}
const getMouseCoordsInsideContainer = (e) => ([e.clientX - containerRect.left, e.clientY - containerRect.top])
const getDotCoordsInsideContainer = Dot => {
if (canGoOutside) {
const { centerX, centerY } = getDomRect(Dot, containerRect.left, containerRect.top)
return [centerX, centerY]
} else {
const dotRect = getDomRect(Dot, containerRect.left, containerRect.top)
let x = dotRect.centerX
let y = dotRect.centerY
if (Dot === TL || Dot === L || Dot === BL) {
x = dotRect.left + 1
} else if (Dot === TR || Dot === R || Dot === BR) {
x = dotRect.right - 1
}
if (Dot === TL || Dot === T || Dot === TR) {
y = dotRect.top + 1
} else if (Dot === BL || Dot === B || Dot === BR) {
y = dotRect.bottom - 1
}
return [x, y]
}
}
const handleResizeChangeR = handleResizeChange.bind({}, false, false, true, false, false)
const handleResizeChangeL = handleResizeChange.bind({}, true, false, true, false, false)
const handleResizeChangeT = handleResizeChange.bind({}, false, true, false, true, false)
const handleResizeChangeB = handleResizeChange.bind({}, false, false, false, true, false)
const handleResizeChangeTL = handleResizeChange.bind({}, true, true, true, true, true)
const handleResizeChangeTR = handleResizeChange.bind({}, false, true, true, true, true)
const handleResizeChangeBR = handleResizeChange.bind({}, false, false, true, true, true)
const handleResizeChangeBL = handleResizeChange.bind({}, true, false, true, true, true)
return {
topLeft: e => handleResizeStart(e, TL, BR, handleResizeChangeTL),
topRight: e => handleResizeStart(e, TR, BL, handleResizeChangeTR),
bottomRight: e => handleResizeStart(e, BR, TL, handleResizeChangeBR),
bottomLeft: e => handleResizeStart(e, BL, TR, handleResizeChangeBL),
top: e => handleResizeStart(e, T, B, handleResizeChangeT),
right: e => handleResizeStart(e, R, L, handleResizeChangeR),
bottom: e => handleResizeStart(e, B, T, handleResizeChangeB),
left: e => handleResizeStart(e, L, R, handleResizeChangeL),
rotation: handleRotationStart,
}
}
Now that we converted mouse coords to resize coords thanks to ResizeBulletsHandlers, we can proceed to magnetise them with others elements coords.
const onResizeStart = () => {
// do something here if you need
}
const onResizeChange = (newResizeState, leftChanged, topChanged, rightChanged, bottomChanged, rotationChanged) => {
if (magnetismIsActive) {
if (rotationChanged) { // if I'm dragging the rotation bullet, I'm sure there is no resize at the same time
const newRotation = magnetizeAngleDegrees(newResizeState.r, tolerances.rotation, rotationMagnetismStep)
if (Math.abs(newRotation) % rotationMagnetismStep === 0) {
/*
TODO in your app code logic:
- show dotted line passing by the point:
- x: (newResizeState.x || 0)
- y: (newResizeState.y || 0)
- and with a rotation angle of: `${newRotation}deg`
*/
} else {
/*
TODO in your app code logic:
- hide rotation dotted lines
*/
}
newResizeState.r = newRotation
} else {
let lineXCoord = 0, lineYCoord = 0;
[newResizeState, lineXCoord, lineYCoord] = applyRatioResizeMagnetism(newResizeState, magnetismCoords, leftChanged, topChanged, rightChanged, bottomChanged)
/*
TODO in your app code logic:
- if (lineXCoord) ==> show vertical dotted line with `left: ${lineXCoord}{px || %};`
- else ==> hide vertical dotted line
- if (lineYCoord) ==> show horizontal dotted line with `top: ${lineYCoord}{px || %};`
- else ==> hide horizontal dotted line
*/
}
}
/*
TODO in your app code logic:
- update selected element css position and state:
- x: newResizeState.x (element center x)
- y: newResizeState.y (element center y)
- width: newResizeState.w
- height: newResizeState.h
- rotation: `${newResizeState.r}deg`
*/
}
const onResizeEnd = () => {
updateMagnetismCoordsIfNeeded()
/*
TODO in your app code logic:
- hide all dotted lines
*/
}
And here is where the ‘resize magnetism magic’ happens.
The problem is that doing a resize from a corner will change two sides at the same time, and both of these changes can be affected by the magnetism.
If we don’t handle this case, the resized element will most likely change its ratio, because the two sides could make two different “jumps” to get to their new value.
const applyRatioResizeMagnetism = (resizeState, magnetismCoords, leftChanged, topChanged, rightChanged, bottomChanged) => {
const ratio = resizeState.w / resizeState.h
const draggedSides = {}
let lineXCoord = 0, lineYCoord = 0
// 1) So my solution is to first check how many sides are dragged, and how many are affected by magnetism...
if (leftChanged) {
const dragValue = resizeState.x - resizeState.w / 2
const [newValue, magnetismLineCoord, magnetised] = findValueWithMagnetism(dragValue, magnetismCoords.l, tolerances.dragAndResize)
if (magnetised) {
draggedSides.left = {
dragValue,
newValue,
magnetismLineCoord,
diff: newValue - dragValue,
}
}
} else if (rightChanged) {
const dragValue = resizeState.x + resizeState.w / 2
const [newValue, magnetismLineCoord, magnetised] = findValueWithMagnetism(dragValue, magnetismCoords.r, tolerances.dragAndResize)
if (magnetised) {
draggedSides.right = {
dragValue,
newValue,
magnetismLineCoord,
diff: newValue - dragValue,
}
}
}
if (topChanged) {
const dragValue = resizeState.y - resizeState.h / 2
const [newValue, magnetismLineCoord, magnetised] = findValueWithMagnetism(dragValue, magnetismCoords.t, tolerances.dragAndResize)
if (magnetised) {
draggedSides.top = {
dragValue,
newValue,
magnetismLineCoord,
diff: newValue - dragValue,
}
}
} else if (bottomChanged) {
const dragValue = resizeState.y + resizeState.h / 2
const [newValue, magnetismLineCoord, magnetised] = findValueWithMagnetism(dragValue, magnetismCoords.b, tolerances.dragAndResize)
if (magnetised) {
draggedSides.bottom = {
dragValue,
newValue,
magnetismLineCoord,
diff: newValue - dragValue,
}
}
}
// 2) then choose to which magnetised side I want to give priority (if any)
// and resize all the others element's sides based on the chosen one
// we can choose to give priority to the smallest or the biggest 'magnetism jump'
const givePriorityToTheSmallestJump = false
if (Object.keys(draggedSides).length) {
let chosedJump
if (givePriorityToTheSmallestJump) {
chosedJump = Math.min(...Object.values(draggedSides).map(side => side.diff))
} else {
chosedJump = Math.max(...Object.values(draggedSides).map(side => side.diff))
}
const chosenMagnetisedSide = Object.keys(draggedSides).find(key => draggedSides[key].diff === chosedJump)
if (chosenMagnetisedSide === 'left') {
resizeState.x = round(resizeState.x + (draggedSides.left.diff / 2), 1)
resizeState.w = round((resizeState.x - draggedSides.left.newValue) * 2, 1)
lineXCoord = round(draggedSides.left.magnetismLineCoord, 0)
if (topChanged || bottomChanged) {
const newHeight = round(resizeState.w / ratio, 1)
const heightDiff = newHeight - resizeState.h
resizeState.h = newHeight
if (topChanged) {
resizeState.y = round(resizeState.y - heightDiff / 2, 1)
} else {
resizeState.y = round(resizeState.y + heightDiff / 2, 1)
}
}
} else if (chosenMagnetisedSide === 'right') {
resizeState.x = round(resizeState.x + (draggedSides.right.diff / 2), 1)
resizeState.w = round((draggedSides.right.newValue - resizeState.x) * 2, 1)
lineXCoord = round(draggedSides.right.magnetismLineCoord, 0)
if (topChanged || bottomChanged) {
const newHeight = round(resizeState.w / ratio, 1)
const heightDiff = newHeight - resizeState.h
resizeState.h = newHeight
if (topChanged) {
resizeState.y = round(resizeState.y - heightDiff / 2, 1)
} else {
resizeState.y = round(resizeState.y + heightDiff / 2, 1)
}
}
} else if (chosenMagnetisedSide === 'top') {
resizeState.y = round(resizeState.y + (draggedSides.top.diff / 2), 1)
resizeState.h = round((resizeState.y - draggedSides.top.newValue) * 2, 1)
lineYCoord = round(draggedSides.top.magnetismLineCoord, 0)
if (leftChanged || rightChanged) {
const newWidth = round(resizeState.h * ratio, 1)
const widthDiff = newWidth - resizeState.w
resizeState.w = newWidth
if (leftChanged) {
resizeState.x = round(resizeState.x - widthDiff / 2, 1)
} else {
resizeState.x = round(resizeState.x + widthDiff / 2, 1)
}
}
} else if (chosenMagnetisedSide === 'bottom') {
resizeState.y = round(resizeState.y + (draggedSides.bottom.diff / 2), 1)
resizeState.h = round((draggedSides.bottom.newValue - resizeState.y) * 2, 1)
lineYCoord = round(draggedSides.bottom.magnetismLineCoord, 0)
if (leftChanged || rightChanged) {
const newWidth = round(resizeState.h * ratio, 1)
const widthDiff = newWidth - resizeState.w
resizeState.w = newWidth
if (leftChanged) {
resizeState.x = round(resizeState.x - widthDiff / 2, 1)
} else {
resizeState.x = round(resizeState.x + widthDiff / 2, 1)
}
}
}
}
return [resizeState, lineXCoord, lineYCoord]
}
And that’s finally it! :)
I didn’t put much comments in here because you never need to touch this sh*t.
Functions’ name are quite explicit about what the function does.
const round = (number, decimals = 0) => {
const factor = decimals ? Math.pow(10, decimals) : 1
return Math.round(number * factor) / factor
}
// Returns [nextValue:number, lineCoord:number|false, hasSnapped:boolean]
const findValueWithMagnetism = (currentValue, magnetimsValues, tolerance) => {
for (let i = 0; i < magnetimsValues.length; i++) {
if (Math.abs(currentValue - magnetimsValues[i][0]) <= tolerance) {
return [magnetimsValues[i][0], magnetimsValues[i][1], true]
}
}
return [currentValue, false, false]
}
const getNumberInBetween = (a, b, c, decimals = 4) => round([a, b, c].sort(arrayOrderNumberUp)[1], decimals)
const getMiddlePointCoords = (x1, y1, x2, y2, decimals = 0) => ([round((x1 + x2) / 2, decimals), round((y1 + y2) / 2, decimals)])
const getAngleDegreesBetweenTwoPoints = (x1, y1, x2, y2) => convertAngleRadiansToDegrees(getAngleRadiansBetweenTwoPoints(x1, y1, x2, y2))
const convertAngleDegreesToRadians = (deg) => deg * Math.PI / 180
const convertAngleRadiansToDegrees = (rad) => rad * 180 / Math.PI
const getSlopeCoefficientBetweenTwoPoints = (x1, y1, x2, y2) => (y2 - y1) / (x2 - x1)
const getPerpendicularLineFunctionPassingByPoint = (slope, x1, y1) => (x) => (-1 / slope) * (x - x1) + y1
const getIntersectionBetween4Points = (x1, y1, x2, y2, x3, y3, x4, y4, d = 0) => {
// points {x1, y1} and {x2, y2} define the first line
// points {x3, y3} and {x4, y4} define the second line
let ua, denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1)
if (denom === 0) {
return [false, false]
}
ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom
return [
round(x1 + ua * (x2 - x1), d),
round(y1 + ua * (y2 - y1), d),
]
}
const getPointProjectionOnLine = (x1, y1, x2, y2, x3, y3) => {
// points {x1, y1} and {x2, y2} define the line
// point {x3, y3} is the point to project on the line
let x4, y4
const slopeLine1 = getSlopeCoefficientBetweenTwoPoints(x1, y1, x2, y2)
if (slopeLine1 === 0) {
x4 = x3
y4 = y1
} else if (isFinite(slopeLine1)) {
const line2 = getPerpendicularLineFunctionPassingByPoint(slopeLine1, x3, y3)
if (x3 === x1) {
x4 = x2
} else {
x4 = x1
}
y4 = line2(x4)
} else {
x4 = x1
y4 = y3
}
return getIntersectionBetween4Points(x1, y1, x2, y2, x3, y3, x4, y4)
}
const getAngleRadiansBetweenTwoPoints = (x1, y1, x2, y2) => {
const m1 = x2 - x1
const m2 = y2 - y1
if (m1 > 0 && m2 > 0) { // first quadrant
return (Math.atan(m2 / m1))
} else if (m1 < 0 && m2 > 0) { // second quadrant
return (Math.atan(m2 / m1) + Math.PI)
} else if (m1 < 0 && m2 < 0) { // third quadrant
return (Math.atan(m2 / m1) + Math.PI)
} else if (m1 > 0 && m2 < 0) { // fourth quadrant
return (Math.atan(m2 / m1) + Math.PI * 2)
} else {
// multiples of 90
if (m1 === 0) {
if (m2 > 0) {
return Math.PI / 2
} else {
return Math.PI * 1.5
}
} else {
if (m1 > 0) {
return 0
} else {
return Math.PI
}
}
}
}
const magnetizeAngleDegrees = (degrees, tolerance = 3, interval = 45) => {
const delta = degrees % interval
if (Math.abs(Math.trunc(delta)) < tolerance) {
return degrees - delta
}
if (Math.abs(Math.trunc(delta)) > interval - tolerance) {
if (delta > 0) {
return Math.round(degrees + interval - delta)
} else {
return Math.round(degrees - interval - delta)
}
}
return degrees
}
const preventDefault = (e) => {
if (e) {
e.preventDefault()
e.stopPropagation()
}
}