UNPKG

@shopware-ag/meteor-component-library

Version:

The meteor component library is a Vue component library developed by Shopware. It is based on the [Meteor Design System](https://shopware.design/).

617 lines (520 loc) 17.8 kB
import type { Directive } from "vue"; export interface DragConfig<DATA = unknown> { delay: number; dragGroup: number | string; draggableCls: string; draggingStateCls: string; dragElementCls: string; validDragCls: string; invalidDragCls: string; preventEvent: boolean; validateDrop: | null | (( dragConfigData: DragConfig<DATA>["data"], dropConfigData: DropConfig<DATA>["data"], ) => boolean); validateDrag: | null | (( dragConfigData: DragConfig<DATA>["data"], dropConfigData: DropConfig<DATA>["data"], ) => boolean); validateDragStart: | null | (( dragConfigData: DragConfig<DATA>["data"], el: HTMLElement, event: MouseEvent | TouchEvent, ) => boolean); onDragStart: | null | ((dragConfig: DragConfig<DATA>, el: HTMLElement, dragElement: HTMLElement) => void); onDragEnter: | null | (( dragConfigData: DragConfig<DATA>["data"], dropConfigData: DropConfig<DATA>["data"], valid: boolean, ) => void); onDragLeave: | null | (( dragConfigData: DragConfig<DATA>["data"], dropConfigData: DropConfig<DATA>["data"], ) => void); onDrop: | null | (( dragConfigData: DragConfig<DATA>["data"], dropConfigData: DropConfig<DATA>["data"], ) => void); data: null | DATA; disabled: boolean; } export interface DropConfig<DATA = unknown> { dragGroup: number | string; droppableCls: string; validDropCls: string; invalidDropCls: string; validateDrop: | null | (( dragConfigData: DragConfig<DATA>["data"], dropConfigData: DropConfig<DATA>["data"], ) => boolean); onDrop: | null | (( dragConfigData: DragConfig<DATA>["data"], dropConfigData: DropConfig<DATA>["data"], ) => void); data: null | DATA; } export interface DropZone { el: HTMLElement; dropConfig: DropConfig; } /** * @description An object representing the current drag element and config. */ let currentDrag: { el: HTMLElement; dragConfig: DragConfig } | null = null; /** * @description An object representing the current drop zone element and config. */ let currentDrop: { el: HTMLElement; dropConfig: DropConfig } | null = null; /** * @description The proxy element which is used to display the moved element. */ let dragElement: HTMLElement | null = null; /** * @description The x offset of the mouse position inside the dragged element. */ let dragMouseOffsetX = 0; /** * @description The y offset of the mouse position inside the dragged element. */ let dragMouseOffsetY = 0; /** * @description The timeout managing the delayed drag start. */ let delayTimeout: number | null = null; /** * @description A registry of all drop zones. */ const dropZones: DropZone[] = []; /** * The default config for the draggable directive. */ const defaultDragConfig: DragConfig = { delay: 100, dragGroup: 1, draggableCls: "is--draggable", draggingStateCls: "is--dragging", dragElementCls: "is--drag-element", validDragCls: "is--valid-drag", invalidDragCls: "is--invalid-drag", preventEvent: true, validateDrop: null, validateDrag: null, validateDragStart: null, onDragStart: null, onDragEnter: null, onDragLeave: null, onDrop: null, data: null, disabled: false, }; /** * The default config for the droppable directive. */ const defaultDropConfig: DropConfig = { dragGroup: 1, droppableCls: "is--droppable", validDropCls: "is--valid-drop", invalidDropCls: "is--invalid-drop", validateDrop: null, onDrop: null, data: null, }; /** * Fired by event callback when the user starts dragging an element. */ function onDrag(el: HTMLElement, dragConfig: DragConfig, event: MouseEvent | TouchEvent): boolean { if (event instanceof MouseEvent && event.buttons !== 1) { return false; } if (dragConfig.preventEvent) { event.preventDefault(); event.stopPropagation(); } if (dragConfig.delay === null || dragConfig.delay <= 0) { startDrag(el, dragConfig, event); } else { // TODO: check if {} instead of "this" is working delayTimeout = window.setTimeout(startDrag.bind({}, el, dragConfig, event), dragConfig.delay); } document.addEventListener("mouseup", stopDrag); document.addEventListener("touchend", stopDrag); return true; } /** * Initializes the drag state for the current drag action. */ function startDrag(el: HTMLElement, dragConfig: DragConfig, event: MouseEvent | TouchEvent) { // check if starting drag is valid if ( dragConfig.validateDragStart !== null && !dragConfig.validateDragStart(dragConfig.data, el, event) ) { return; } delayTimeout = null; if (currentDrag !== null) { return; } currentDrag = { el, dragConfig }; const elBoundingBox = el.getBoundingClientRect(); const pageX = ((event instanceof MouseEvent && event.pageX) || (event instanceof TouchEvent && event.touches[0].pageX)) as number; const pageY = ((event instanceof MouseEvent && event.pageY) || (event instanceof TouchEvent && event.touches[0].pageY)) as number; dragMouseOffsetX = pageX - elBoundingBox.left; dragMouseOffsetY = pageY - elBoundingBox.top; dragElement = el.cloneNode(true) as HTMLElement; dragElement.classList.add(dragConfig.dragElementCls); dragElement.style.width = `${elBoundingBox.width}px`; dragElement.style.translate = `${pageX - dragMouseOffsetX}px ${pageY - dragMouseOffsetY}px`; dragElement.style.left = "0"; dragElement.style.top = "0"; document.body.appendChild(dragElement); el.classList.add(dragConfig.draggingStateCls); if (typeof currentDrag.dragConfig.onDragStart === "function") { currentDrag.dragConfig.onDragStart(currentDrag.dragConfig, el, dragElement); } document.addEventListener("mousemove", moveDrag); document.addEventListener("touchmove", moveDrag); } let rotationTimeout = 0; /** * Calculate the rotation based on movement direction. */ function calculateRotation(oldX: number, newX: number): string { if (oldX && Math.abs(newX - oldX) > 2) { const moveRight = newX - oldX > 0; return `${moveRight ? 5 : -5}deg`; } return ""; } /** * Fired by event callback when the user moves the dragged element. */ function moveDrag(event: MouseEvent | TouchEvent) { if (currentDrag === null) { stopDrag(); return; } const pageX = ((event instanceof MouseEvent && event.pageX) || (event instanceof TouchEvent && event.touches[0].pageX)) as number; const pageY = ((event instanceof MouseEvent && event.pageY) || (event instanceof TouchEvent && event.touches[0].pageY)) as number; if (dragElement) { // get the old value from dataset const oldX = Number(dragElement.dataset.translateX); const newX = pageX - dragMouseOffsetX; const newY = pageY - dragMouseOffsetY; // calculate rotation dragElement.style.rotate = calculateRotation(oldX, newX); // use timeout for resetting the rotation after movement stops clearTimeout(rotationTimeout); rotationTimeout = window.setTimeout(() => { if (dragElement) dragElement.style.rotate = "0deg"; }, 100); // set translate value dragElement.style.translate = `${newX}px ${newY}px`; // save new x value to dataset dragElement.dataset.translateX = newX.toString(); } if (event.type === "touchmove") { const foundZone = dropZones.find((zone) => isEventOverElement(event, zone.el)); clearAllDropZoneHighlights(); if (foundZone) { enterDropZone(foundZone.el, foundZone.dropConfig); } else if (currentDrop) { leaveDropZone(currentDrop.el, currentDrop.dropConfig); } } } /** * Helper method for detecting if the current event position * is in the boundaries of an existing drop zone element. */ function isEventOverElement(event: MouseEvent | TouchEvent, el: HTMLElement): boolean { const pageX = ((event instanceof MouseEvent && event.pageX) || (event instanceof TouchEvent && event.touches[0].pageX)) as number; const pageY = ((event instanceof MouseEvent && event.pageY) || (event instanceof TouchEvent && event.touches[0].pageY)) as number; const box = el.getBoundingClientRect(); return ( pageX >= box.x && pageX <= box.x + box.width && pageY >= box.y && pageY <= box.y + box.height ); } /** * Stops all drag interaction and resets all variables and listeners. */ function stopDrag() { if (delayTimeout !== null) { window.clearTimeout(delayTimeout); delayTimeout = null; return; } const validDrag = validateDrag(); const validDrop = validateDrop(); if (validDrag && currentDrag) { if (typeof currentDrag.dragConfig.onDrop === "function") { currentDrag.dragConfig.onDrop( currentDrag.dragConfig.data, validDrop ? currentDrop?.dropConfig?.data : null, ); } } if (validDrop && currentDrop) { if (typeof currentDrop.dropConfig.onDrop === "function") { currentDrop.dropConfig.onDrop(currentDrag?.dragConfig.data, currentDrop.dropConfig.data); } } document.removeEventListener("mousemove", moveDrag); document.removeEventListener("touchmove", moveDrag); document.removeEventListener("mouseup", stopDrag); document.removeEventListener("touchend", stopDrag); if (dragElement !== null) { dragElement.remove(); dragElement = null; } if (currentDrag !== null) { currentDrag.el.classList.remove(currentDrag.dragConfig.draggingStateCls); currentDrag.el.classList.remove(currentDrag.dragConfig.validDragCls); currentDrag.el.classList.remove(currentDrag.dragConfig.invalidDragCls); currentDrag = null; } if (currentDrop !== null) { currentDrop.el.classList.remove(currentDrop.dropConfig.validDropCls); currentDrop.el.classList.remove(currentDrop.dropConfig.invalidDropCls); currentDrop = null; } dragMouseOffsetX = 0; dragMouseOffsetY = 0; } /** * Fired by event callback when the user moves the dragged element over an existing drop zone. */ function enterDropZone(el: HTMLElement, dropConfig: DropConfig) { if (currentDrag === null) { return; } currentDrop = { el, dropConfig }; const valid = validateDrop(); if (valid) { el.classList.add(dropConfig.validDropCls); el.classList.remove(dropConfig.invalidDropCls); if (dragElement) { dragElement.classList.add(currentDrag.dragConfig.validDragCls); dragElement.classList.remove(currentDrag.dragConfig.invalidDragCls); } } else { el.classList.add(dropConfig.invalidDropCls); el.classList.remove(dropConfig.validDropCls); if (dragElement) { dragElement.classList.add(currentDrag.dragConfig.invalidDragCls); dragElement.classList.remove(currentDrag.dragConfig.validDragCls); } } if (typeof currentDrag.dragConfig.onDragEnter === "function") { currentDrag.dragConfig.onDragEnter( currentDrag.dragConfig.data, currentDrop.dropConfig.data, valid, ); } } /** * Fired by event callback when the user moves the dragged element out of an existing drop zone. * * @param {HTMLElement} el * @param {DropConfig} dropConfig */ function leaveDropZone(el: HTMLElement, dropConfig: DropConfig) { if (currentDrag === null) { return; } if (currentDrop === null) { return; } if (typeof currentDrag.dragConfig.onDragLeave === "function") { currentDrag.dragConfig.onDragLeave(currentDrag.dragConfig.data, currentDrop.dropConfig.data); } el.classList.remove(dropConfig.validDropCls); el.classList.remove(dropConfig.invalidDropCls); if (dragElement) { dragElement.classList.remove(currentDrag.dragConfig.validDragCls); dragElement.classList.remove(currentDrag.dragConfig.invalidDragCls); } currentDrop = null; } /** * Validates a drop using the {currentDrag} and {currentDrop} configuration. * Also calls the custom validator functions of the two configs. */ function validateDrop(): boolean { let valid = true; let customDragValidation = true; let customDropValidation = true; // Validate if the drag and drop are using the same drag group. if ( currentDrag === null || currentDrop === null || currentDrop.dropConfig.dragGroup !== currentDrag.dragConfig.dragGroup ) { valid = false; } // Check the custom drag validate function. if (currentDrag !== null && typeof currentDrag.dragConfig.validateDrop === "function") { customDragValidation = currentDrag.dragConfig.validateDrop( currentDrag.dragConfig.data, currentDrop?.dropConfig.data, ); } // Check the custom drop validate function. if (currentDrop !== null && typeof currentDrop.dropConfig.validateDrop === "function") { customDropValidation = currentDrop.dropConfig.validateDrop( currentDrag?.dragConfig.data, currentDrop.dropConfig.data, ); } return valid && customDragValidation && customDropValidation; } /** * Validates a drag using the {currentDrag} configuration. * Also calls the custom validator functions of the config. */ function validateDrag(): boolean { let valid = true; let customDragValidation = true; // Validate if the drag and drop are using the same drag group. if (currentDrag === null) { valid = false; } // Check the custom drag validate function. if (currentDrag !== null && typeof currentDrag.dragConfig.validateDrag === "function") { customDragValidation = currentDrag.dragConfig.validateDrag( currentDrag.dragConfig.data, currentDrop?.dropConfig.data, ); } return valid && customDragValidation; } function mergeConfigs(defaultConfig: DragConfig | DropConfig, binding: { value: unknown }) { const mergedConfig = { ...defaultConfig }; if (binding.value instanceof Object) { Object.assign(mergedConfig, binding.value); } else { Object.assign(mergedConfig, { data: binding.value }); } return mergedConfig; } interface DragHTMLElement extends HTMLElement { dragConfig?: DragConfig; boundDragListener?: (event: MouseEvent | TouchEvent) => boolean; } /** * Directive for making elements draggable. * * Usage: * <div v-draggable="{ data: {...}, onDrop() {...} }"></div> * * See the {DragConfig} for all possible config options. */ export const draggable: Directive = { mounted(el: DragHTMLElement, binding) { const dragConfig = mergeConfigs(defaultDragConfig, binding as { value: unknown }) as DragConfig; el.dragConfig = dragConfig; el.boundDragListener = onDrag.bind(this, el, el.dragConfig); if (!dragConfig.disabled) { el.classList.add(dragConfig.draggableCls); el.addEventListener("mousedown", el.boundDragListener); el.addEventListener("touchstart", el.boundDragListener); } }, updated(el: DragHTMLElement, binding) { const dragConfig = mergeConfigs(defaultDragConfig, binding as { value: unknown }) as DragConfig; if (el.dragConfig && el.dragConfig.disabled !== dragConfig.disabled) { if (!dragConfig.disabled) { el.classList.remove(el.dragConfig.draggableCls); el.classList.add(dragConfig.draggableCls); if (el.boundDragListener) { el.addEventListener("mousedown", el.boundDragListener); el.addEventListener("touchstart", el.boundDragListener); } } else { el.classList.remove(el.dragConfig.draggableCls); if (el.boundDragListener) { el.removeEventListener("mousedown", el.boundDragListener); el.removeEventListener("touchstart", el.boundDragListener); } } } // @ts-expect-error - typescript support is missing here Object.assign(el.dragConfig, dragConfig); }, unmounted(el: DragHTMLElement, binding) { const dragConfig = mergeConfigs(defaultDragConfig, binding as { value: unknown }) as DragConfig; el.classList.remove(dragConfig.draggableCls); if (el.boundDragListener) { el.removeEventListener("mousedown", el.boundDragListener); el.removeEventListener("touchstart", el.boundDragListener); } }, }; /** * Directive to define an element as a drop zone. * * Usage: * <div v-droppable="{ data: {...}, onDrop() {...} }"></div> * * See the {dropConfig} for all possible config options. */ export const droppable: Directive = { mounted(el, binding) { const dropConfig = mergeConfigs(defaultDropConfig, binding as { value: unknown }) as DropConfig; dropZones.push({ el, dropConfig }); el.classList.add(dropConfig.droppableCls); el.addEventListener("mouseenter", enterDropZone.bind(this, el, dropConfig)); el.addEventListener("mouseleave", leaveDropZone.bind(this, el, dropConfig)); }, unmounted(el, binding) { const dropConfig = mergeConfigs(defaultDropConfig, binding as { value: unknown }) as DropConfig; dropZones.splice( dropZones.findIndex((zone) => zone.el === el), 1, ); el.classList.remove(dropConfig.droppableCls); el.removeEventListener("mouseenter", enterDropZone.bind(this, el, dropConfig)); el.removeEventListener("mouseleave", leaveDropZone.bind(this, el, dropConfig)); }, updated: (el, binding) => { const dropZone = dropZones.find((zone) => zone.el === el); if (binding.value instanceof Object) { // @ts-expect-error - typescript support is missing here Object.assign(dropZone?.dropConfig, binding.value); } else { // @ts-expect-error - typescript support is missing here Object.assign(dropZone?.dropConfig, { data: binding.value }); } }, }; function clearAllDropZoneHighlights() { dropZones.forEach((zone) => { zone.el.classList.remove(zone.dropConfig.validDropCls); zone.el.classList.remove(zone.dropConfig.invalidDropCls); }); }