477 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			477 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js'; | |||
|  | import type { IBlock } from '../../wysiwyg.types.js'; | |||
|  | import { cssManager } from '@design.estate/dees-element'; | |||
|  | 
 | |||
|  | /** | |||
|  |  * AttachmentBlockHandler - Handles file attachments | |||
|  |  *  | |||
|  |  * Features: | |||
|  |  * - Multiple file upload support | |||
|  |  * - Click to upload or drag and drop | |||
|  |  * - File type icons | |||
|  |  * - Remove individual files | |||
|  |  * - Base64 encoding (TODO: server upload in production) | |||
|  |  */ | |||
|  | export class AttachmentBlockHandler extends BaseBlockHandler { | |||
|  |   type = 'attachment'; | |||
|  |    | |||
|  |   render(block: IBlock, isSelected: boolean): string { | |||
|  |     const files = block.metadata?.files || []; | |||
|  |      | |||
|  |     return `
 | |||
|  |       <div class="attachment-block-container${isSelected ? ' selected' : ''}"  | |||
|  |            data-block-id="${block.id}" | |||
|  |            tabindex="0"> | |||
|  |         <div class="attachment-header"> | |||
|  |           <div class="attachment-icon">📎</div> | |||
|  |           <div class="attachment-title">File Attachments</div> | |||
|  |         </div> | |||
|  |         <div class="attachment-list"> | |||
|  |           ${files.length > 0 ? this.renderFiles(files) : this.renderPlaceholder()} | |||
|  |         </div> | |||
|  |         <input type="file"  | |||
|  |                class="attachment-file-input"  | |||
|  |                multiple  | |||
|  |                style="display: none;" /> | |||
|  |         ${files.length > 0 ? '<button class="add-more-files">Add More Files</button>' : ''} | |||
|  |       </div> | |||
|  |     `;
 | |||
|  |   } | |||
|  |    | |||
|  |   private renderPlaceholder(): string { | |||
|  |     return `
 | |||
|  |       <div class="attachment-placeholder"> | |||
|  |         <div class="placeholder-text">Click to add files</div> | |||
|  |         <div class="placeholder-hint">or drag and drop</div> | |||
|  |       </div> | |||
|  |     `;
 | |||
|  |   } | |||
|  |    | |||
|  |   private renderFiles(files: any[]): string { | |||
|  |     return files.map((file: any) => `
 | |||
|  |       <div class="attachment-item" data-file-id="${file.id}"> | |||
|  |         <div class="file-icon">${this.getFileIcon(file.type)}</div> | |||
|  |         <div class="file-info"> | |||
|  |           <div class="file-name">${this.escapeHtml(file.name)}</div> | |||
|  |           <div class="file-size">${this.formatFileSize(file.size)}</div> | |||
|  |         </div> | |||
|  |         <button class="remove-file" data-file-id="${file.id}">×</button> | |||
|  |       </div> | |||
|  |     `).join('');
 | |||
|  |   } | |||
|  |    | |||
|  |   setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void { | |||
|  |     const container = element.querySelector('.attachment-block-container') as HTMLElement; | |||
|  |     const fileInput = element.querySelector('.attachment-file-input') as HTMLInputElement; | |||
|  |      | |||
|  |     if (!container || !fileInput) { | |||
|  |       console.error('AttachmentBlockHandler: Could not find required elements'); | |||
|  |       return; | |||
|  |     } | |||
|  |      | |||
|  |     // Initialize files array if needed
 | |||
|  |     if (!block.metadata) block.metadata = {}; | |||
|  |     if (!block.metadata.files) block.metadata.files = []; | |||
|  |      | |||
|  |     // Click to upload on placeholder
 | |||
|  |     const placeholder = container.querySelector('.attachment-placeholder'); | |||
|  |     if (placeholder) { | |||
|  |       placeholder.addEventListener('click', (e) => { | |||
|  |         e.preventDefault(); | |||
|  |         e.stopPropagation(); | |||
|  |         fileInput.click(); | |||
|  |       }); | |||
|  |     } | |||
|  |      | |||
|  |     // Add more files button
 | |||
|  |     const addMoreBtn = container.querySelector('.add-more-files') as HTMLButtonElement; | |||
|  |     if (addMoreBtn) { | |||
|  |       addMoreBtn.addEventListener('click', (e) => { | |||
|  |         e.preventDefault(); | |||
|  |         e.stopPropagation(); | |||
|  |         fileInput.click(); | |||
|  |       }); | |||
|  |     } | |||
|  |      | |||
|  |     // File input change
 | |||
|  |     fileInput.addEventListener('change', async (e) => { | |||
|  |       const input = e.target as HTMLInputElement; | |||
|  |       const files = input.files; | |||
|  |       if (files && files.length > 0) { | |||
|  |         await this.handleFileAttachments(files, block, handlers); | |||
|  |         input.value = ''; // Clear input for next selection
 | |||
|  |       } | |||
|  |     }); | |||
|  |      | |||
|  |     // Remove file buttons
 | |||
|  |     container.addEventListener('click', (e) => { | |||
|  |       const target = e.target as HTMLElement; | |||
|  |       if (target.classList.contains('remove-file')) { | |||
|  |         e.preventDefault(); | |||
|  |         e.stopPropagation(); | |||
|  |         const fileId = target.getAttribute('data-file-id'); | |||
|  |         if (fileId) { | |||
|  |           this.removeFile(fileId, block, handlers); | |||
|  |         } | |||
|  |       } | |||
|  |     }); | |||
|  |      | |||
|  |     // Drag and drop
 | |||
|  |     container.addEventListener('dragover', (e) => { | |||
|  |       e.preventDefault(); | |||
|  |       e.stopPropagation(); | |||
|  |       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 files = e.dataTransfer?.files; | |||
|  |       if (files && files.length > 0) { | |||
|  |         await this.handleFileAttachments(files, 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') { | |||
|  |         // Only remove all files if container is focused, not when removing individual files
 | |||
|  |         if (document.activeElement === container && block.metadata?.files?.length > 0) { | |||
|  |           e.preventDefault(); | |||
|  |           block.metadata.files = []; | |||
|  |           handlers.onRequestUpdate?.(); | |||
|  |           return; | |||
|  |         } | |||
|  |       } | |||
|  |       handlers.onKeyDown(e); | |||
|  |     }); | |||
|  |   } | |||
|  |    | |||
|  |   private async handleFileAttachments( | |||
|  |     files: FileList, | |||
|  |     block: IBlock, | |||
|  |     handlers: IBlockEventHandlers | |||
|  |   ): Promise<void> { | |||
|  |     if (!block.metadata) block.metadata = {}; | |||
|  |     if (!block.metadata.files) block.metadata.files = []; | |||
|  |      | |||
|  |     for (const file of Array.from(files)) { | |||
|  |       try { | |||
|  |         const dataUrl = await this.fileToDataUrl(file); | |||
|  |         const fileData = { | |||
|  |           id: this.generateId(), | |||
|  |           name: file.name, | |||
|  |           size: file.size, | |||
|  |           type: file.type, | |||
|  |           data: dataUrl | |||
|  |         }; | |||
|  |          | |||
|  |         block.metadata.files.push(fileData); | |||
|  |       } catch (error) { | |||
|  |         console.error('Failed to attach file:', file.name, error); | |||
|  |       } | |||
|  |     } | |||
|  |      | |||
|  |     // Update block content with file count
 | |||
|  |     block.content = `${block.metadata.files.length} file${block.metadata.files.length !== 1 ? 's' : ''} attached`; | |||
|  |      | |||
|  |     // Request UI update
 | |||
|  |     handlers.onRequestUpdate?.(); | |||
|  |   } | |||
|  |    | |||
|  |   private removeFile(fileId: string, block: IBlock, handlers: IBlockEventHandlers): void { | |||
|  |     if (!block.metadata?.files) return; | |||
|  |      | |||
|  |     block.metadata.files = block.metadata.files.filter((f: any) => f.id !== fileId); | |||
|  |      | |||
|  |     // Update content
 | |||
|  |     block.content = block.metadata.files.length > 0  | |||
|  |       ? `${block.metadata.files.length} file${block.metadata.files.length !== 1 ? 's' : ''} attached` | |||
|  |       : ''; | |||
|  |      | |||
|  |     // Request UI update
 | |||
|  |     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 getFileIcon(mimeType: string): string { | |||
|  |     if (mimeType.startsWith('image/')) return '🖼️'; | |||
|  |     if (mimeType.startsWith('video/')) return '🎥'; | |||
|  |     if (mimeType.startsWith('audio/')) return '🎵'; | |||
|  |     if (mimeType.includes('pdf')) return '📄'; | |||
|  |     if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('tar')) return '🗄️'; | |||
|  |     if (mimeType.includes('sheet')) return '📊'; | |||
|  |     if (mimeType.includes('document') || mimeType.includes('msword')) return '📝'; | |||
|  |     if (mimeType.includes('presentation')) return '📋'; | |||
|  |     if (mimeType.includes('text')) return '📃'; | |||
|  |     return '📁'; | |||
|  |   } | |||
|  |    | |||
|  |   private formatFileSize(bytes: number): string { | |||
|  |     if (bytes === 0) return '0 Bytes'; | |||
|  |     const k = 1024; | |||
|  |     const sizes = ['Bytes', 'KB', 'MB', 'GB']; | |||
|  |     const i = Math.floor(Math.log(bytes) / Math.log(k)); | |||
|  |     return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | |||
|  |   } | |||
|  |    | |||
|  |   private generateId(): string { | |||
|  |     return `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; | |||
|  |   } | |||
|  |    | |||
|  |   private escapeHtml(text: string): string { | |||
|  |     const div = document.createElement('div'); | |||
|  |     div.textContent = text; | |||
|  |     return div.innerHTML; | |||
|  |   } | |||
|  |    | |||
|  |   getContent(element: HTMLElement): string { | |||
|  |     // Content is the description of attached files
 | |||
|  |     const block = this.getBlockFromElement(element); | |||
|  |     return block?.content || ''; | |||
|  |   } | |||
|  |    | |||
|  |   setContent(element: HTMLElement, content: string): void { | |||
|  |     // Content is the description of attached files
 | |||
|  |     const block = this.getBlockFromElement(element); | |||
|  |     if (block) { | |||
|  |       block.content = content; | |||
|  |     } | |||
|  |   } | |||
|  |    | |||
|  |   private getBlockFromElement(element: HTMLElement): IBlock | null { | |||
|  |     const container = element.querySelector('.attachment-block-container'); | |||
|  |     const blockId = container?.getAttribute('data-block-id'); | |||
|  |     if (!blockId) return null; | |||
|  |      | |||
|  |     // Simplified version - in real implementation would need access to block data
 | |||
|  |     return { | |||
|  |       id: blockId, | |||
|  |       type: 'attachment', | |||
|  |       content: '', | |||
|  |       metadata: {} | |||
|  |     }; | |||
|  |   } | |||
|  |    | |||
|  |   getCursorPosition(element: HTMLElement): number | null { | |||
|  |     return null; // Attachment blocks 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('.attachment-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; // Attachment blocks can't be split
 | |||
|  |   } | |||
|  |    | |||
|  |   getStyles(): string { | |||
|  |     return `
 | |||
|  |       /* Attachment Block Container */ | |||
|  |       .attachment-block-container { | |||
|  |         position: relative; | |||
|  |         margin: 12px 0; | |||
|  |         border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; | |||
|  |         border-radius: 6px; | |||
|  |         overflow: hidden; | |||
|  |         transition: all 0.15s ease; | |||
|  |         outline: none; | |||
|  |         background: ${cssManager.bdTheme('#ffffff', '#111827')}; | |||
|  |       } | |||
|  |        | |||
|  |       .attachment-block-container.selected { | |||
|  |         border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')}; | |||
|  |       } | |||
|  |        | |||
|  |       .attachment-block-container.drag-over { | |||
|  |         background: ${cssManager.bdTheme('#f9fafb', '#1f2937')}; | |||
|  |         border-color: ${cssManager.bdTheme('#6366f1', '#818cf8')}; | |||
|  |       } | |||
|  |        | |||
|  |       /* Header */ | |||
|  |       .attachment-header { | |||
|  |         display: flex; | |||
|  |         align-items: center; | |||
|  |         gap: 8px; | |||
|  |         padding: 12px 16px; | |||
|  |         border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; | |||
|  |         background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')}; | |||
|  |       } | |||
|  |        | |||
|  |       .attachment-icon { | |||
|  |         font-size: 18px; | |||
|  |         opacity: 0.8; | |||
|  |       } | |||
|  |        | |||
|  |       .attachment-title { | |||
|  |         font-size: 14px; | |||
|  |         font-weight: 500; | |||
|  |         color: ${cssManager.bdTheme('#374151', '#e5e7eb')}; | |||
|  |       } | |||
|  |        | |||
|  |       /* File List */ | |||
|  |       .attachment-list { | |||
|  |         padding: 8px; | |||
|  |         min-height: 80px; | |||
|  |         display: flex; | |||
|  |         flex-direction: column; | |||
|  |         gap: 4px; | |||
|  |       } | |||
|  |        | |||
|  |       /* Placeholder */ | |||
|  |       .attachment-placeholder { | |||
|  |         display: flex; | |||
|  |         flex-direction: column; | |||
|  |         align-items: center; | |||
|  |         justify-content: center; | |||
|  |         padding: 24px; | |||
|  |         cursor: pointer; | |||
|  |         transition: all 0.15s ease; | |||
|  |       } | |||
|  |        | |||
|  |       .attachment-placeholder:hover { | |||
|  |         background: ${cssManager.bdTheme('#f9fafb', '#1f2937')}; | |||
|  |       } | |||
|  |        | |||
|  |       .placeholder-text { | |||
|  |         font-size: 14px; | |||
|  |         color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; | |||
|  |         margin-bottom: 4px; | |||
|  |       } | |||
|  |        | |||
|  |       .placeholder-hint { | |||
|  |         font-size: 12px; | |||
|  |         color: ${cssManager.bdTheme('#9ca3af', '#6b7280')}; | |||
|  |       } | |||
|  |        | |||
|  |       /* File Items */ | |||
|  |       .attachment-item { | |||
|  |         display: flex; | |||
|  |         align-items: center; | |||
|  |         gap: 12px; | |||
|  |         padding: 8px 12px; | |||
|  |         background: ${cssManager.bdTheme('#f9fafb', '#1f2937')}; | |||
|  |         border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; | |||
|  |         border-radius: 4px; | |||
|  |         transition: all 0.15s ease; | |||
|  |       } | |||
|  |        | |||
|  |       .attachment-item:hover { | |||
|  |         background: ${cssManager.bdTheme('#f3f4f6', '#374151')}; | |||
|  |       } | |||
|  |        | |||
|  |       .file-icon { | |||
|  |         font-size: 20px; | |||
|  |         flex-shrink: 0; | |||
|  |       } | |||
|  |        | |||
|  |       .file-info { | |||
|  |         flex: 1; | |||
|  |         min-width: 0; | |||
|  |       } | |||
|  |        | |||
|  |       .file-name { | |||
|  |         font-size: 13px; | |||
|  |         font-weight: 500; | |||
|  |         color: ${cssManager.bdTheme('#111827', '#f9fafb')}; | |||
|  |         overflow: hidden; | |||
|  |         text-overflow: ellipsis; | |||
|  |         white-space: nowrap; | |||
|  |       } | |||
|  |        | |||
|  |       .file-size { | |||
|  |         font-size: 11px; | |||
|  |         color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; | |||
|  |         margin-top: 2px; | |||
|  |       } | |||
|  |        | |||
|  |       .remove-file { | |||
|  |         flex-shrink: 0; | |||
|  |         width: 24px; | |||
|  |         height: 24px; | |||
|  |         display: flex; | |||
|  |         align-items: center; | |||
|  |         justify-content: center; | |||
|  |         background: transparent; | |||
|  |         border: 1px solid transparent; | |||
|  |         border-radius: 4px; | |||
|  |         color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; | |||
|  |         font-size: 18px; | |||
|  |         line-height: 1; | |||
|  |         cursor: pointer; | |||
|  |         transition: all 0.15s ease; | |||
|  |         padding: 0; | |||
|  |       } | |||
|  |        | |||
|  |       .remove-file:hover { | |||
|  |         background: ${cssManager.bdTheme('#fee2e2', '#991b1b')}; | |||
|  |         border-color: ${cssManager.bdTheme('#fca5a5', '#dc2626')}; | |||
|  |         color: ${cssManager.bdTheme('#dc2626', '#fca5a5')}; | |||
|  |       } | |||
|  |        | |||
|  |       /* Add More Files Button */ | |||
|  |       .add-more-files { | |||
|  |         margin: 8px; | |||
|  |         padding: 6px 12px; | |||
|  |         background: transparent; | |||
|  |         border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; | |||
|  |         border-radius: 4px; | |||
|  |         font-size: 13px; | |||
|  |         color: ${cssManager.bdTheme('#374151', '#e5e7eb')}; | |||
|  |         cursor: pointer; | |||
|  |         transition: all 0.15s ease; | |||
|  |         font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; | |||
|  |       } | |||
|  |        | |||
|  |       .add-more-files:hover { | |||
|  |         background: ${cssManager.bdTheme('#f9fafb', '#1f2937')}; | |||
|  |         border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')}; | |||
|  |       } | |||
|  |        | |||
|  |       /* Hidden file input */ | |||
|  |       .attachment-file-input { | |||
|  |         display: none !important; | |||
|  |       } | |||
|  |     `;
 | |||
|  |   } | |||
|  | } |