Passed
Push — trunk ( 71aa83...cb9357 )
by Christian
14:16 queued 12s
created

dragdrop.directive.ts ➔ update   B

Complexity

Conditions 6

Size

Total Lines 26
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
eloc 21
dl 0
loc 26
rs 8.4426
c 0
b 0
f 0
1
/**
2
 * @package admin
3
 */
4
5
const { types } = Shopware.Utils;
6
7
interface DropConfig<DATA = unknown> {
8
    dragGroup: number|string,
9
    droppableCls: string,
10
    validDropCls: string,
11
    invalidDropCls: string,
12
    // eslint-disable-next-line no-use-before-define
13
    validateDrop: null|((dragConfigData: DragConfig<DATA>['data'], dropConfigData: DropConfig<DATA>['data']) => boolean),
14
    // eslint-disable-next-line no-use-before-define
15
    onDrop: null|((dragConfigData: DragConfig<DATA>['data'], dropConfigData: DropConfig<DATA>['data']) => void),
16
    data: null|DATA,
17
}
18
19
interface DragConfig<DATA = unknown> {
20
    delay: number,
21
    dragGroup: number|string,
22
    draggableCls: string,
23
    draggingStateCls: string,
24
    dragElementCls: string,
25
    validDragCls: string,
26
    invalidDragCls: string,
27
    preventEvent: boolean,
28
    validateDrop: null|((dragConfigData: DragConfig<DATA>['data'], dropConfigData: DropConfig<DATA>['data']) => boolean),
29
    validateDrag: null|((dragConfigData: DragConfig<DATA>['data'], dropConfigData: DropConfig<DATA>['data']) => boolean),
30
    onDragStart: null|((dragConfig: DragConfig<DATA>, el: HTMLElement, dragElement: HTMLElement) => void),
31
    onDragEnter:
32
        null|((dragConfigData: DragConfig<DATA>['data'], dropConfigData: DropConfig<DATA>['data'], valid?: boolean) => void),
33
    onDragLeave: null|((dragConfigData: DragConfig<DATA>['data'], dropConfigData: DropConfig<DATA>['data']) => void),
34
    onDrop: null|((dragConfigData: DragConfig<DATA>['data'], dropConfigData: DropConfig<DATA>['data']) => void),
35
    data: null|DATA,
36
    disabled: boolean,
37
}
38
39
interface DropZone {
40
    el: HTMLElement,
41
    dropConfig: DropConfig,
42
}
43
44
interface DragHTMLElement extends HTMLElement {
45
    dragConfig?: DragConfig;
46
    boundDragListener?: (event: MouseEvent | TouchEvent) => boolean;
47
}
48
49
/**
50
 * @description An object representing the current drag element and config.
51
 */
52
let currentDrag: { el: HTMLElement, dragConfig: DragConfig }|null = null;
53
54
/**
55
 * @description An object representing the current drop zone element and config.
56
 */
57
let currentDrop: { el: HTMLElement, dropConfig: DropConfig }|null = null;
58
59
/**
60
 * @description The proxy element which is used to display the moved element.
61
 */
62
let dragElement: HTMLElement|null = null;
63
64
/**
65
 * @description The x offset of the mouse position inside the dragged element.
66
 */
67
let dragMouseOffsetX = 0;
68
69
/**
70
 * @description The y offset of the mouse position inside the dragged element.
71
 */
72
let dragMouseOffsetY = 0;
73
74
/**
75
 * @description The timeout managing the delayed drag start.
76
 */
77
let delayTimeout: number|null = null;
78
79
/**
80
 * @description A registry of all drop zones.
81
 */
82
const dropZones: DropZone[] = [];
83
84
/**
85
 * The default config for the draggable directive.
86
 */
87
const defaultDragConfig: DragConfig = {
88
    delay: 100,
89
    dragGroup: 1,
90
    draggableCls: 'is--draggable',
91
    draggingStateCls: 'is--dragging',
92
    dragElementCls: 'is--drag-element',
93
    validDragCls: 'is--valid-drag',
94
    invalidDragCls: 'is--invalid-drag',
95
    preventEvent: true,
96
    validateDrop: null,
97
    validateDrag: null,
98
    onDragStart: null,
99
    onDragEnter: null,
100
    onDragLeave: null,
101
    onDrop: null,
102
    data: null,
103
    disabled: false,
104
};
105
106
/**
107
 * The default config for the droppable directive.
108
 */
