JS Swappable Metro Quads
Published: 2019-02-22
Updated: 2019-02-23
Web: https://fritzthecat-blog.blogspot.com/2019/02/js-swappable-metro-quads.html
Letting the user rearrange a grid by drag & drop may be quite useful sometimes, especially when the order of things is in need of discussion. In this Blog I will present CSS and JavaScript (no JQuery:-) code showing how such could be done.
Limitations
WARNING: This example will work on HTML-5 browsers only, as it uses CSS variables!
The swappable rectangles need to have a fixed dimension.
The CSS hardcodes the number of rows and columns of the grid, thus adding grid cells will require changing the CSS.
Demo
Try to drag one of the yellow rectangles below. You can move it to another grid cell, then it will be swapped with the one residing there. You can also move it outside the grid and drop it there.
CSS
:root
{
--dock-element-width: 10em; /* CSS variables */
--dock-element-height: 6em; /* to bind dock-element to grid dimension */
}
.dock-dimension
{
width: var(--dock-element-width); /* fixed dimension for all swapable rectangles */
height: var(--dock-element-height);
}
.dock-container /* would be dispensable here, but not in HTML */
{
}
.dock-element
{
background-color: #FFFF99; /* would be transparent else */
box-sizing: border-box; /* needed when having a border */
border: 1px solid gray; /* to better see the element when dragged */
}
This CSS defines two variables that contain width and height of a grid cell. A CSS variable is written like a property, but it starts with two dashes "--". Both variables sit on the :root
pseudo-class, which is a safe place for globals.
Their purpose is to size any HTML-element that declares class="dock-dimension"
. Mind that dereferencing a variable requires the var()
wrapper.
For completeness I also defined the CSS class dock-container
. Then there is some styling for grid cells tagged by class="dock-element"
. Essentially only the dimension is needed in this first CSS part.
.table
{
display: table;
width: calc(var(--dock-element-width) * 3); /* grid contains 3 columns */
height: calc(var(--dock-element-height) * 3); /* grid contains 3 rows */
}
.table > div
{
display: table-row;
}
.table > div > div
{
display: table-cell;
}
This is the table-layout of the grid holding the swappable cells. It needs to be exactly as wide as three cells are, same for height. CSS variables can be used inside the calc()
function, and thanks to HTML-5 even the em
unit survives the calculation.
The following two rule-sets define that div
elements below are table-rows and -cells. You could also use a conventional HTML table, but you must size it like shown here.
HTML
<div class="table">
<div>
<div class="dock-container dock-dimension">
<div class="dock-element dock-dimension">1</div>
</div>
<div class="dock-container dock-dimension">
<div class="dock-element dock-dimension">2</div>
</div>
<div class="dock-container dock-dimension">
<div class="dock-element dock-dimension">3</div>
</div>
</div>
<div>
<div class="dock-container dock-dimension">
<div class="dock-element dock-dimension">4</div>
</div>
<div class="dock-container dock-dimension">
<div class="dock-element dock-dimension">5</div>
</div>
<div class="dock-container dock-dimension">
<div class="dock-element dock-dimension">6</div>
</div>
</div>
<div>
<div class="dock-container dock-dimension">
<div class="dock-element dock-dimension">7</div>
</div>
<div class="dock-container dock-dimension">
<div class="dock-element dock-dimension">8</div>
</div>
<div class="dock-container dock-dimension">
<div class="dock-element dock-dimension">9</div>
</div>
</div>
</div>
Here the CSS classes are used to define all cell-elements that should be swappable, and their containers. They need to be marked with these classes not only due to the CSS dimensions, but also because JavaScript needs to find and manage them.
JavaScript
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | var zIndex = 1; var draggedElement; function moveWhenDragged(element) { var relativeOffset;
element.addEventListener('mousedown', function(event) { if (event.button === 0) { /* is left mouse button */ relativeOffset = { /* remember relative click coordinates */ x: event.clientX - element.offsetLeft, y: event.clientY - element.offsetTop }; element.style.zIndex = zIndex; /* move to top */ zIndex++; } }, true); document.addEventListener('mouseup', function() { relativeOffset = undefined; }, true); document.addEventListener('mousemove', function(event) { if (relativeOffset !== undefined) { event.preventDefault(); element.style.left = Math.round(event.clientX - relativeOffset.x)+"px"; element.style.top = Math.round(event.clientY - relativeOffset.y)+"px"; draggedElement = element; } }, true); }; var dockElements = document.getElementsByClassName("dock-element"); for (var i = 0; i < dockElements.length; i++) moveWhenDragged(dockElements[i]);
|
This will let you drag and drop all dock-elements in case their CSS position
is absolute
. They will be prepared that way by the initialize()
function in code below.
The mouse-down callback inside moveWhenDragged()
will be installed onto all elements that carry the CSS class dock-element
. That function will calculate the offsets relative to the dragged rectangle.
The mouse-up callback will erase that offset-object, so that it is defined just while the user drags. Mind that all listeners except mouse-down get installed onto the document
.
The mouse-move event callback will change the element's page-absolute coordinates, using the event's clientX
and clientY
and the current mouse-down point inside the rectangle.
The global variable zIndex
is to lift any dragged element above all others.
The global variable draggedElement
will be evaluated whenever an element is dragged, it will be used in further code below.
Next comes the hard part, but it's the last :-)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 | function moveToWhenOverDock(containers, elements) { function intersectionArea(r1, r2) { var overlapX = Math.max(0, Math.min(r1.right, r2.right) - Math.max(r1.left, r2.left)); var overlapY = Math.max(0, Math.min(r1.bottom, r2.bottom) - Math.max(r1.top, r2.top)); return overlapX * overlapY; }; function sameTopLeft(containerRect, elementRect) { var DEVIATION = 2; var top1 = Math.round(containerRect.top); var top2 = Math.round(elementRect.top); var left1 = Math.round(containerRect.left); var left2 = Math.round(elementRect.left); return Math.abs(top1 - top2) <= DEVIATION && Math.abs(left1 - left2) <= DEVIATION; }; function indexOfMaximum(array) { var max = 0; var maxIndex = -1; for (var i = 0; i < array.length; i++) if (array[i] > max) max = array[maxIndex = i]; return maxIndex; }; function getDockedElement(container, excludedElement) { var containerRect = container.getBoundingClientRect(); for (var j = 0; j < elements.length; j++) if (elements[j] !== excludedElement && sameTopLeft(containerRect, elements[j].getBoundingClientRect())) return elements[j]; return undefined; }; function findNearestFreeDock(toContainer) { var startIndex = 0; for (var i = 0; i < containers.length; i++) if (containers[i] === toContainer) startIndex = i; var index = startIndex; var toggle = 1; var leftOk = true; var rightOk = true; while (leftOk || rightOk) { leftOk = (index >= 0); rightOk = (index < containers.length); if (leftOk && rightOk && toContainer !== containers[index]) { var container = containers[index]; var isEmpty = true; for (var j = 0; isEmpty && j < elements.length; j++) if (sameTopLeft(container.getBoundingClientRect(), elements[j].getBoundingClientRect())) isEmpty = false; if (isEmpty) return container; } /* change index going once left and once right */ index += (toggle % 2) ? toggle : (-toggle); toggle++; } return undefined; }; function moveToDock(container, element) { /* check if there is an element inside container */ var currentlyDockedElement = getDockedElement(container, element); var firstFreeDock; if (currentlyDockedElement && (firstFreeDock = findNearestFreeDock(container))) move(firstFreeDock, currentlyDockedElement); /* move this to a free container */ move(container, element); }; function move(container, element) { /* animate moving */ element.style.transition = "left 0.7s, top 0.7s"; setTimeout(function() { element.style.transition = ""; }, 700); /* after delay remove animation to enable normal drag */ /* move to container */ var containerRect = container.getBoundingClientRect(); var scrollTop = document.documentElement.scrollTop; var scrollLeft = document.documentElement.scrollLeft; element.style.left = (scrollLeft + containerRect.x)+"px"; element.style.top = (scrollTop + containerRect.y)+"px"; }; /* initialize animations */ function initialize() { for (var j = 0; j < elements.length; j++) { var element = elements[j]; element.style.position = "absolute"; move(element.parentElement, element); } }; document.addEventListener( "mouseup", function() { if ( ! draggedElement ) return; var overlapAreas = []; for (var i = 0; i < containers.length; i++) { var containerRect = containers[i].getBoundingClientRect(); var draggedElementRect = draggedElement.getBoundingClientRect(); overlapAreas[i] = intersectionArea(containerRect, draggedElementRect); } var maxOverlapIndex = indexOfMaximum(overlapAreas); if (maxOverlapIndex >= 0) moveToDock(containers[maxOverlapIndex], draggedElement); draggedElement = undefined; /* release variable */ }, true); initialize(); return initialize; }; window.addEventListener("load", function() { var initialize = moveToWhenOverDock(document.getElementsByClassName("dock-container"), dockElements); window.addEventListener("resize", initialize); });
|
This makes the element residing where you drop go where you dragged from, i.e this implements the swap.
The intersectionArea()
function calculates the intersection of two rectangles. It will be used to decide where the dropped rectangle should go when it hovers several cells.
The sameTopLeft()
function will be used to find the element currently being docked to a container, this won't always be the container's HTML-child. The element will be found by its top-left coordinate that must sit on the one of the container. I needed to use a tolerance DEVIATION here, may be still buggy.
The indexOfMaximum()
function is used to find the biggest overlap area on drop.
The getDockedElement()
function finds the element sitting on a given dock-container, if any. It refers to the module-parameter elements
that contains all dock-elements.
The findNearestFreeDock()
function does a tricky thing. It iterates the module-parameter containers
by jumping incrementally left and right of the initial point, so that it always finds the nearest free dock-container (as you can drag and drop elements outside, there could be several free containers).
The moveToDock()
function will move a dock-element to a container, and move the element currently being there, if any, to the nearest free dock-container.
The move()
function moves a given element to a given container. First it sets an animation onto the element. Then it sets a timeout that will remove the animation after the defined transition interval of 0.7 seconds. Without this removal, any drag-action would also be animated and thus quite slow!
The calculation of the target location uses the predefined getBoundingClientRect()
, and it also considers the current scroll position of the page's viewport.
The initialize()
function sets all dock-elements absolutely positioned. The top
and left
CSS properties will need page-absolute coordinates then. Then it moves any of them to their current place, which is needed to initialize the animation. This will be called on page-ready, and on any resize-event.
Finally a mouse-up event listener gets installed. Here the decision is done to which container a dropped element should go, by finding the maximum intersection area out of all overlapped dock-containers.
The moveToWhenOverDock()
function now calls initialize()
and returns that function. The caller will use this return as resize-event callback function.
moveToWhenOverDock()
must be called when all page coodinates can be calculated by the browser, so we defer its execution to the page-load event.
All Source Code
That's it! For trying out, expand the section below and copy the complete example HTML.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 | <!DOCTYPE HTML> <html>
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"/> <title>Metro Layout</title> <style> /* necessary CSS classes for docking */ :root { --dock-element-width: 10em; /* CSS variables */ --dock-element-height: 6em; /* to bind dock-element to grid dimension */ } .dock-dimension { width: var(--dock-element-width); /* fixed dimension for all swapable rectangles */ height: var(--dock-element-height); } .dock-container /* would be dispensable here, but not in HTML */ { } .dock-element { background-color: #FFFF99; /* would be transparent else */ box-sizing: border-box; /* needed when having a border */ border: 1px solid gray; /* to better see the element when dragged */ } </style> <style> /* container grid */ .table { display: table; width: calc(var(--dock-element-width) * 3); /* grid contains 3 columns */ height: calc(var(--dock-element-height) * 3); /* grid contains 3 rows */ } .table > div { display: table-row; } .table > div > div { display: table-cell; } </style> <style> /* displaying the grid centered */ .centering-container { display: flex; justify-content: center; align-items: center; padding: 1em; } </style> </head>
<body> <div class="centering-container"> <div class="table"> <div> <div class="dock-container dock-dimension"> <div class="dock-element dock-dimension">1</div> </div> <div class="dock-container dock-dimension"> <div class="dock-element dock-dimension">2</div> </div> <div class="dock-container dock-dimension"> <div class="dock-element dock-dimension">3</div> </div> </div> <div> <div class="dock-container dock-dimension"> <div class="dock-element dock-dimension">4</div> </div> <div class="dock-container dock-dimension"> <div class="dock-element dock-dimension">5</div> </div> <div class="dock-container dock-dimension"> <div class="dock-element dock-dimension">6</div> </div> </div> <div> <div class="dock-container dock-dimension"> <div class="dock-element dock-dimension">7</div> </div> <div class="dock-container dock-dimension"> <div class="dock-element dock-dimension">8</div> </div> <div class="dock-container dock-dimension"> <div class="dock-element dock-dimension">9</div> </div> </div> </div> </div>
<!---------------------------------------------->
<script> var zIndex = 1; var draggedElement; function moveWhenDragged(element) { var relativeOffset;
element.addEventListener('mousedown', function(event) { if (event.button === 0) { /* is left mouse button */ relativeOffset = { /* remember relative click coordinates */ x: event.clientX - element.offsetLeft, y: event.clientY - element.offsetTop }; element.style.zIndex = zIndex; /* move to top */ zIndex++; } }, true); document.addEventListener('mouseup', function() { relativeOffset = undefined; }, true); document.addEventListener('mousemove', function(event) { if (relativeOffset !== undefined) { event.preventDefault(); element.style.left = Math.round(event.clientX - relativeOffset.x)+"px"; element.style.top = Math.round(event.clientY - relativeOffset.y)+"px"; draggedElement = element; } }, true); }; var dockElements = document.getElementsByClassName("dock-element"); for (var i = 0; i < dockElements.length; i++) moveWhenDragged(dockElements[i]); function moveToWhenOverDock(containers, elements) { function intersectionArea(r1, r2) { var overlapX = Math.max(0, Math.min(r1.right, r2.right) - Math.max(r1.left, r2.left)); var overlapY = Math.max(0, Math.min(r1.bottom, r2.bottom) - Math.max(r1.top, r2.top)); return overlapX * overlapY; }; function sameTopLeft(containerRect, elementRect) { var DEVIATION = 2; var top1 = Math.round(containerRect.top); var top2 = Math.round(elementRect.top); var left1 = Math.round(containerRect.left); var left2 = Math.round(elementRect.left); return Math.abs(top1 - top2) <= DEVIATION && Math.abs(left1 - left2) <= DEVIATION; }; function indexOfMaximum(array) { var max = 0; var maxIndex = -1; for (var i = 0; i < array.length; i++) if (array[i] > max) max = array[maxIndex = i]; return maxIndex; }; function getDockedElement(container, excludedElement) { var containerRect = container.getBoundingClientRect(); for (var j = 0; j < elements.length; j++) if (elements[j] !== excludedElement && sameTopLeft(containerRect, elements[j].getBoundingClientRect())) return elements[j]; return undefined; }; function findNearestFreeDock(toContainer) { var startIndex = 0; for (var i = 0; i < containers.length; i++) if (containers[i] === toContainer) startIndex = i; var index = startIndex; var toggle = 1; var leftOk = true; var rightOk = true; while (leftOk || rightOk) { leftOk = (index >= 0); rightOk = (index < containers.length); if (leftOk && rightOk && toContainer !== containers[index]) { var container = containers[index]; var isEmpty = true; for (var j = 0; isEmpty && j < elements.length; j++) if (sameTopLeft(container.getBoundingClientRect(), elements[j].getBoundingClientRect())) isEmpty = false; if (isEmpty) return container; } /* change index going once left and once right */ index += (toggle % 2) ? toggle : (-toggle); toggle++; } return undefined; }; function moveToDock(container, element) { /* check if there is an element inside container */ var currentlyDockedElement = getDockedElement(container, element); var firstFreeDock; if (currentlyDockedElement && (firstFreeDock = findNearestFreeDock(container))) move(firstFreeDock, currentlyDockedElement); /* move this to a free container */ move(container, element); }; function move(container, element) { /* animate moving */ element.style.transition = "left 0.7s, top 0.7s"; setTimeout(function() { element.style.transition = ""; }, 700); /* after delay remove animation to enable normal drag */ /* move to container */ var containerRect = container.getBoundingClientRect(); var scrollTop = document.documentElement.scrollTop; var scrollLeft = document.documentElement.scrollLeft; element.style.left = (scrollLeft + containerRect.x)+"px"; element.style.top = (scrollTop + containerRect.y)+"px"; }; /* initialize animations */ function initialize() { for (var j = 0; j < elements.length; j++) { var element = elements[j]; element.style.position = "absolute"; move(element.parentElement, element); } }; document.addEventListener( "mouseup", function() { if ( ! draggedElement ) return; var overlapAreas = []; for (var i = 0; i < containers.length; i++) { var containerRect = containers[i].getBoundingClientRect(); var draggedElementRect = draggedElement.getBoundingClientRect(); overlapAreas[i] = intersectionArea(containerRect, draggedElementRect); } var maxOverlapIndex = indexOfMaximum(overlapAreas); if (maxOverlapIndex >= 0) moveToDock(containers[maxOverlapIndex], draggedElement); draggedElement = undefined; /* release variable */ }, true); initialize(); return initialize; }; window.addEventListener("load", function() { var initialize = moveToWhenOverDock(document.getElementsByClassName("dock-container"), dockElements); window.addEventListener("resize", initialize); }); </script>
</body> </html>
|
ɔ⃝ Fritz Ritzberger, 2019-02-22