import { type IBlock } from './wysiwyg.types.js'; import { type IWysiwygComponent } from './wysiwyg.interfaces.js'; export class WysiwygDragDropHandler { private component: IWysiwygComponent; private draggedBlockId: string | null = null; private dragOverBlockId: string | null = null; private dragOverPosition: 'before' | 'after' | null = null; private dropIndicator: HTMLElement | null = null; private initialMouseY: number = 0; private initialBlockY: number = 0; private draggedBlockElement: HTMLElement | null = null; private draggedBlockHeight: number = 0; private lastUpdateTime: number = 0; private updateThrottle: number = 80; // milliseconds constructor(component: IWysiwygComponent) { this.component = component; } /** * Gets the current drag state */ get dragState() { return { draggedBlockId: this.draggedBlockId, dragOverBlockId: this.dragOverBlockId, dragOverPosition: this.dragOverPosition }; } /** * Handles drag start */ handleDragStart(e: DragEvent, block: IBlock): void { if (!e.dataTransfer) return; this.draggedBlockId = block.id; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', block.id); // Hide the default drag image const emptyImg = new Image(); emptyImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs='; e.dataTransfer.setDragImage(emptyImg, 0, 0); // Store initial mouse position and block element this.initialMouseY = e.clientY; this.draggedBlockElement = this.component.editorContentRef.querySelector(`[data-block-id="${block.id}"]`); if (this.draggedBlockElement) { const rect = this.draggedBlockElement.getBoundingClientRect(); this.draggedBlockHeight = rect.height; this.initialBlockY = rect.top; // Create drop indicator this.createDropIndicator(); // Set up drag event listeners document.addEventListener('dragover', this.handleGlobalDragOver); document.addEventListener('dragend', this.handleGlobalDragEnd); } // Update component state this.component.draggedBlockId = this.draggedBlockId; // Add dragging class after a small delay setTimeout(() => { if (this.draggedBlockElement) { this.draggedBlockElement.classList.add('dragging'); } if (this.component.editorContentRef) { this.component.editorContentRef.classList.add('dragging'); } }, 10); } /** * Handles drag end */ handleDragEnd(): void { // Clean up visual state const allBlocks = this.component.editorContentRef.querySelectorAll('.block-wrapper'); allBlocks.forEach((block: HTMLElement) => { block.classList.remove('dragging', 'move-up', 'move-down'); block.style.removeProperty('--drag-offset'); block.style.removeProperty('transform'); }); // Remove dragging class from editor if (this.component.editorContentRef) { this.component.editorContentRef.classList.remove('dragging'); } // Reset drag state this.draggedBlockId = null; this.dragOverBlockId = null; this.dragOverPosition = null; this.draggedBlockElement = null; this.draggedBlockHeight = 0; this.initialBlockY = 0; // Update component state this.component.draggedBlockId = null; this.component.dragOverBlockId = null; this.component.dragOverPosition = null; } /** * Handles drag over */ handleDragOver(e: DragEvent, block: IBlock): void { e.preventDefault(); if (!e.dataTransfer || !this.draggedBlockId || this.draggedBlockId === block.id) return; e.dataTransfer.dropEffect = 'move'; const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const midpoint = rect.top + rect.height / 2; this.dragOverBlockId = block.id; this.dragOverPosition = e.clientY < midpoint ? 'before' : 'after'; // Update component state this.component.dragOverBlockId = this.dragOverBlockId; this.component.dragOverPosition = this.dragOverPosition; // The parent component already handles drag-over classes programmatically } /** * Handles drag leave */ handleDragLeave(block: IBlock): void { if (this.dragOverBlockId === block.id) { this.dragOverBlockId = null; this.dragOverPosition = null; // Update component state this.component.dragOverBlockId = null; this.component.dragOverPosition = null; // The parent component already handles removing drag-over classes programmatically } } /** * Handles drop */ handleDrop(e: DragEvent, targetBlock: IBlock): void { e.preventDefault(); if (!this.draggedBlockId || this.draggedBlockId === targetBlock.id) return; // The parent component already has a handleDrop method that handles this programmatically // We'll delegate to that to ensure proper programmatic rendering this.component.handleDrop(e, targetBlock); } /** * Checks if a block is being dragged */ isDragging(blockId: string): boolean { return this.draggedBlockId === blockId; } /** * Checks if a block has drag over state */ isDragOver(blockId: string): boolean { return this.dragOverBlockId === blockId; } /** * Gets drag over CSS classes for a block */ getDragOverClasses(blockId: string): string { if (!this.isDragOver(blockId)) return ''; return this.dragOverPosition === 'before' ? 'drag-over-before' : 'drag-over-after'; } /** * Creates the drop indicator element */ private createDropIndicator(): void { this.dropIndicator = document.createElement('div'); this.dropIndicator.className = 'drop-indicator'; this.dropIndicator.style.display = 'none'; this.component.editorContentRef.appendChild(this.dropIndicator); } /** * Handles global dragover to update dragged block position and move other blocks */ private handleGlobalDragOver = (e: DragEvent): void => { e.preventDefault(); if (!this.draggedBlockElement) return; // Calculate vertical offset from initial position const deltaY = e.clientY - this.initialMouseY; // Apply transform to move the dragged block vertically this.draggedBlockElement.style.transform = `translateY(${deltaY}px)`; // Throttle position updates to reduce stuttering const now = Date.now(); if (now - this.lastUpdateTime < this.updateThrottle) { return; } this.lastUpdateTime = now; // Calculate which blocks should move this.updateBlockPositions(e.clientY); }; /** * Updates block positions based on cursor position */ private updateBlockPositions(mouseY: number): void { const blocks = Array.from(this.component.editorContentRef.querySelectorAll('.block-wrapper')) as HTMLElement[]; const draggedIndex = blocks.findIndex(b => b.getAttribute('data-block-id') === this.draggedBlockId); if (draggedIndex === -1) return; // Reset all transforms first (except the dragged block) blocks.forEach(block => { if (block.getAttribute('data-block-id') !== this.draggedBlockId) { block.classList.remove('move-up', 'move-down'); block.style.removeProperty('--drag-offset'); } }); // Calculate where the dragged block should be inserted let newIndex = blocks.length; // Default to end for (let i = 0; i < blocks.length; i++) { if (i === draggedIndex) continue; const block = blocks[i]; const rect = block.getBoundingClientRect(); const blockTop = rect.top; // Check if mouse is above this block's middle if (mouseY < blockTop + (rect.height * 0.5)) { newIndex = i; break; } } // Apply transforms to move blocks out of the way for (let i = 0; i < blocks.length; i++) { if (i === draggedIndex) continue; const block = blocks[i]; // Determine if this block needs to move if (draggedIndex < newIndex) { // Dragging down: blocks between original and new position move up if (i > draggedIndex && i < newIndex) { block.classList.add('move-up'); block.style.setProperty('--drag-offset', `${this.draggedBlockHeight}px`); } } else if (draggedIndex > newIndex) { // Dragging up: blocks between new and original position move down if (i >= newIndex && i < draggedIndex) { block.classList.add('move-down'); block.style.setProperty('--drag-offset', `${this.draggedBlockHeight}px`); } } } // Update drop indicator position this.updateDropIndicator(blocks, newIndex, draggedIndex); } /** * Updates the drop indicator position */ private updateDropIndicator(blocks: HTMLElement[], targetIndex: number, draggedIndex: number): void { if (!this.dropIndicator || !this.draggedBlockElement) return; this.dropIndicator.style.display = 'block'; this.dropIndicator.style.height = `${this.draggedBlockHeight}px`; const containerRect = this.component.editorContentRef.getBoundingClientRect(); // Calculate where the block will actually land let topPosition = 0; if (targetIndex === 0) { // Before first block topPosition = 0; } else { // After a specific block const prevIndex = targetIndex - 1; let blockCount = 0; // Find the visual position of the block that will be before our dropped block for (let i = 0; i < blocks.length; i++) { if (i === draggedIndex) continue; // Skip the dragged block if (blockCount === prevIndex) { const rect = blocks[i].getBoundingClientRect(); topPosition = rect.bottom - containerRect.top + 16; // 16px gap break; } blockCount++; } } this.dropIndicator.style.top = `${topPosition}px`; } /** * Handles global drag end */ private handleGlobalDragEnd = (): void => { // Clean up event listeners document.removeEventListener('dragover', this.handleGlobalDragOver); document.removeEventListener('dragend', this.handleGlobalDragEnd); // Remove drop indicator if (this.dropIndicator) { this.dropIndicator.remove(); this.dropIndicator = null; } // Trigger the actual drop if we have a dragged block if (this.draggedBlockId) { // Small delay to ensure transforms are applied requestAnimationFrame(() => { this.performDrop(); // Call the regular drag end handler after drop this.handleDragEnd(); }); } else { // Call the regular drag end handler this.handleDragEnd(); } }; /** * Performs the actual drop operation */ private performDrop(): void { if (!this.draggedBlockId) return; // Get the visual order of blocks based on their positions const blockElements = Array.from(this.component.editorContentRef.querySelectorAll('.block-wrapper')) as HTMLElement[]; const draggedElement = blockElements.find(el => el.getAttribute('data-block-id') === this.draggedBlockId); if (!draggedElement) return; // Create an array of blocks with their visual positions const visualOrder = blockElements.map(el => { const id = el.getAttribute('data-block-id'); const rect = el.getBoundingClientRect(); const centerY = rect.top + rect.height / 2; return { id, centerY, element: el }; }); // Sort by visual Y position visualOrder.sort((a, b) => a.centerY - b.centerY); // Get the new order of block IDs const newBlockIds = visualOrder.map(item => item.id).filter(id => id !== null); // Find the original block data const originalBlocks = [...this.component.blocks]; const draggedBlock = originalBlocks.find(b => b.id === this.draggedBlockId); if (!draggedBlock) return; // Check if order actually changed const oldOrder = originalBlocks.map(b => b.id); const orderChanged = !newBlockIds.every((id, index) => id === oldOrder[index]); if (!orderChanged) { return; } // Reorder blocks based on visual positions const newBlocks = newBlockIds.map(id => originalBlocks.find(b => b.id === id)!).filter(Boolean); // Update blocks this.component.blocks = newBlocks; // Re-render blocks programmatically this.component.renderBlocksProgrammatically(); // Update value this.component.updateValue(); // Focus the moved block after a delay setTimeout(() => { if (draggedBlock.type !== 'divider') { this.component.blockOperations.focusBlock(draggedBlock.id); } }, 100); } }