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.

1
2
3
4
5
6
7
8
9

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