forked from marceljuenemann/angular-drag-and-drop-lists
-
Notifications
You must be signed in to change notification settings - Fork 0
/
angular-drag-and-drop-lists.js
590 lines (531 loc) · 29.1 KB
/
angular-drag-and-drop-lists.js
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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
/**
* angular-drag-and-drop-lists v1.4.0
*
* Copyright (c) 2014 Marcel Juenemann [email protected]
* Copyright (c) 2014-2016 Google Inc.
* https://github.com/marceljuenemann/angular-drag-and-drop-lists
*
* License: MIT
*/
angular.module('dndLists', [])
/**
* Use the dnd-draggable attribute to make your element draggable
*
* Attributes:
* - dnd-draggable Required attribute. The value has to be an object that represents the data
* of the element. In case of a drag and drop operation the object will be
* serialized and unserialized on the receiving end.
* - dnd-selected Callback that is invoked when the element was clicked but not dragged.
* The original click event will be provided in the local event variable.
* - dnd-effect-allowed Use this attribute to limit the operations that can be performed. Options:
* - "move": The drag operation will move the element. This is the default.
* - "copy": The drag operation will copy the element. Shows a copy cursor.
* - "copyMove": The user can choose between copy and move by pressing the
* ctrl or shift key. *Not supported in IE:* In Internet Explorer this
* option will be the same as "copy". *Not fully supported in Chrome on
* Windows:* In the Windows version of Chrome the cursor will always be the
* move cursor. However, when the user drops an element and has the ctrl
* key pressed, we will perform a copy anyways.
* - HTML5 also specifies the "link" option, but this library does not
* actively support it yet, so use it at your own risk.
* - dnd-moved Callback that is invoked when the element was moved. Usually you will
* remove your element from the original list in this callback, since the
* directive is not doing that for you automatically. The original dragend
* event will be provided in the local event variable.
* - dnd-canceled Callback that is invoked if the element was dragged, but the operation was
* canceled and the element was not dropped. The original dragend event will
* be provided in the local event variable.
* - dnd-copied Same as dnd-moved, just that it is called when the element was copied
* instead of moved. The original dragend event will be provided in the local
* event variable.
* - dnd-dragstart Callback that is invoked when the element was dragged. The original
* dragstart event will be provided in the local event variable.
* - dnd-dragend Callback that is invoked when the drag operation ended. Available local
* variables are event and dropEffect.
* - dnd-type Use this attribute if you have different kinds of items in your
* application and you want to limit which items can be dropped into which
* lists. Combine with dnd-allowed-types on the dnd-list(s). This attribute
* should evaluate to a string, although this restriction is not enforced.
* - dnd-disable-if You can use this attribute to dynamically disable the draggability of the
* element. This is useful if you have certain list items that you don't want
* to be draggable, or if you want to disable drag & drop completely without
* having two different code branches (e.g. only allow for admins).
* **Note**: If your element is not draggable, the user is probably able to
* select text or images inside of it. Since a selection is always draggable,
* this breaks your UI. You most likely want to disable user selection via
* CSS (see user-select).
*
* CSS classes:
* - dndDragging This class will be added to the element while the element is being
* dragged. It will affect both the element you see while dragging and the
* source element that stays at it's position. Do not try to hide the source
* element with this class, because that will abort the drag operation.
* - dndDraggingSource This class will be added to the element after the drag operation was
* started, meaning it only affects the original element that is still at
* it's source position, and not the "element" that the user is dragging with
* his mouse pointer.
*/
.directive('dndDraggable', ['$parse', '$timeout', 'dndDropEffectWorkaround', 'dndDragTypeWorkaround',
function($parse, $timeout, dndDropEffectWorkaround, dndDragTypeWorkaround) {
return function(scope, element, attr) {
// Set the HTML5 draggable attribute on the element
element.attr("draggable", "true");
// If the dnd-disable-if attribute is set, we have to watch that
if (attr.dndDisableIf) {
scope.$watch(attr.dndDisableIf, function(disabled) {
element.attr("draggable", !disabled);
});
}
/**
* When the drag operation is started we have to prepare the dataTransfer object,
* which is the primary way we communicate with the target element
*/
element.on('dragstart', function(event) {
event = event.originalEvent || event;
// Check whether the element is draggable, since dragstart might be triggered on a child.
if (element.attr('draggable') == 'false') return true;
// Serialize the data associated with this element. IE only supports the Text drag type
event.dataTransfer.setData("Text", angular.toJson(scope.$eval(attr.dndDraggable)));
// Only allow actions specified in dnd-effect-allowed attribute
event.dataTransfer.effectAllowed = attr.dndEffectAllowed || "move";
// Add CSS classes. See documentation above
element.addClass("dndDragging");
$timeout(function() { element.addClass("dndDraggingSource"); }, 0);
// Workarounds for stupid browsers, see description below
dndDropEffectWorkaround.dropEffect = "none";
dndDragTypeWorkaround.isDragging = true;
// Save type of item in global state. Usually, this would go into the dataTransfer
// typename, but we have to use "Text" there to support IE
dndDragTypeWorkaround.dragType = attr.dndType ? scope.$eval(attr.dndType) : undefined;
// Try setting a proper drag image if triggered on a dnd-handle (won't work in IE).
if (event._dndHandle && event.dataTransfer.setDragImage) {
event.dataTransfer.setDragImage(element[0], 0, 0);
}
// Invoke callback
$parse(attr.dndDragstart)(scope, {event: event});
event.stopPropagation();
});
/**
* The dragend event is triggered when the element was dropped or when the drag
* operation was aborted (e.g. hit escape button). Depending on the executed action
* we will invoke the callbacks specified with the dnd-moved or dnd-copied attribute.
*/
element.on('dragend', function(event) {
event = event.originalEvent || event;
// Invoke callbacks. Usually we would use event.dataTransfer.dropEffect to determine
// the used effect, but Chrome has not implemented that field correctly. On Windows
// it always sets it to 'none', while Chrome on Linux sometimes sets it to something
// else when it's supposed to send 'none' (drag operation aborted).
var dropEffect = dndDropEffectWorkaround.dropEffect;
scope.$apply(function() {
switch (dropEffect) {
case "move":
$parse(attr.dndMoved)(scope, {event: event});
break;
case "copy":
$parse(attr.dndCopied)(scope, {event: event});
break;
case "none":
$parse(attr.dndCanceled)(scope, {event: event});
break;
}
$parse(attr.dndDragend)(scope, {event: event, dropEffect: dropEffect});
});
// Clean up
element.removeClass("dndDragging");
$timeout(function() { element.removeClass("dndDraggingSource"); }, 0);
dndDragTypeWorkaround.isDragging = false;
event.stopPropagation();
});
/**
* When the element is clicked we invoke the callback function
* specified with the dnd-selected attribute.
*/
element.on('click', function(event) {
if (!attr.dndSelected) return;
event = event.originalEvent || event;
scope.$apply(function() {
$parse(attr.dndSelected)(scope, {event: event});
});
// Prevent triggering dndSelected in parent elements.
event.stopPropagation();
});
/**
* Workaround to make element draggable in IE9
*/
element.on('selectstart', function() {
if (this.dragDrop) this.dragDrop();
});
};
}])
/**
* Use the dnd-list attribute to make your list element a dropzone. Usually you will add a single
* li element as child with the ng-repeat directive. If you don't do that, we will not be able to
* position the dropped element correctly. If you want your list to be sortable, also add the
* dnd-draggable directive to your li element(s). Both the dnd-list and it's direct children must
* have position: relative CSS style, otherwise the positioning algorithm will not be able to
* determine the correct placeholder position in all browsers.
*
* Attributes:
* - dnd-list Required attribute. The value has to be the array in which the data of
* the dropped element should be inserted.
* - dnd-allowed-types Optional array of allowed item types. When used, only items that had a
* matching dnd-type attribute will be dropable.
* - dnd-disable-if Optional boolean expresssion. When it evaluates to true, no dropping
* into the list is possible. Note that this also disables rearranging
* items inside the list.
* - dnd-horizontal-list Optional boolean expresssion. When it evaluates to true, the positioning
* algorithm will use the left and right halfs of the list items instead of
* the upper and lower halfs.
* - dnd-dragover Optional expression that is invoked when an element is dragged over the
* list. If the expression is set, but does not return true, the element is
* not allowed to be dropped. The following variables will be available:
* - event: The original dragover event sent by the browser.
* - index: The position in the list at which the element would be dropped.
* - type: The dnd-type set on the dnd-draggable, or undefined if unset.
* - external: Whether the element was dragged from an external source.
* - dnd-drop Optional expression that is invoked when an element is dropped on the
* list. The following variables will be available:
* - event: The original drop event sent by the browser.
* - index: The position in the list at which the element would be dropped.
* - item: The transferred object.
* - type: The dnd-type set on the dnd-draggable, or undefined if unset.
* - external: Whether the element was dragged from an external source.
* The return value determines the further handling of the drop:
* - false: The drop will be canceled and the element won't be inserted.
* - true: Signalises that the drop is allowed, but the dnd-drop
* callback already took care of inserting the element.
* - otherwise: All other return values will be treated as the object to
* insert into the array. In most cases you want to simply return the
* item parameter, but there are no restrictions on what you can return.
* - dnd-inserted Optional expression that is invoked after a drop if the element was
* actually inserted into the list. The same local variables as for
* dnd-drop will be available. Note that for reorderings inside the same
* list the old element will still be in the list due to the fact that
* dnd-moved was not called yet.
* - dnd-external-sources Optional boolean expression. When it evaluates to true, the list accepts
* drops from sources outside of the current browser tab. This allows to
* drag and drop accross different browser tabs. Note that this will allow
* to drop arbitrary text into the list, thus it is highly recommended to
* implement the dnd-drop callback to check the incoming element for
* sanity. Furthermore, the dnd-type of external sources can not be
* determined, therefore do not rely on restrictions of dnd-allowed-type.
*
* CSS classes:
* - dndPlaceholder When an element is dragged over the list, a new placeholder child
* element will be added. This element is of type li and has the class
* dndPlaceholder set. Alternatively, you can define your own placeholder
* by creating a child element with dndPlaceholder class.
* - dndDragover Will be added to the list while an element is dragged over the list.
*/
.directive('dndList', ['$parse', '$timeout', 'dndDropEffectWorkaround', 'dndDragTypeWorkaround',
function($parse, $timeout, dndDropEffectWorkaround, dndDragTypeWorkaround) {
return function(scope, element, attr) {
// While an element is dragged over the list, this placeholder element is inserted
// at the location where the element would be inserted after dropping
var placeholder = getPlaceholderElement();
var placeholderNode = placeholder[0];
var listNode = element[0];
placeholder.remove();
var horizontal = attr.dndHorizontalList && scope.$eval(attr.dndHorizontalList);
var externalSources = attr.dndExternalSources && scope.$eval(attr.dndExternalSources);
/**
* The dragenter event is fired when a dragged element or text selection enters a valid drop
* target. According to the spec, we either need to have a dropzone attribute or listen on
* dragenter events and call preventDefault(). It should be noted though that no browser seems
* to enforce this behaviour.
*/
element.on('dragenter', function (event) {
event = event.originalEvent || event;
if (!isDropAllowed(event)) return true;
event.preventDefault();
});
/**
* The dragover event is triggered "every few hundred milliseconds" while an element
* is being dragged over our list, or over an child element.
*/
element.on('dragover', function(event) {
event = event.originalEvent || event;
if (!isDropAllowed(event)) return true;
// First of all, make sure that the placeholder is shown
// This is especially important if the list is empty
if (placeholderNode.parentNode != listNode) {
element.append(placeholder);
}
if (event.target !== listNode) {
// Try to find the node direct directly below the list node.
var listItemNode = event.target;
while (listItemNode.parentNode !== listNode && listItemNode.parentNode) {
listItemNode = listItemNode.parentNode;
}
if (listItemNode.parentNode === listNode && listItemNode !== placeholderNode) {
// If the mouse pointer is in the upper half of the child element,
// we place it before the child element, otherwise below it.
if (isMouseInFirstHalf(event, listItemNode)) {
listNode.insertBefore(placeholderNode, listItemNode);
} else {
listNode.insertBefore(placeholderNode, listItemNode.nextSibling);
}
}
} else {
// This branch is reached when we are dragging directly over the list element.
// Usually we wouldn't need to do anything here, but the IE does not fire it's
// events for the child element, only for the list directly. Therefore, we repeat
// the positioning algorithm for IE here.
if (isMouseInFirstHalf(event, placeholderNode, true)) {
// Check if we should move the placeholder element one spot towards the top.
// Note that display none elements will have offsetTop and offsetHeight set to
// zero, therefore we need a special check for them.
while (placeholderNode.previousElementSibling
&& (isMouseInFirstHalf(event, placeholderNode.previousElementSibling, true)
|| placeholderNode.previousElementSibling.offsetHeight === 0)) {
listNode.insertBefore(placeholderNode, placeholderNode.previousElementSibling);
}
} else {
// Check if we should move the placeholder element one spot towards the bottom
while (placeholderNode.nextElementSibling &&
!isMouseInFirstHalf(event, placeholderNode.nextElementSibling, true)) {
listNode.insertBefore(placeholderNode,
placeholderNode.nextElementSibling.nextElementSibling);
}
}
}
// At this point we invoke the callback, which still can disallow the drop.
// We can't do this earlier because we want to pass the index of the placeholder.
if (attr.dndDragover && !invokeCallback(attr.dndDragover, event, getPlaceholderIndex())) {
return stopDragover();
}
element.addClass("dndDragover");
event.preventDefault();
event.stopPropagation();
return false;
});
/**
* When the element is dropped, we use the position of the placeholder element as the
* position where we insert the transferred data. This assumes that the list has exactly
* one child element per array element.
*/
element.on('drop', function(event) {
event = event.originalEvent || event;
if (!isDropAllowed(event)) return true;
// The default behavior in Firefox is to interpret the dropped element as URL and
// forward to it. We want to prevent that even if our drop is aborted.
event.preventDefault();
// Unserialize the data that was serialized in dragstart. According to the HTML5 specs,
// the "Text" drag type will be converted to text/plain, but IE does not do that.
var data = event.dataTransfer.getData("Text") || event.dataTransfer.getData("text/plain");
var transferredObject;
try {
transferredObject = JSON.parse(data);
} catch(e) {
return stopDragover();
}
// Invoke the callback, which can transform the transferredObject and even abort the drop.
var index = getPlaceholderIndex();
if (attr.dndDrop) {
transferredObject = invokeCallback(attr.dndDrop, event, index, transferredObject);
if (!transferredObject) {
return stopDragover();
}
}
// Insert the object into the array, unless dnd-drop took care of that (returned true).
if (transferredObject !== true) {
scope.$apply(function() {
scope.$eval(attr.dndList).splice(index, 0, transferredObject);
});
}
invokeCallback(attr.dndInserted, event, index, transferredObject);
// In Chrome on Windows the dropEffect will always be none...
// We have to determine the actual effect manually from the allowed effects
if (event.dataTransfer.dropEffect === "none") {
if (event.dataTransfer.effectAllowed === "copy" ||
event.dataTransfer.effectAllowed === "move") {
dndDropEffectWorkaround.dropEffect = event.dataTransfer.effectAllowed;
} else {
dndDropEffectWorkaround.dropEffect = event.ctrlKey ? "copy" : "move";
}
} else {
dndDropEffectWorkaround.dropEffect = event.dataTransfer.dropEffect;
}
// Clean up
stopDragover();
event.stopPropagation();
return false;
});
/**
* We have to remove the placeholder when the element is no longer dragged over our list. The
* problem is that the dragleave event is not only fired when the element leaves our list,
* but also when it leaves a child element -- so practically it's fired all the time. As a
* workaround we wait a few milliseconds and then check if the dndDragover class was added
* again. If it is there, dragover must have been called in the meantime, i.e. the element
* is still dragging over the list. If you know a better way of doing this, please tell me!
*/
element.on('dragleave', function(event) {
event = event.originalEvent || event;
element.removeClass("dndDragover");
$timeout(function() {
if (!element.hasClass("dndDragover")) {
placeholder.remove();
}
}, 100);
});
/**
* Checks whether the mouse pointer is in the first half of the given target element.
*
* In Chrome we can just use offsetY, but in Firefox we have to use layerY, which only
* works if the child element has position relative. In IE the events are only triggered
* on the listNode instead of the listNodeItem, therefore the mouse positions are
* relative to the parent element of targetNode.
*/
function isMouseInFirstHalf(event, targetNode, relativeToParent) {
var mousePointer = horizontal ? (event.offsetX || event.layerX)
: (event.offsetY || event.layerY);
var targetSize = horizontal ? targetNode.offsetWidth : targetNode.offsetHeight;
var targetPosition = horizontal ? targetNode.offsetLeft : targetNode.offsetTop;
targetPosition = relativeToParent ? targetPosition : 0;
return mousePointer < targetPosition + targetSize / 2;
}
/**
* Tries to find a child element that has the dndPlaceholder class set. If none was found, a
* new li element is created.
*/
function getPlaceholderElement() {
var placeholder;
angular.forEach(element.children(), function(childNode) {
var child = angular.element(childNode);
if (child.hasClass('dndPlaceholder')) {
placeholder = child;
}
});
return placeholder || angular.element("<li class='dndPlaceholder'></li>");
}
/**
* We use the position of the placeholder node to determine at which position of the array the
* object needs to be inserted
*/
function getPlaceholderIndex() {
return Array.prototype.indexOf.call(listNode.children, placeholderNode);
}
/**
* Checks various conditions that must be fulfilled for a drop to be allowed
*/
function isDropAllowed(event) {
// Disallow drop from external source unless it's allowed explicitly.
if (!dndDragTypeWorkaround.isDragging && !externalSources) return false;
// Check mimetype. Usually we would use a custom drag type instead of Text, but IE doesn't
// support that.
if (!hasTextMimetype(event.dataTransfer.types)) return false;
// Now check the dnd-allowed-types against the type of the incoming element. For drops from
// external sources we don't know the type, so it will need to be checked via dnd-drop.
if (attr.dndAllowedTypes && dndDragTypeWorkaround.isDragging) {
var allowed = scope.$eval(attr.dndAllowedTypes);
if (angular.isArray(allowed) && allowed.indexOf(dndDragTypeWorkaround.dragType) === -1) {
return false;
}
}
// Check whether droping is disabled completely
if (attr.dndDisableIf && scope.$eval(attr.dndDisableIf)) return false;
return true;
}
/**
* Small helper function that cleans up if we aborted a drop.
*/
function stopDragover() {
placeholder.remove();
element.removeClass("dndDragover");
return true;
}
/**
* Invokes a callback with some interesting parameters and returns the callbacks return value.
*/
function invokeCallback(expression, event, index, item) {
return $parse(expression)(scope, {
event: event,
index: index,
item: item || undefined,
external: !dndDragTypeWorkaround.isDragging,
type: dndDragTypeWorkaround.isDragging ? dndDragTypeWorkaround.dragType : undefined
});
}
/**
* Check if the dataTransfer object contains a drag type that we can handle. In old versions
* of IE the types collection will not even be there, so we just assume a drop is possible.
*/
function hasTextMimetype(types) {
if (!types) return true;
for (var i = 0; i < types.length; i++) {
if (types[i] === "Text" || types[i] === "text/plain") return true;
}
return false;
}
};
}])
/**
* Use the dnd-nodrag attribute inside of dnd-draggable elements to prevent them from starting
* drag operations. This is especially useful if you want to use input elements inside of
* dnd-draggable elements or create specific handle elements. Note: This directive does not work
* in Internet Explorer 9.
*/
.directive('dndNodrag', function() {
return function(scope, element, attr) {
// Set as draggable so that we can cancel the events explicitly
element.attr("draggable", "true");
/**
* Since the element is draggable, the browser's default operation is to drag it on dragstart.
* We will prevent that and also stop the event from bubbling up.
*/
element.on('dragstart', function(event) {
event = event.originalEvent || event;
if (!event._dndHandle) {
// If a child element already reacted to dragstart and set a dataTransfer object, we will
// allow that. For example, this is the case for user selections inside of input elements.
if (!(event.dataTransfer.types && event.dataTransfer.types.length)) {
event.preventDefault();
}
event.stopPropagation();
}
});
/**
* Stop propagation of dragend events, otherwise dnd-moved might be triggered and the element
* would be removed.
*/
element.on('dragend', function(event) {
event = event.originalEvent || event;
if (!event._dndHandle) {
event.stopPropagation();
}
});
};
})
/**
* Use the dnd-handle directive within a dnd-nodrag element in order to allow dragging with that
* element after all. Therefore, by combining dnd-nodrag and dnd-handle you can allow
* dnd-draggable elements to only be dragged via specific "handle" elements. Note that Internet
* Explorer will show the handle element as drag image instead of the dnd-draggable element. You
* can work around this by styling the handle element differently when it is being dragged. Use
* the CSS selector .dndDragging:not(.dndDraggingSource) [dnd-handle] for that.
*/
.directive('dndHandle', function() {
return function(scope, element, attr) {
element.attr("draggable", "true");
element.on('dragstart dragend', function(event) {
event = event.originalEvent || event;
event._dndHandle = true;
});
};
})
/**
* This workaround handles the fact that Internet Explorer does not support drag types other than
* "Text" and "URL". That means we can not know whether the data comes from one of our elements or
* is just some other data like a text selection. As a workaround we save the isDragging flag in
* here. When a dropover event occurs, we only allow the drop if we are already dragging, because
* that means the element is ours.
*/
.factory('dndDragTypeWorkaround', function(){ return {} })
/**
* Chrome on Windows does not set the dropEffect field, which we need in dragend to determine
* whether a drag operation was successful. Therefore we have to maintain it in this global
* variable. The bug report for that has been open for years:
* https://code.google.com/p/chromium/issues/detail?id=39399
*/
.factory('dndDropEffectWorkaround', function(){ return {} });