Passed
Push — trunk ( c1976e...a42427 )
by Christian
12:40 queued 13s
created

dragdrop.directive.ts ➔ moveDrag   B

Complexity

Conditions 8

Size

Total Lines 37
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

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