fix(wysiwyg):Improve Wysiwyg editor

This commit is contained in:
Juergen Kunz
2025-06-24 12:24:02 +00:00
parent 35a648d450
commit 08a4c361fa
4 changed files with 311 additions and 26 deletions

View File

@ -168,7 +168,6 @@ export class DeesInputWysiwyg extends DeesInputBase<string> {
dragHandle.className = 'drag-handle';
dragHandle.draggable = true;
dragHandle.addEventListener('dragstart', (e) => this.dragDropHandler.handleDragStart(e, block));
dragHandle.addEventListener('dragend', () => this.dragDropHandler.handleDragEnd());
wrapper.appendChild(dragHandle);
}

View File

@ -1,3 +1,4 @@
* We don't use lit template logic, but use static`` here to handle dom operations ourselves
* We try to have separated concerns in different classes
* We try to have clean concise and managable code
* lets log whats happening, so if something goes wrong, we understand whats happening.

View File

@ -5,6 +5,13 @@ export class WysiwygDragDropHandler {
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: any) {
this.component = component;
@ -31,26 +38,71 @@ export class WysiwygDragDropHandler {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', block.id);
// Update component state (for parent drag handler access)
// 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;
// The parent component already handles adding dragging classes programmatically
// 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;
// The parent component already handles removing dragging classes programmatically
}
/**
@ -126,4 +178,227 @@ export class WysiwygDragDropHandler {
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);
}
}

View File

@ -326,7 +326,7 @@ export const wysiwygStyles = css`
/* Drag and Drop Styles */
.block-wrapper {
position: relative;
transition: all 0.2s ease;
transition: transform 0.3s ease, opacity 0.2s ease;
}
/* Ensure proper spacing context for blocks */
@ -385,31 +385,41 @@ export const wysiwygStyles = css`
}
.block-wrapper.dragging {
opacity: 0.5;
opacity: 0.8;
pointer-events: none;
position: relative;
z-index: 2001;
transition: none !important;
}
.block-wrapper.drag-over-before::before {
content: '';
position: absolute;
top: -8px;
left: -8px;
right: -8px;
height: 2px;
background: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
border-radius: 1px;
box-shadow: 0 0 8px ${cssManager.bdTheme('rgba(0, 102, 204, 0.4)', 'rgba(77, 148, 255, 0.4)')};
/* Blocks that should move out of the way */
.block-wrapper.move-down {
transform: translateY(var(--drag-offset, 0px));
}
.block-wrapper.drag-over-after::after {
content: '';
.block-wrapper.move-up {
transform: translateY(calc(-1 * var(--drag-offset, 0px)));
}
/* Drop indicator */
.drop-indicator {
position: absolute;
bottom: -8px;
left: -8px;
right: -8px;
height: 2px;
background: ${cssManager.bdTheme('#0066cc', '#4d94ff')};
border-radius: 1px;
box-shadow: 0 0 8px ${cssManager.bdTheme('rgba(0, 102, 204, 0.4)', 'rgba(77, 148, 255, 0.4)')};
left: 0;
right: 0;
background: ${cssManager.bdTheme('rgba(0, 102, 204, 0.08)', 'rgba(77, 148, 255, 0.08)')};
border: 2px dashed ${cssManager.bdTheme('#0066cc', '#4d94ff')};
border-radius: 8px;
transition: top 0.2s ease, height 0.2s ease;
pointer-events: none;
z-index: 1999;
box-sizing: border-box;
}
/* Remove old drag-over styles */
.block-wrapper.drag-over-before,
.block-wrapper.drag-over-after {
/* No longer needed, using drop indicator instead */
}
.editor-content.dragging * {