109
const defaultDropConfig: DropConfig = {
110
    dragGroup: 1,
111
    droppableCls: 'is--droppable',
112
    validDropCls: 'is--valid-drop',
113
    invalidDropCls: 'is--invalid-drop',
114
    validateDrop: null,
115
    onDrop: null,
116
    data: null,
117
};
118
119
/**
120
 * Fired by event callback when the user starts dragging an element.
121
 */
122
function onDrag(el: HTMLElement, dragConfig: DragConfig, event: MouseEvent|TouchEvent): boolean {
123
    if (event instanceof MouseEvent && event.buttons !== 1) {
124
        return false;
125
    }
126
127
    if (dragConfig.preventEvent) {
128
        event.preventDefault();
129
        event.stopPropagation();
130
    }
131
132
    if (dragConfig.delay === null || dragConfig.delay <= 0) {
133
        startDrag(el, dragConfig, event);
134
    } else {
135
        delayTimeout = window.setTimeout(startDrag.bind({}, el, dragConfig, event), dragConfig.delay);
136
    }
137
138
    document.addEventListener('mouseup', stopDrag);
139
    document.addEventListener('touchend', stopDrag);
140
141
    return true;
142
}
143
144
/**
145
 * Initializes the drag state for the current drag action.
146
 */
147
function startDrag(el: HTMLElement, dragConfig: DragConfig, event: MouseEvent|TouchEvent) {
148
    delayTimeout = null;
149
150
    if (currentDrag !== null) {
151
        return;
152
    }
153
154
    currentDrag = { el, dragConfig };
155
156
    const elBoundingBox = el.getBoundingClientRect();
157
158
    const pageX = (
159
        (event instanceof MouseEvent && event.pageX) ||
160
        (event instanceof TouchEvent && event.touches[0].pageX)
161
    ) as number;
162
163
    const pageY = (
164
        (event instanceof MouseEvent && event.pageY) ||
165
        (event instanceof TouchEvent && event.touches[0].pageY)
166
    ) as number;
167
168
    dragMouseOffsetX = pageX - elBoundingBox.left;
169
    dragMouseOffsetY = pageY - elBoundingBox.top;
170
171
    dragElement = el.cloneNode(true) as HTMLElement;
172
    dragElement.classList.add(dragConfig.dragElementCls);
173
    dragElement.style.width = `${elBoundingBox.width}px`;
174
    dragElement.style.left = `${pageX - dragMouseOffsetX}px`;
175
    dragElement.style.top = `${pageY - dragMouseOffsetY}px`;
176
    document.body.appendChild(dragElement);
177
178
    el.classList.add(dragConfig.draggingStateCls);
179
180
    if (types.isFunction(currentDrag.dragConfig.onDragStart)) {
181
        currentDrag.dragConfig.onDragStart(currentDrag.dragConfig, el, dragElement);
182
    }
183
184
    document.addEventListener('mousemove', moveDrag);
185
    document.addEventListener('touchmove', moveDrag);
186
}
187
188
/**
189
 * Fired by event callback when the user moves the dragged element.
190
 */
191
function moveDrag(event: MouseEvent|TouchEvent) {
192
    if (currentDrag === null) {
193
        stopDrag();
194
        return;
195
    }
196
197
    const pageX = (
198
        (event instanceof MouseEvent && event.pageX) ||
199
        (event instanceof TouchEvent && event.touches[0].pageX)
200
    ) as number;
201
202
    const pageY = (
203
        (event instanceof MouseEvent && event.pageY) ||
204
        (event instanceof TouchEvent && event.touches[0].pageY)
205
    ) as number;
206
207
    if (!pageX || !pageY) {
208
        return;
209
    }
210
211
    if (dragElement) {
212
        dragElement.style.left = `${pageX - dragMouseOffsetX}px`;
213
        dragElement.style.top = `${pageY - dragMouseOffsetY}px`;
214
    }
215
216
    if (event.type === 'touchmove') {
217
        dropZones.forEach((zone) => {
218
            if (isEventOverElement(event, zone.el)) {
219
                if (currentDrop === null || zone.el !== currentDrop.el) {
220
                    enterDropZone(zone.el, zone.dropConfig);
221
                }
222
            } else if (currentDrop !== null && zone.el === currentDrop.el) {
223
                leaveDropZone(zone.el, zone.dropConfig);
224
            }
225
        });
226
    }
227
}
228
229
/**
230
 * Helper method for detecting if the current event position
231
 * is in the boundaries of an existing drop zone element.
232
 */
