- Implemented WysiwygModalManager for managing modals related to code blocks and block settings. - Created WysiwygSelection for handling text selection across Shadow DOM boundaries. - Introduced WysiwygShortcuts for managing keyboard shortcuts and slash menu items. - Developed wysiwygStyles for consistent styling of the WYSIWYG editor. - Defined types for blocks, slash menu items, and shortcut patterns in wysiwyg.types.ts.
		
			
				
	
	
		
			406 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			406 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
 | |
| import type { IBlock } from '../../wysiwyg.types.js';
 | |
| import { cssManager } from '@design.estate/dees-element';
 | |
| 
 | |
| /**
 | |
|  * ImageBlockHandler - Handles image upload, display, and interactions
 | |
|  * 
 | |
|  * Features:
 | |
|  * - Click to upload
 | |
|  * - Drag and drop support
 | |
|  * - Base64 encoding (TODO: server upload in production)
 | |
|  * - Loading states
 | |
|  * - Alt text from filename
 | |
|  */
 | |
| export class ImageBlockHandler extends BaseBlockHandler {
 | |
|   type = 'image';
 | |
|   
 | |
|   render(block: IBlock, isSelected: boolean): string {
 | |
|     const imageUrl = block.metadata?.url;
 | |
|     const altText = block.content || 'Image';
 | |
|     const isLoading = block.metadata?.loading;
 | |
|     
 | |
|     return `
 | |
|       <div class="image-block-container${isSelected ? ' selected' : ''}" 
 | |
|            data-block-id="${block.id}"
 | |
|            data-has-image="${!!imageUrl}"
 | |
|            tabindex="0">
 | |
|         ${isLoading ? this.renderLoading() : 
 | |
|           imageUrl ? this.renderImage(imageUrl, altText) : 
 | |
|           this.renderPlaceholder()}
 | |
|         <input type="file" 
 | |
|                class="image-file-input" 
 | |
|                accept="image/*" 
 | |
|                style="display: none;" />
 | |
|       </div>
 | |
|     `;
 | |
|   }
 | |
|   
 | |
|   private renderPlaceholder(): string {
 | |
|     return `
 | |
|       <div class="image-upload-placeholder" style="cursor: pointer;">
 | |
|         <div class="upload-icon" style="pointer-events: none;">
 | |
|           <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
 | |
|             <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
 | |
|             <circle cx="8.5" cy="8.5" r="1.5"/>
 | |
|             <polyline points="21 15 16 10 5 21"/>
 | |
|           </svg>
 | |
|         </div>
 | |
|         <div class="upload-text" style="pointer-events: none;">Click to upload an image</div>
 | |
|         <div class="upload-hint" style="pointer-events: none;">or drag and drop</div>
 | |
|       </div>
 | |
|     `;
 | |
|   }
 | |
|   
 | |
|   private renderImage(url: string, altText: string): string {
 | |
|     return `
 | |
|       <div class="image-container">
 | |
|         <img src="${url}" alt="${this.escapeHtml(altText)}" />
 | |
|       </div>
 | |
|     `;
 | |
|   }
 | |
|   
 | |
|   private renderLoading(): string {
 | |
|     return `
 | |
|       <div class="image-loading">
 | |
|         <div class="loading-spinner"></div>
 | |
|         <div class="loading-text">Uploading image...</div>
 | |
|       </div>
 | |
|     `;
 | |
|   }
 | |
|   
 | |
|   setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
 | |
|     const container = element.querySelector('.image-block-container') as HTMLElement;
 | |
|     const fileInput = element.querySelector('.image-file-input') as HTMLInputElement;
 | |
|     
 | |
|     if (!container) {
 | |
|       console.error('ImageBlockHandler: Could not find container');
 | |
|       return;
 | |
|     }
 | |
|     
 | |
|     if (!fileInput) {
 | |
|       console.error('ImageBlockHandler: Could not find file input');
 | |
|       return;
 | |
|     }
 | |
|     
 | |
|     // Click to upload (only on placeholder)
 | |
|     const placeholder = container.querySelector('.image-upload-placeholder');
 | |
|     if (placeholder) {
 | |
|       placeholder.addEventListener('click', (e) => {
 | |
|         e.preventDefault();
 | |
|         e.stopPropagation();
 | |
|         console.log('ImageBlockHandler: Placeholder clicked, opening file selector');
 | |
|         fileInput.click();
 | |
|       });
 | |
|     }
 | |
|     
 | |
|     // Container click for focus
 | |
|     container.addEventListener('click', () => {
 | |
|       handlers.onFocus();
 | |
|     });
 | |
|     
 | |
|     // File input change
 | |
|     fileInput.addEventListener('change', async (e) => {
 | |
|       const input = e.target as HTMLInputElement;
 | |
|       const file = input.files?.[0];
 | |
|       if (file) {
 | |
|         console.log('ImageBlockHandler: File selected:', file.name);
 | |
|         await this.handleFileUpload(file, block, handlers);
 | |
|       }
 | |
|     });
 | |
|     
 | |
|     // Drag and drop
 | |
|     container.addEventListener('dragover', (e) => {
 | |
|       e.preventDefault();
 | |
|       e.stopPropagation();
 | |
|       if (!block.metadata?.url) {
 | |
|         container.classList.add('drag-over');
 | |
|       }
 | |
|     });
 | |
|     
 | |
|     container.addEventListener('dragleave', (e) => {
 | |
|       e.preventDefault();
 | |
|       e.stopPropagation();
 | |
|       container.classList.remove('drag-over');
 | |
|     });
 | |
|     
 | |
|     container.addEventListener('drop', async (e) => {
 | |
|       e.preventDefault();
 | |
|       e.stopPropagation();
 | |
|       container.classList.remove('drag-over');
 | |
|       
 | |
|       const file = e.dataTransfer?.files[0];
 | |
|       if (file && file.type.startsWith('image/') && !block.metadata?.url) {
 | |
|         await this.handleFileUpload(file, block, handlers);
 | |
|       }
 | |
|     });
 | |
|     
 | |
|     // Focus/blur
 | |
|     container.addEventListener('focus', () => handlers.onFocus());
 | |
|     container.addEventListener('blur', () => handlers.onBlur());
 | |
|     
 | |
|     // Keyboard navigation
 | |
|     container.addEventListener('keydown', (e) => {
 | |
|       if (e.key === 'Delete' || e.key === 'Backspace') {
 | |
|         if (block.metadata?.url) {
 | |
|           // Clear the image
 | |
|           block.metadata.url = undefined;
 | |
|           block.metadata.loading = false;
 | |
|           block.content = '';
 | |
|           handlers.onInput(new InputEvent('input'));
 | |
|           return;
 | |
|         }
 | |
|       }
 | |
|       handlers.onKeyDown(e);
 | |
|     });
 | |
|   }
 | |
