/** * Block Editor Drag & Drop * * Native HTML5 Drag & Drop API implementation for reordering CMS blocks */ export class BlockEditorDnD { constructor(container) { this.container = container; this.blocksContainer = container.querySelector('[data-sortable="blocks"]'); this.draggedElement = null; this.dragOverIndex = null; this.componentId = null; // Find component ID from parent LiveComponent const liveComponent = container.closest('[data-live-component]'); if (liveComponent) { this.componentId = liveComponent.dataset.liveComponent; } this.init(); } init() { if (!this.blocksContainer) { return; } // Make all block cards draggable this.updateDraggableElements(); // Listen for dynamically added blocks (LiveComponent updates) this.observeBlockChanges(); } /** * Make all block cards draggable and add event listeners */ updateDraggableElements() { const blockCards = this.blocksContainer.querySelectorAll('.admin-block-card'); blockCards.forEach((card, index) => { // Set draggable attribute card.draggable = true; card.dataset.blockIndex = index; // Remove existing listeners to avoid duplicates const newCard = card.cloneNode(true); card.parentNode.replaceChild(newCard, card); // Add drag event listeners to card newCard.addEventListener('dragstart', (e) => this.handleDragStart(e, newCard)); newCard.addEventListener('dragend', (e) => this.handleDragEnd(e, newCard)); newCard.addEventListener('dragover', (e) => this.handleDragOver(e, newCard)); newCard.addEventListener('dragenter', (e) => this.handleDragEnter(e, newCard)); newCard.addEventListener('dragleave', (e) => this.handleDragLeave(e, newCard)); newCard.addEventListener('drop', (e) => this.handleDrop(e, newCard)); // Prevent drag start on interactive elements, but allow it on drag handle const interactiveElements = newCard.querySelectorAll('button, input, textarea, select, a'); interactiveElements.forEach(el => { // Allow drag handle to start drag if (el.closest('[data-drag-handle="true"]')) { return; } el.addEventListener('mousedown', (e) => { // Prevent drag when clicking on interactive elements e.stopPropagation(); }); }); }); } /** * Observe DOM changes to update draggable elements when blocks are added/removed */ observeBlockChanges() { const observer = new MutationObserver(() => { this.updateDraggableElements(); }); observer.observe(this.blocksContainer, { childList: true, subtree: false }); } /** * Handle drag start */ handleDragStart(e, card) { // Don't start drag if clicking on buttons or inputs if (e.target.closest('button, input, textarea, select')) { e.preventDefault(); return; } this.draggedElement = card; card.classList.add('admin-block-card--dragging'); // Set drag data e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/html', card.outerHTML); e.dataTransfer.setData('text/plain', card.dataset.blockId); // Add visual feedback e.dataTransfer.setDragImage(card, 0, 0); // Add placeholder styling this.blocksContainer.classList.add('admin-block-editor__blocks--dragging'); } /** * Handle drag end */ handleDragEnd(e, card) { card.classList.remove('admin-block-card--dragging'); this.blocksContainer.classList.remove('admin-block-editor__blocks--dragging'); // Remove all drag-over classes const allCards = this.blocksContainer.querySelectorAll('.admin-block-card'); allCards.forEach(c => { c.classList.remove('admin-block-card--drag-over'); c.classList.remove('admin-block-card--drag-over-before'); c.classList.remove('admin-block-card--drag-over-after'); }); this.draggedElement = null; this.dragOverIndex = null; } /** * Handle drag over */ handleDragOver(e, card) { if (this.draggedElement === card) { return; } e.preventDefault(); e.dataTransfer.dropEffect = 'move'; // Calculate drop position const cardRect = card.getBoundingClientRect(); const mouseY = e.clientY; const cardMiddleY = cardRect.top + cardRect.height / 2; // Determine if we're dropping before or after this card const isBefore = mouseY < cardMiddleY; // Remove all drag-over classes const allCards = this.blocksContainer.querySelectorAll('.admin-block-card'); allCards.forEach(c => { c.classList.remove('admin-block-card--drag-over'); c.classList.remove('admin-block-card--drag-over-before'); c.classList.remove('admin-block-card--drag-over-after'); }); // Add appropriate class if (isBefore) { card.classList.add('admin-block-card--drag-over-before'); } else { card.classList.add('admin-block-card--drag-over-after'); } } /** * Handle drag enter */ handleDragEnter(e, card) { if (this.draggedElement === card) { return; } e.preventDefault(); } /** * Handle drag leave */ handleDragLeave(e, card) { // Only remove classes if we're actually leaving the card if (!card.contains(e.relatedTarget)) { card.classList.remove('admin-block-card--drag-over'); card.classList.remove('admin-block-card--drag-over-before'); card.classList.remove('admin-block-card--drag-over-after'); } } /** * Handle drop */ handleDrop(e, targetCard) { e.preventDefault(); e.stopPropagation(); if (this.draggedElement === targetCard || !this.draggedElement) { return; } // Get all block cards in current order const allCards = Array.from(this.blocksContainer.querySelectorAll('.admin-block-card')); const draggedIndex = allCards.indexOf(this.draggedElement); const targetIndex = allCards.indexOf(targetCard); // Calculate final position based on drop zone const cardRect = targetCard.getBoundingClientRect(); const mouseY = e.clientY; const cardMiddleY = cardRect.top + cardRect.height / 2; const isBefore = mouseY < cardMiddleY; let finalIndex = targetIndex; if (isBefore && draggedIndex > targetIndex) { finalIndex = targetIndex; } else if (!isBefore && draggedIndex < targetIndex) { finalIndex = targetIndex + 1; } else if (isBefore && draggedIndex < targetIndex) { finalIndex = targetIndex; } else { finalIndex = targetIndex + 1; } // Extract block IDs in new order const blockIds = allCards.map(card => card.dataset.blockId); // Remove dragged element from array blockIds.splice(draggedIndex, 1); // Insert at new position blockIds.splice(finalIndex, 0, this.draggedElement.dataset.blockId); // Call LiveComponent action to reorder blocks this.reorderBlocks(blockIds); // Clean up this.handleDragEnd(e, targetCard); } /** * Call LiveComponent action to reorder blocks */ reorderBlocks(blockIds) { if (!this.componentId) { console.error('BlockEditorDnD: No component ID found'); return; } // Find the LiveComponent instance const liveComponentElement = document.querySelector(`[data-live-component="${this.componentId}"]`); if (!liveComponentElement) { console.error('BlockEditorDnD: LiveComponent element not found'); return; } // Use the LiveComponent API if available if (window.liveComponentManager) { const manager = window.liveComponentManager; const component = manager.getComponent(this.componentId); if (component) { // Call action directly component.executeAction('reorderBlocks', { blockIds: blockIds }); return; } } // Fallback: Create a temporary button to trigger the action const actionButton = document.createElement('button'); actionButton.type = 'button'; actionButton.dataset.liveAction = 'reorderBlocks'; actionButton.dataset.liveComponent = this.componentId; actionButton.style.display = 'none'; // Set block IDs as JSON string in data attribute // The parameter binder will convert data-param-block-ids to blockIds actionButton.dataset.paramBlockIds = JSON.stringify(blockIds); liveComponentElement.appendChild(actionButton); // Trigger click event (LiveComponent handler will pick it up) actionButton.click(); // Remove temporary button after a short delay setTimeout(() => { actionButton.remove(); }, 100); } /** * Initialize all block editors on the page */ static initializeAll() { const containers = document.querySelectorAll('.admin-block-editor'); const instances = []; containers.forEach(container => { instances.push(new BlockEditorDnD(container)); }); return instances; } } // Auto-initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { BlockEditorDnD.initializeAll(); }); } else { BlockEditorDnD.initializeAll(); } // Re-initialize when LiveComponents are loaded dynamically document.addEventListener('livecomponent:loaded', () => { BlockEditorDnD.initializeAll(); });