233
function isEventOverElement(event: MouseEvent|TouchEvent, el: HTMLElement): boolean {
234
    const pageX = (
235
        (event instanceof MouseEvent && event.pageX) ||
236
        (event instanceof TouchEvent && event.touches[0].pageX)
237
    ) as number;
238
239
    const pageY = (
240
        (event instanceof MouseEvent && event.pageY) ||
241
        (event instanceof TouchEvent && event.touches[0].pageY)
242
    ) as number;
243
244
    const box = el.getBoundingClientRect();
245
246
    return pageX >= box.x && pageX <= (box.x + box.width) &&
247
        pageY >= box.y && pageY <= (box.y + box.height);
248
}
249
250
/**
251
 * Stops all drag interaction and resets all variables and listeners.
252
 */
253
function stopDrag() {
254
    if (delayTimeout !== null) {
255
        window.clearTimeout(delayTimeout);
256
        delayTimeout = null;
257
        return;
258
    }
259
260
    const validDrag = validateDrag();
261
    const validDrop = validateDrop();
262
263
    if (validDrag && currentDrag) {
264
        if (types.isFunction(currentDrag.dragConfig.onDrop)) {
265
            currentDrag.dragConfig.onDrop(
266
                currentDrag.dragConfig.data,
267
                validDrop ? currentDrop && currentDrop.dropConfig.data : null,
268
            );
269
        }
270
    }
271
272
    if (validDrop && currentDrop) {
273
        if (types.isFunction(currentDrop.dropConfig.onDrop)) {
274
            currentDrop.dropConfig.onDrop(currentDrag && currentDrag.dragConfig.data, currentDrop.dropConfig.data);
275
        }
276
    }
277
278
    document.removeEventListener('mousemove', moveDrag);
279
    document.removeEventListener('touchmove', moveDrag);
280
281
    document.removeEventListener('mouseup', stopDrag);
282
    document.removeEventListener('touchend', stopDrag);
283
284
    if (dragElement !== null) {
285
        dragElement.remove();
286
        dragElement = null;
287
    }
288
289
    if (currentDrag !== null) {
290
        currentDrag.el.classList.remove(currentDrag.dragConfig.draggingStateCls);
291
        currentDrag.el.classList.remove(currentDrag.dragConfig.validDragCls);
292
        currentDrag.el.classList.remove(currentDrag.dragConfig.invalidDragCls);
293
        currentDrag = null;
294
    }
295
296
    if (currentDrop !== null) {
297
        currentDrop.el.classList.remove(currentDrop.dropConfig.validDropCls);
298
        currentDrop.el.classList.remove(currentDrop.dropConfig.invalidDropCls);
299
        currentDrop = null;
300
    }
301
302
    dragMouseOffsetX = 0;
303
    dragMouseOffsetY = 0;
304
}
305
306
/**
307
 * Fired by event callback when the user moves the dragged element over an existing drop zone.
308
 */
309
function enterDropZone(el: HTMLElement, dropConfig: DropConfig) {
310
    if (currentDrag === null) {
311
        return;
312
    }
313
    currentDrop = { el, dropConfig };
314
315
    const valid = validateDrop();
316
317
    if (valid) {
318
        el.classList.add(dropConfig.validDropCls);
319
        el.classList.remove(dropConfig.invalidDropCls);
320
321
        if (dragElement) {
322
            dragElement.classList.add(currentDrag.dragConfig.validDragCls);
323
            dragElement.classList.remove(currentDrag.dragConfig.invalidDragCls);
324
        }
325
    } else {
326
        el.classList.add(dropConfig.invalidDropCls);
327
        el.classList.remove(dropConfig.validDropCls);
328
329
        if (dragElement) {
330
            dragElement.classList.add(currentDrag.dragConfig.invalidDragCls);
331
            dragElement.classList.remove(currentDrag.dragConfig.validDragCls);
332
        }
333
    }
334
335
    if (types.isFunction(currentDrag.dragConfig.onDragEnter)) {
336
        currentDrag.dragConfig.onDragEnter(currentDrag.dragConfig.data, currentDrop.dropConfig.data, valid);
337
    }
338
}
339
340
/**
341
 * Fired by event callback when the user moves the dragged element out of an existing drop zone.
342
 */
