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 `
📎
File Attachments
${files.length > 0 ? this.renderFiles(files) : this.renderPlaceholder()}
${files.length > 0 ? '' : ''}
`; } private renderPlaceholder(): string { return `
Click to add files
or drag and drop
`; } private renderFiles(files: any[]): string { return files.map((file: any) => `
${this.getFileIcon(file.type)}
${this.escapeHtml(file.name)}
${this.formatFileSize(file.size)}
`).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 { 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 { 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; } `; } }