UNPKG

@rws-framework/components

Version:
480 lines (400 loc) 19 kB
import { RWSInject, RWSService, RWSViewComponent } from '@rws-framework/client'; import { DragData, DropZone, IDragOpts } from './types'; import { EventManager } from './EventManager'; import { HandlerManager } from './HandlerManager'; class DragDropService extends RWSService { private currentDrag: DragData<any> | null = null; private dropZones: Map<HTMLElement, DropZone<any>> = new Map(); private handlerManager: HandlerManager = new HandlerManager(); private successfulDrop: boolean = false; private originalPosition: number = -1; private originalNextSibling: Element | null = null; /** * Initialize drag functionality for an element */ drag<T>(draggedElement: HTMLElement, component?: RWSViewComponent, dragOptions: Partial<IDragOpts> = {}): void { EventManager.validateComponent(component); draggedElement.draggable = true; const dragStartHandler = (event: DragEvent) => { if(!dragOptions.getDragElementData){ throw new Error('DragDropService.drag: getDragElementData function must be provided in dragOptions'); } const data = dragOptions.getDragElementData(draggedElement).data as T; const type = dragOptions.dragElementType || 'tab'; // For sortable items, ensure data-order attribute exists if (dragOptions.sortable || type === 'sortable-item') { const dataOrderAttr = draggedElement.getAttribute('data-order'); if (!dataOrderAttr) { throw new Error('DragDropService: data-order attribute is required on draggable elements for sortable functionality'); } } // Reset successful drop flag for new drag operation this.successfulDrop = false; // Store original position for sortable behavior if (draggedElement.parentElement) { const siblings = Array.from(draggedElement.parentElement.children); this.originalPosition = siblings.indexOf(draggedElement); this.originalNextSibling = draggedElement.nextElementSibling; } // Store original order from DOM attribute const originalOrderAttr = draggedElement.getAttribute('data-order'); const originalOrder = originalOrderAttr ? parseFloat(originalOrderAttr) : 0; this.currentDrag = { element: draggedElement, data: data, type, originalPosition: this.originalPosition, originalOrder: originalOrder }; if(dragOptions.onDropOut){ this.currentDrag.onDropOut = dragOptions.onDropOut; } if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', JSON.stringify({ type, data })); } // Add visual feedback via dragstate attribute draggedElement.setAttribute('dragstate', 'dragging'); // Emit drag start event EventManager.emitEvent(component!, 'drag-start', { element: draggedElement, data: data, type: type }); }; const dragEndHandler = async (event: DragEvent) => { // Remove visual feedback via dragstate attribute draggedElement.removeAttribute('dragstate'); // Clear sortstate attributes from all items if (draggedElement.parentElement) { this.clearSortStateAttributes(draggedElement.parentElement); } // Check if element was dropped outside of any drop zone if (!this.successfulDrop && this.currentDrag?.onDropOut) { try { await this.currentDrag.onDropOut(this.currentDrag); } catch (error) { console.error('Error executing onDropOut callback:', error); } } // Emit drag end event EventManager.emitEvent(component!, 'drag-end', { element: draggedElement, data: this.currentDrag?.data, type: this.currentDrag?.type }); this.currentDrag = null; }; // Remove existing handlers if any this.handlerManager.removeDragHandlers(draggedElement); // Add new handlers draggedElement.addEventListener('dragstart', dragStartHandler); draggedElement.addEventListener('dragend', dragEndHandler); // Store handlers for cleanup this.handlerManager.storeDragHandlers(draggedElement, { dragStart: dragStartHandler, dragEnd: dragEndHandler }); } /** * Initialize drop functionality for an element */ drop<T>(targetArea: HTMLElement, acceptedTypes: string[] = ['default'], onDropCallback?: (dragData: DragData<T>, dropZone: DropZone<T>, event?: DragEvent) => void, component?: RWSViewComponent, options?: { sortable?: boolean }): void { const dropZone: DropZone<T> = { element: targetArea, accepts: acceptedTypes, onDrop: onDropCallback, sortable: options?.sortable || false }; const dragOverHandler = (event: DragEvent) => { event.preventDefault(); if (this.currentDrag && this.canAcceptDrop(dropZone, this.currentDrag)) { if (event.dataTransfer) { event.dataTransfer.dropEffect = 'move'; } targetArea.setAttribute('dragstate', 'drag-over'); // Handle sortable behavior - just visual feedback, no DOM manipulation if (dropZone.sortable && this.currentDrag.element !== event.target) { // Store intended drop position for later calculation, but don't manipulate DOM // DOM manipulation conflicts with RWS template system this.storeIntendedDropPosition(event, dropZone); } } else { if (event.dataTransfer) { event.dataTransfer.dropEffect = 'none'; } } }; const dragLeaveHandler = (event: DragEvent) => { targetArea.removeAttribute('dragstate'); }; const dropHandler = (event: DragEvent) => { event.preventDefault(); targetArea.removeAttribute('dragstate'); if (this.currentDrag && this.canAcceptDrop(dropZone, this.currentDrag)) { // Mark as successful drop this.successfulDrop = true; // Handle sortable drop if (dropZone.sortable) { this.handleSortableDrop(event, dropZone, this.currentDrag); } // Execute callback if provided - now with event information if (dropZone.onDrop) { dropZone.onDrop(this.currentDrag, dropZone, event); } // Emit drop event using component's $emit if available if (component && typeof component.$emit === 'function') { EventManager.emitEvent(component, 'drop', { dragData: this.currentDrag, dropZone: dropZone, targetElement: targetArea, dropEvent: event }); } } }; // Remove existing handlers if any this.handlerManager.removeDropHandlers(targetArea); // Add new handlers targetArea.addEventListener('dragover', dragOverHandler); targetArea.addEventListener('dragleave', dragLeaveHandler); targetArea.addEventListener('drop', dropHandler); // Store handlers and drop zone for cleanup this.handlerManager.storeDropHandlers(targetArea, { dragOver: dragOverHandler, dragLeave: dragLeaveHandler, drop: dropHandler }); this.dropZones.set(targetArea, dropZone); } /** * Remove drag functionality from an element */ removeDrag(element: HTMLElement): void { this.handlerManager.removeDragHandlers(element); element.draggable = false; element.removeAttribute('dragstate'); } /** * Remove drop functionality from an element */ removeDrop(element: HTMLElement): void { this.handlerManager.removeDropHandlers(element); this.dropZones.delete(element); element.removeAttribute('dragstate'); } /** * Get current drag data */ getCurrentDrag<T>(): DragData<T> | null { return this.currentDrag; } /** * Check if a drop zone can accept the current drag */ private canAcceptDrop<T>(dropZone: DropZone<T>, dragData: DragData<T>): boolean { return dropZone.accepts.includes(dragData.type) || dropZone.accepts.includes('*'); } /** * Handle sortable dragover behavior - store intended position without DOM manipulation */ private storeIntendedDropPosition(event: DragEvent, dropZone: DropZone<any>): void { // Just store the intended drop position for later calculation // Don't manipulate DOM to avoid conflicts with RWS template system const container = dropZone.element; const afterElement = this.getDragAfterElement(container, event.clientY); // Clear previous sortstate attributes this.clearSortStateAttributes(container); // Add sortstate attributes for visual feedback this.applySortStateAttributes(container, afterElement, event.clientY); // Store intended position in currentDrag for later use in calculateSortingOrder if (this.currentDrag) { this.currentDrag.intendedAfterElement = afterElement; this.currentDrag.lastDropY = event.clientY; } } /** * Get all items order from container - calculate new order based on drag operation */ private getAllItemsOrder(container: HTMLElement, dragData?: DragData<any>): Array<{id: string, order: number}> { const allItems = Array.from(container.children) as HTMLElement[]; // If no drag data, just return current DOM order if (!dragData || dragData.sortingOrder === undefined) { return allItems.map((item, index) => { const id = item.getAttribute('data-id') || ''; return { id, order: index }; }).filter(item => item.id); } // Calculate new order based on drag operation const draggedId = dragData.element.getAttribute('data-id') || ''; const newPosition = dragData.sortingOrder; // Create new order array const itemsWithIds = allItems.map((item, index) => ({ id: item.getAttribute('data-id') || '', originalIndex: index, element: item })).filter(item => item.id); // Find dragged item const draggedItem = itemsWithIds.find(item => item.id === draggedId); if (!draggedItem) { // Fallback to original order if dragged item not found return itemsWithIds.map((item, index) => ({ id: item.id, order: index })); } // Remove dragged item from its current position const otherItems = itemsWithIds.filter(item => item.id !== draggedId); // Insert dragged item at new position const newOrderItems = [...otherItems]; newOrderItems.splice(newPosition, 0, draggedItem); // Return with new 0-based ordering return newOrderItems.map((item, index) => ({ id: item.id, order: index })); } /** * Handle sortable drop behavior */ private handleSortableDrop(event: DragEvent, dropZone: DropZone<any>, dragData: DragData<any>): void { // Calculate the new order based on DOM position and data-order attributes const newSortingOrder = this.calculateSortingOrder(event, dropZone.element, dragData); // Set the new sorting order (this will be different from originalOrder) dragData.sortingOrder = newSortingOrder; // The originalOrder should already be set from dragstart, but ensure it's there if (dragData.originalOrder === undefined) { const originalOrderAttr = dragData.element.getAttribute('data-order'); dragData.originalOrder = originalOrderAttr ? parseFloat(originalOrderAttr) : 0; } // Store original position index if not already set if (dragData.originalPosition === undefined) { dragData.originalPosition = this.originalPosition; } // Get all items order for component to use - pass dragData to calculate new order dragData.allItemsOrder = this.getAllItemsOrder(dropZone.element, dragData); } /** * Clear sortstate attributes from all items in container */ private clearSortStateAttributes(container: HTMLElement): void { const allItems = Array.from(container.children) as HTMLElement[]; allItems.forEach(item => { item.removeAttribute('sortstate'); }); } /** * Apply sortstate attributes to provide visual feedback */ private applySortStateAttributes(container: HTMLElement, afterElement: HTMLElement | null, dropY: number): void { if (!this.currentDrag) return; const allItems = Array.from(container.children) as HTMLElement[]; const draggedElement = this.currentDrag.element; const draggedIndex = allItems.indexOf(draggedElement); // Calculate where the item will be inserted let insertIndex: number; if (afterElement === null) { insertIndex = allItems.length - 1; // Insert at end } else { insertIndex = allItems.indexOf(afterElement); } allItems.forEach((item, index) => { if (item === draggedElement) return; // Skip the dragged item if (draggedIndex < insertIndex) { // Moving item down - items between current and target move up if (index > draggedIndex && index <= insertIndex) { item.setAttribute('sortstate', 'move-up'); // Show gap above the target position if (index === insertIndex) { item.setAttribute('sortstate', 'gap-above'); } } } else if (draggedIndex > insertIndex) { // Moving item up - items between target and current move down if (index >= insertIndex && index < draggedIndex) { item.setAttribute('sortstate', 'move-down'); // Show gap above the target position if (index === insertIndex) { item.setAttribute('sortstate', 'gap-above'); } } } // Special case: if inserting at the very end, show gap below last item if (afterElement === null && index === allItems.length - 2) { item.setAttribute('sortstate', 'gap-below'); } }); } /** * Get the element that should come after the dragged element */ private getDragAfterElement(container: HTMLElement, y: number): HTMLElement | null { const draggableElements = Array.from(container.children).filter( (child: Element) => child as HTMLElement !== this.currentDrag?.element ) as HTMLElement[]; return draggableElements.reduce((closest: any, child: HTMLElement) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }).element; } /** * Calculate sorting order based on drop position */ private calculateSortingOrder(event: DragEvent, container: HTMLElement, dragData: DragData<any>): number { const allItems = Array.from(container.children) as HTMLElement[]; const draggedElement = dragData.element; // Use intended drop position if available, otherwise calculate from mouse position let newIndex: number; if (dragData.intendedAfterElement !== undefined) { if (dragData.intendedAfterElement === null) { // Intended to be at the end newIndex = allItems.length - 1; } else { // Intended to be before a specific element const afterIndex = allItems.indexOf(dragData.intendedAfterElement); newIndex = afterIndex > 0 ? afterIndex - 1 : 0; } } else { // Calculate based on mouse position const dropY = dragData.lastDropY || event.clientY; newIndex = 0; for (let i = 0; i < allItems.length; i++) { const item = allItems[i] as HTMLElement; if (item === draggedElement) continue; const rect = item.getBoundingClientRect(); const itemCenterY = rect.top + (rect.height / 2); if (dropY < itemCenterY) { newIndex = i; break; } else { newIndex = i + 1; } } // Adjust if we're moving the dragged element const currentIndex = allItems.indexOf(draggedElement); if (currentIndex < newIndex) { newIndex--; } } // Ensure index is within bounds newIndex = Math.max(0, Math.min(newIndex, allItems.length - 1)); return newIndex; } /** * Cleanup all drag and drop handlers */ cleanup(): void { // Clean up all handlers using handler manager this.handlerManager.clearAll(); // Clear drop zones this.dropZones.clear(); this.currentDrag = null; } } export default DragDropService.getSingleton(); export { DragDropService as DragDropServiceInstance };