Files
michaelschiemer/resources/js/modules/admin/block-editor-dnd.js
2025-11-24 21:28:25 +01:00

318 lines
10 KiB
JavaScript

/**
* 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();
});