343
function leaveDropZone(el: HTMLElement, dropConfig: DropConfig) {
344
    if (currentDrag === null) {
345
        return;
346
    }
347
348
    if (types.isFunction(currentDrag.dragConfig.onDragLeave)) {
349
        currentDrag.dragConfig.onDragLeave(currentDrag.dragConfig.data, currentDrop && currentDrop.dropConfig.data);
350
    }
351
352
    el.classList.remove(dropConfig.validDropCls);
353
    el.classList.remove(dropConfig.invalidDropCls);
354
355
    if (dragElement) {
356
        dragElement.classList.remove(currentDrag.dragConfig.validDragCls);
357
        dragElement.classList.remove(currentDrag.dragConfig.invalidDragCls);
358
    }
359
360
    currentDrop = null;
361
}
362
363
/**
364
 * Validates a drop using the {currentDrag} and {currentDrop} configuration.
365
 * Also calls the custom validator functions of the two configs.
366
 */
367
function validateDrop(): boolean {
368
    let valid = true;
369
    let customDragValidation = true;
370
    let customDropValidation = true;
371
372
    // Validate if the drag and drop are using the same drag group.
373
    if (currentDrag === null ||
374
        currentDrop === null ||
375
        currentDrop.dropConfig.dragGroup !== currentDrag.dragConfig.dragGroup) {
376
        valid = false;
377
    }
378
379
    // Check the custom drag validate function.
380
    if (currentDrag !== null && types.isFunction(currentDrag.dragConfig.validateDrop)) {
381
        customDragValidation = currentDrag.dragConfig.validateDrop(
382
            currentDrag.dragConfig.data, currentDrop && currentDrop.dropConfig.data,
383
        );
384
    }
385
386
    // Check the custom drop validate function.
387
    if (currentDrop !== null && types.isFunction(currentDrop.dropConfig.validateDrop)) {
388
        customDropValidation = currentDrop.dropConfig.validateDrop(
389
            currentDrag && currentDrag.dragConfig.data, currentDrop.dropConfig.data,
390
        );
391
    }
392
393
    return valid && customDragValidation && customDropValidation;
394
}
395
/**
396
 * Validates a drag using the {currentDrag} configuration.
397
 * Also calls the custom validator functions of the config.
398
 */
399
function validateDrag(): boolean {
400
    let valid = true;
401
    let customDragValidation = true;
402
403
    // Validate if the drag and drop are using the same drag group.
404
    if (currentDrag === null) {
405
        valid = false;
406
    }
407
408
    // Check the custom drag validate function.
409
    if (currentDrag !== null && types.isFunction(currentDrag.dragConfig.validateDrag)) {
410
        customDragValidation = currentDrag.dragConfig.validateDrag(
411
            currentDrag.dragConfig.data, currentDrop && currentDrop.dropConfig.data,
412
        );
413
    }
414
415
    return valid && customDragValidation;
416
}
417
418
function mergeConfigs(defaultConfig: DragConfig|DropConfig, binding: { value: unknown }) {
419
    const mergedConfig = Object.assign({}, defaultConfig);
420
421
    if (types.isObject(binding.value)) {
422
        Object.assign(mergedConfig, binding.value);
423
    } else {
424
        Object.assign(mergedConfig, { data: binding.value });
425
    }
426
427
    return mergedConfig;
428
}
429
430
/**
431
 * Directive for making elements draggable.
432
 *
433
 * Usage:
434
 * <div v-draggable="{ data: {...}, onDrop() {...} }"></div>
435
 *
436
 * See the {DragConfig} for all possible config options.
437
 */
