Some checks failed
Deploy Application / deploy (push) Has been cancelled
318 lines
10 KiB
JavaScript
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();
|
|
});
|
|
|