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