|   
 | |
|   private async handleFileUpload(
 | |
|     file: File, 
 | |
|     block: IBlock, 
 | |
|     handlers: IBlockEventHandlers
 | |
|   ): Promise<void> {
 | |
|     console.log('ImageBlockHandler: Starting file upload', { 
 | |
|       fileName: file.name, 
 | |
|       fileSize: file.size,
 | |
|       blockId: block.id 
 | |
|     });
 | |
|     
 | |
|     // Validate file
 | |
|     if (!file.type.startsWith('image/')) {
 | |
|       console.error('Invalid file type:', file.type);
 | |
|       return;
 | |
|     }
 | |
|     
 | |
|     // Check file size (10MB limit)
 | |
|     const maxSize = 10 * 1024 * 1024;
 | |
|     if (file.size > maxSize) {
 | |
|       console.error('File too large. Maximum size is 10MB');
 | |
|       return;
 | |
|     }
 | |
|     
 | |
|     // Set loading state
 | |
|     if (!block.metadata) block.metadata = {};
 | |
|     block.metadata.loading = true;
 | |
|     block.metadata.fileName = file.name;
 | |
|     block.metadata.fileSize = file.size;
 | |
|     block.metadata.mimeType = file.type;
 | |
|     
 | |
|     console.log('ImageBlockHandler: Set loading state, requesting update');
 | |
|     // Request immediate UI update for loading state
 | |
|     handlers.onRequestUpdate?.();
 | |
|     
 | |
|     try {
 | |
|       // Convert to base64
 | |
|       const dataUrl = await this.fileToDataUrl(file);
 | |
|       
 | |
|       // Update block
 | |
|       block.metadata.url = dataUrl;
 | |
|       block.metadata.loading = false;
 | |
|       
 | |
|       // Set default alt text from filename
 | |
|       const nameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
 | |
|       block.content = nameWithoutExt;
 | |
|       
 | |
|       console.log('ImageBlockHandler: Upload complete, requesting update', {
 | |
|         hasUrl: !!block.metadata.url,
 | |
|         urlLength: dataUrl.length,
 | |
|         altText: block.content
 | |
|       });
 | |
|       
 | |
|       // Request immediate UI update to show uploaded image
 | |
|       handlers.onRequestUpdate?.();
 | |
|       
 | |
|     } catch (error) {
 | |
|       console.error('Failed to upload image:', error);
 | |
|       block.metadata.loading = false;
 | |
|       // Request UI update to clear loading state
 | |
|       handlers.onRequestUpdate?.();
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   private fileToDataUrl(file: File): Promise<string> {
 | |
|     return new Promise((resolve, reject) => {
 | |
|       const reader = new FileReader();
 | |
|       reader.onload = (e) => {
 | |
|         const result = e.target?.result;
 | |
|         if (typeof result === 'string') {
 | |
|           resolve(result);
 | |
|         } else {
 | |
|           reject(new Error('Failed to read file'));
 | |
|         }
 | |
|       };
 | |
|       reader.onerror = reject;
 | |
|       reader.readAsDataURL(file);
 | |
|     });
 | |
|   }
 | |
|   
 | |
|   private escapeHtml(text: string): string {
 | |
|     const div = document.createElement('div');
 | |
|     div.textContent = text;
 | |
|     return div.innerHTML;
 | |
|   }
 | |
|   
 | |
|   getContent(element: HTMLElement): string {
 | |
|     // Content is the alt text
 | |
|     const block = this.getBlockFromElement(element);
 | |
|     return block?.content || '';
 | |
|   }
 | |
|   
 | |
|   setContent(element: HTMLElement, content: string): void {
 | |
|     // Content is the alt text
 | |
|     const block = this.getBlockFromElement(element);
 | |
|     if (block) {
 | |
|       block.content = content;
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   private getBlockFromElement(element: HTMLElement): IBlock | null {
 | |
|     const container = element.querySelector('.image-block-container');
 | |
|     const blockId = container?.getAttribute('data-block-id');
 | |
|     if (!blockId) return null;
 | |
|     
 | |
|     // This is a simplified version - in real implementation,
 | |
|     // we'd need access to the block data
 | |
|     return {
 | |
|       id: blockId,
 | |
|       type: 'image',
 | |
|       content: '',
 | |
|       metadata: {}
 | |
|     };
 | |
|   }
 | |
|   
 | |
|   getCursorPosition(element: HTMLElement): number | null {
 | |
|     return null; // Images don't have cursor position
 | |
|   }
 | |
|   
 | |
|   setCursorToStart(element: HTMLElement): void {
 | |
|     this.focus(element);
 | |
|   }
 | |
|   
 | |
|   setCursorToEnd(element: HTMLElement): void {
 | |
|     this.focus(element);
 | |
|   }
 | |
|   
 | |
|   focus(element: HTMLElement): void {
 | |
|     const container = element.querySelector('.image-block-container') as HTMLElement;
 | |
|     container?.focus();
 | |
|   }
 | |
|   
 | |
|   focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
 | |
|     this.focus(element);
 | |
|   }
 | |
|   
 | |
|   getSplitContent(element: HTMLElement): { before: string; after: string } | null {
 | |
|     return null; // Images can't be split
 | |
|   }
 | |
|   
 | |
|   getStyles(): string {
 | |
|     return `
 | |
|       /* Image Block Container */
 | |
|       .image-block-container {
 | |
|         position: relative;
 | |
|         margin: 12px 0;
 | |
|         border-radius: 6px;
 | |
|         overflow: hidden;
 | |
|         transition: all 0.15s ease;
 | |
|         outline: none;
 | |
|         cursor: pointer;
 | |
|       }
 | |
|       
 | |
|       .image-block-container.selected {
 | |
|         box-shadow: 0 0 0 2px ${cssManager.bdTheme('#6366f1', '#818cf8')};
 | |
|       }
 | |
|       
 | |
|       /* Upload Placeholder */
 | |
|       .image-upload-placeholder {
 | |
|         display: flex;
 | |
|         flex-direction: column;
 | |
|         align-items: center;
 | |
|         justify-content: center;
 | |
|         padding: 48px 24px;
 | |
|         border: 2px dashed ${cssManager.bdTheme('#e5e7eb', '#374151')};
 | |
|         border-radius: 6px;
 | |
|         background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
 | |
|         transition: all 0.15s ease;
 | |
|       }
 | |
|       
 | |
|       .image-block-container:hover .image-upload-placeholder {
 | |
|         border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
 | |
|         background: ${cssManager.bdTheme('#f9fafb', '#111827')};
 | |
|       }
 | |
|       
 | |
|       .image-block-container.drag-over .image-upload-placeholder {
 | |
|         border-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
 | |
|         background: ${cssManager.bdTheme('#eff6ff', '#1e1b4b')};
 | |
|       }
 | |
|       
 | |
|       .upload-icon {
 | |
|         margin-bottom: 12px;
 | |
|         color: ${cssManager.bdTheme('#9ca3af', '#4b5563')};
 | |
|       }
 | |
|       
 | |
|       .upload-text {
 | |
|         font-size: 14px;
 | |
|         font-weight: 500;
 | |
|         color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
 | |
|         margin-bottom: 4px;
 | |
|       }
 | |
|       
 | |
|       .upload-hint {
 | |
|         font-size: 12px;
 | |
|         color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
 | |
|       }
 | |
|       
 | |
|       /* Image Container */
 | |
|       .image-container {
 | |
|         display: flex;
 | |
|         justify-content: center;
 | |
|         align-items: center;
 | |
|         min-height: 200px;
 | |
|         background: ${cssManager.bdTheme('#f9fafb', '#111827')};
 | |
|       }
 | |
|       
 | |
|       .image-container img {
 | |
|         max-width: 100%;
 | |
|         height: auto;
 | |
|         display: block;
 | |
|         border-radius: 4px;
 | |
|       }
 | |
|       
 | |
|       /* Loading State */
 | |
|       .image-loading {
 | |
|         display: flex;
 | |
|         flex-direction: column;
 | |
|         align-items: center;
 | |
|         justify-content: center;
 | |
|         padding: 48px 24px;
 | |
|         background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')};
 | |
|       }
 | |
|       
 | |
|       .loading-spinner {
 | |
|         width: 32px;
 | |
|         height: 32px;
 | |
|         border: 3px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
 | |
|         border-top-color: ${cssManager.bdTheme('#6366f1', '#818cf8')};
 | |
|         border-radius: 50%;
 | |
|         animation: spin 0.8s linear infinite;
 | |
|         margin-bottom: 12px;
 | |
|       }
 | |
|       
 | |
|       @keyframes spin {
 | |
|         to { transform: rotate(360deg); }
 | |
|       }
 | |
|       
 | |
|       .loading-text {
 | |
|         font-size: 14px;
 | |
|         color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
 | |
|       }
 | |
|       
 | |
|       /* File input hidden */
 | |
|       .image-file-input {
 | |
|         display: none !important;
 | |
|       }
 | |
|     `;
 | |
|   }
 | |
| } |