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; | ||
|  |       } | ||
|  |     `;
 | ||
|  |   } | ||
|  | } |