438
Shopware.Directive.register('draggable', {
439
    inserted(el: DragHTMLElement, binding: { value: unknown }) {
440
        const dragConfig = mergeConfigs(defaultDragConfig, binding) as DragConfig;
441
        el.dragConfig = dragConfig;
442
        el.boundDragListener = onDrag.bind(this, el, el.dragConfig);
443
444
        if (!dragConfig.disabled) {
445
            el.classList.add(dragConfig.draggableCls);
446
            el.addEventListener('mousedown', el.boundDragListener);
447
            el.addEventListener('touchstart', el.boundDragListener);
448
        }
449
    },
450
451
    update(el: DragHTMLElement, binding: { value: unknown }) {
452
        const dragConfig = mergeConfigs(defaultDragConfig, binding) as DragConfig;
453
454
        if (el.dragConfig && el.dragConfig.disabled !== dragConfig.disabled) {
455
            if (!dragConfig.disabled) {
456
                el.classList.remove(el.dragConfig.draggableCls);
457
                el.classList.add(dragConfig.draggableCls);
458
                if (el.boundDragListener) {
459
                    el.addEventListener('mousedown', el.boundDragListener);
460
                    el.addEventListener('touchstart', el.boundDragListener);
461
                }
462
            } else {
463
                el.classList.remove(el.dragConfig.draggableCls);
464
                if (el.boundDragListener) {
465
                    el.removeEventListener('mousedown', el.boundDragListener);
466
                    el.removeEventListener('touchstart', el.boundDragListener);
467
                }
468
            }
469
        }
470
471
        if (!el.dragConfig) {
472
            el.dragConfig = {} as DragConfig;
473
        }
474
475
        Object.assign(el.dragConfig, dragConfig);
476
    },
477
478
    unbind(el: DragHTMLElement, binding: { value: unknown }) {
479
        const dragConfig = mergeConfigs(defaultDragConfig, binding) as DragConfig;
480
481
        el.classList.remove(dragConfig.draggableCls);
482
483
        if (el.boundDragListener) {
484
            el.removeEventListener('mousedown', el.boundDragListener);
485
            el.removeEventListener('touchstart', el.boundDragListener);
486
        }
487
    },
488
});
489
490
/**
491
 * Directive to define an element as a drop zone.
492
 *
493
 * Usage:
494
 * <div v-droppable="{ data: {...}, onDrop() {...} }"></div>
495
 *
496
 * See the {dropConfig} for all possible config options.
497
 */
498
Shopware.Directive.register('droppable', {
499
    inserted(el: HTMLElement, binding: { value: unknown }) {
500
        const dropConfig = mergeConfigs(defaultDropConfig, binding) as DropConfig;
501
502
        dropZones.push({ el, dropConfig });
503
504
        el.classList.add(dropConfig.droppableCls);
505
        el.addEventListener('mouseenter', enterDropZone.bind(this, el, dropConfig));
506
        el.addEventListener('mouseleave', leaveDropZone.bind(this, el, dropConfig));
507
    },
508
509
    unbind(el: HTMLElement, binding: { value: unknown }) {
510
        const dropConfig = mergeConfigs(defaultDropConfig, binding) as DropConfig;
511
512
        dropZones.splice(dropZones.findIndex(zone => zone.el === el), 1);
513
514
        el.classList.remove(dropConfig.droppableCls);
515
        el.removeEventListener('mouseenter', enterDropZone.bind(this, el, dropConfig));
516
        el.removeEventListener('mouseleave', leaveDropZone.bind(this, el, dropConfig));
517
    },
518
519
    update: (el: HTMLElement, binding: { value: unknown }) => {
520
        const dropZone = dropZones.find(zone => zone.el === el);
521
        if (!dropZone) {
522
            return;
523
        }
524
525
        if (types.isObject(binding.value)) {
526
            Object.assign(dropZone.dropConfig, binding.value);
527
        } else {
528
            Object.assign(dropZone.dropConfig, { data: binding.value });
529
        }
530
    },
531
});
532
533
/**
534
 * @deprecated tag:v6.6.0 - Will be private
535
 */
536
export type { DragConfig, DropConfig };
537