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 `
${isLoading ? this.renderLoading() : imageUrl ? this.renderImage(imageUrl, altText) : this.renderPlaceholder()}
`; } private renderPlaceholder(): string { return `
Click to upload an image
or drag and drop
`; } private renderImage(url: string, altText: string): string { return `
${this.escapeHtml(altText)}
`; } private renderLoading(): string { return `
Uploading image...
`; } 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 { 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 { 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; } `; } }