- 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.
		
			
				
	
	
		
			562 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			562 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js';
 | |
| import type { IBlock } from '../../wysiwyg.types.js';
 | |
| import { cssManager } from '@design.estate/dees-element';
 | |
| 
 | |
| /**
 | |
|  * MarkdownBlockHandler - Handles markdown content with preview/edit toggle
 | |
|  * 
 | |
|  * Features:
 | |
|  * - Live markdown preview
 | |
|  * - Edit/preview mode toggle
 | |
|  * - Syntax highlighting in edit mode
 | |
|  * - Common markdown shortcuts
 | |
|  * - Auto-save on mode switch
 | |
|  */
 | |
| export class MarkdownBlockHandler extends BaseBlockHandler {
 | |
|   type = 'markdown';
 | |
|   
 | |
|   render(block: IBlock, isSelected: boolean): string {
 | |
|     const isEditMode = block.metadata?.isEditMode ?? true;
 | |
|     const content = block.content || '';
 | |
|     
 | |
|     return `
 | |
|       <div class="markdown-block-container${isSelected ? ' selected' : ''}" 
 | |
|            data-block-id="${block.id}"
 | |
|            data-edit-mode="${isEditMode}">
 | |
|         <div class="markdown-header">
 | |
|           <div class="markdown-icon">M↓</div>
 | |
|           <div class="markdown-title">Markdown</div>
 | |
|           <button class="markdown-toggle-mode" title="${isEditMode ? 'Preview' : 'Edit'}">
 | |
|             ${isEditMode ? '👁️' : '✏️'}
 | |
|           </button>
 | |
|         </div>
 | |
|         <div class="markdown-content">
 | |
|           ${isEditMode ? this.renderEditor(content) : this.renderPreview(content)}
 | |
|         </div>
 | |
|       </div>
 | |
|     `;
 | |
|   }
 | |
|   
 | |
|   private renderEditor(content: string): string {
 | |
|     return `
 | |
|       <textarea class="markdown-editor" 
 | |
|                 placeholder="Enter markdown content..."
 | |
|                 spellcheck="false">${this.escapeHtml(content)}</textarea>
 | |
|     `;
 | |
|   }
 | |
|   
 | |
|   private renderPreview(content: string): string {
 | |
|     const html = this.parseMarkdown(content);
 | |
|     return `
 | |
|       <div class="markdown-preview">
 | |
|         ${html || '<div class="preview-empty">No content to preview</div>'}
 | |
|       </div>
 | |
|     `;
 | |
|   }
 | |
|   
 | |
|   setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
 | |
|     const container = element.querySelector('.markdown-block-container') as HTMLElement;
 | |
|     const toggleBtn = element.querySelector('.markdown-toggle-mode') as HTMLButtonElement;
 | |
|     
 | |
|     if (!container || !toggleBtn) {
 | |
|       console.error('MarkdownBlockHandler: Could not find required elements');
 | |
|       return;
 | |
|     }
 | |
|     
 | |
|     // Initialize metadata
 | |
|     if (!block.metadata) block.metadata = {};
 | |
|     if (block.metadata.isEditMode === undefined) block.metadata.isEditMode = true;
 | |
|     
 | |
|     // Toggle mode button
 | |
|     toggleBtn.addEventListener('click', (e) => {
 | |
|       e.preventDefault();
 | |
|       e.stopPropagation();
 | |
|       
 | |
|       // Save current content if in edit mode
 | |
|       if (block.metadata.isEditMode) {
 | |
|         const editor = container.querySelector('.markdown-editor') as HTMLTextAreaElement;
 | |
|         if (editor) {
 | |
|           block.content = editor.value;
 | |
|         }
 | |
|       }
 | |
|       
 | |
|       // Toggle mode
 | |
|       block.metadata.isEditMode = !block.metadata.isEditMode;
 | |
|       
 | |
|       // Request UI update
 | |
|       handlers.onRequestUpdate?.();
 | |
|     });
 | |
|     
 | |
|     // Setup based on mode
 | |
|     if (block.metadata.isEditMode) {
 | |
|       this.setupEditor(element, block, handlers);
 | |
|     } else {
 | |
|       this.setupPreview(element, block, handlers);
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   private setupEditor(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
 | |
|     const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
 | |
|     if (!editor) return;
 | |
|     
 | |
|     // Focus handling
 | |
|     editor.addEventListener('focus', () => handlers.onFocus());
 | |
|     editor.addEventListener('blur', () => handlers.onBlur());
 | |
|     
 | |
|     // Content changes
 | |
|     editor.addEventListener('input', () => {
 | |
|       block.content = editor.value;
 | |
|     });
 | |
|     
 | |
|     // Keyboard shortcuts
 | |
|     editor.addEventListener('keydown', (e) => {
 | |
|       // Tab handling for indentation
 | |
|       if (e.key === 'Tab') {
 | |
|         e.preventDefault();
 | |
|         const start = editor.selectionStart;
 | |
|         const end = editor.selectionEnd;
 | |
|         const value = editor.value;
 | |
|         
 | |
|         if (e.shiftKey) {
 | |
|           // Unindent
 | |
|           const beforeCursor = value.substring(0, start);
 | |
|           const lastNewline = beforeCursor.lastIndexOf('\n');
 | |
|           const lineStart = lastNewline + 1;
 | |
|           const lineContent = value.substring(lineStart, start);
 | |
|           
 | |
|           if (lineContent.startsWith('  ')) {
 | |
|             editor.value = value.substring(0, lineStart) + lineContent.substring(2) + value.substring(start);
 | |
|             editor.selectionStart = editor.selectionEnd = start - 2;
 | |
|           }
 | |
|         } else {
 | |
|           // Indent
 | |
|           editor.value = value.substring(0, start) + '  ' + value.substring(end);
 | |
|           editor.selectionStart = editor.selectionEnd = start + 2;
 | |
|         }
 | |
|         
 | |
|         block.content = editor.value;
 | |
|         return;
 | |
|       }
 | |
|       
 | |
|       // Bold shortcut (Ctrl/Cmd + B)
 | |
|       if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
 | |
|         e.preventDefault();
 | |
|         this.wrapSelection(editor, '**', '**');
 | |
|         block.content = editor.value;
 | |
|         return;
 | |
|       }
 | |
|       
 | |
|       // Italic shortcut (Ctrl/Cmd + I)
 | |
|       if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
 | |
|         e.preventDefault();
 | |
|         this.wrapSelection(editor, '_', '_');
 | |
|         block.content = editor.value;
 | |
|         return;
 | |
|       }
 | |
|       
 | |
|       // Link shortcut (Ctrl/Cmd + K)
 | |
|       if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
 | |
|         e.preventDefault();
 | |
|         this.insertLink(editor);
 | |
|         block.content = editor.value;
 | |
|         return;
 | |
|       }
 | |
|       
 | |
|       // Pass other key events to handlers
 | |
|       handlers.onKeyDown(e);
 | |
|     });
 | |
|     
 | |
|     // Auto-resize
 | |
|     this.autoResize(editor);
 | |
|     editor.addEventListener('input', () => this.autoResize(editor));
 | |
|   }
 | |
|   
 | |
|   private setupPreview(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void {
 | |
|     const container = element.querySelector('.markdown-block-container') as HTMLElement;
 | |
|     const preview = element.querySelector('.markdown-preview') as HTMLElement;
 | |
|     
 | |
|     if (!container || !preview) return;
 | |
|     
 | |
|     // Make preview focusable
 | |
|     preview.setAttribute('tabindex', '0');
 | |
|     
 | |
|     // Focus handling
 | |
|     preview.addEventListener('focus', () => handlers.onFocus());
 | |
|     preview.addEventListener('blur', () => handlers.onBlur());
 | |
|     
 | |
|     // Keyboard navigation
 | |
|     preview.addEventListener('keydown', (e) => {
 | |
|       // Switch to edit mode on Enter
 | |
|       if (e.key === 'Enter' && !e.shiftKey) {
 | |
|         e.preventDefault();
 | |
|         block.metadata.isEditMode = true;
 | |
|         handlers.onRequestUpdate?.();
 | |
|         return;
 | |
|       }
 | |
|       
 | |
|       handlers.onKeyDown(e);
 | |
|     });
 | |
|   }
 | |
|   
 | |
|   private wrapSelection(editor: HTMLTextAreaElement, before: string, after: string): void {
 | |
|     const start = editor.selectionStart;
 | |
|     const end = editor.selectionEnd;
 | |
|     const selectedText = editor.value.substring(start, end);
 | |
|     const replacement = before + (selectedText || 'text') + after;
 | |
|     
 | |
|     editor.value = editor.value.substring(0, start) + replacement + editor.value.substring(end);
 | |
|     
 | |
|     if (selectedText) {
 | |
|       editor.selectionStart = start;
 | |
|       editor.selectionEnd = start + replacement.length;
 | |
|     } else {
 | |
|       editor.selectionStart = start + before.length;
 | |
|       editor.selectionEnd = start + before.length + 4; // 'text'.length
 | |
|     }
 | |
|     
 | |
|     editor.focus();
 | |
|   }
 | |
|   
 | |
|   private insertLink(editor: HTMLTextAreaElement): void {
 | |
|     const start = editor.selectionStart;
 | |
|     const end = editor.selectionEnd;
 | |
|     const selectedText = editor.value.substring(start, end);
 | |
|     const linkText = selectedText || 'link text';
 | |
|     const replacement = `[${linkText}](url)`;
 | |
|     
 | |
|     editor.value = editor.value.substring(0, start) + replacement + editor.value.substring(end);
 | |
|     
 | |
|     // Select the URL part
 | |
|     editor.selectionStart = start + linkText.length + 3; // '[linktext]('.length
 | |
|     editor.selectionEnd = start + linkText.length + 6; // '[linktext](url'.length
 | |
|     
 | |
|     editor.focus();
 | |
|   }
 | |
|   
 | |
|   private autoResize(editor: HTMLTextAreaElement): void {
 | |
|     editor.style.height = 'auto';
 | |
|     editor.style.height = editor.scrollHeight + 'px';
 | |
|   }
 | |
|   
 | |
|   private parseMarkdown(markdown: string): string {
 | |
|     // Basic markdown parsing - in production, use a proper markdown parser
 | |
|     let html = this.escapeHtml(markdown);
 | |
|     
 | |
|     // Headers
 | |
|     html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
 | |
|     html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
 | |
|     html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
 | |
|     
 | |
|     // Bold
 | |
|     html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
 | |
|     html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
 | |
|     
 | |
|     // Italic
 | |
|     html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
 | |
|     html = html.replace(/_(.+?)_/g, '<em>$1</em>');
 | |
|     
 | |
|     // Code blocks
 | |
|     html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
 | |
|     
 | |
|     // Inline code
 | |
|     html = html.replace(/`(.+?)`/g, '<code>$1</code>');
 | |
|     
 | |
|     // Links
 | |
|     html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
 | |
|     
 | |
|     // Lists
 | |
|     html = html.replace(/^\* (.+)$/gm, '<li>$1</li>');
 | |
|     html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
 | |
|     html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
 | |
|     
 | |
|     // Wrap consecutive list items
 | |
|     html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => {
 | |
|       return '<ul>' + match + '</ul>';
 | |
|     });
 | |
|     
 | |
|     // Paragraphs
 | |
|     html = html.replace(/\n\n/g, '</p><p>');
 | |
|     html = '<p>' + html + '</p>';
 | |
|     
 | |
|     // Clean up empty paragraphs
 | |
|     html = html.replace(/<p><\/p>/g, '');
 | |
|     html = html.replace(/<p>(<h[1-3]>)/g, '$1');
 | |
|     html = html.replace(/(<\/h[1-3]>)<\/p>/g, '$1');
 | |
|     html = html.replace(/<p>(<ul>)/g, '$1');
 | |
|     html = html.replace(/(<\/ul>)<\/p>/g, '$1');
 | |
|     html = html.replace(/<p>(<pre>)/g, '$1');
 | |
|     html = html.replace(/(<\/pre>)<\/p>/g, '$1');
 | |
|     
 | |
|     return html;
 | |
|   }
 | |
|   
 | |
|   private escapeHtml(text: string): string {
 | |
|     const div = document.createElement('div');
 | |
|     div.textContent = text;
 | |
|     return div.innerHTML;
 | |
|   }
 | |
|   
 | |
|   getContent(element: HTMLElement): string {
 | |
|     const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
 | |
|     if (editor) {
 | |
|       return editor.value;
 | |
|     }
 | |
|     
 | |
|     // If in preview mode, return the stored content
 | |
|     const container = element.querySelector('.markdown-block-container');
 | |
|     const blockId = container?.getAttribute('data-block-id');
 | |
|     // In real implementation, would need access to block data
 | |
|     return '';
 | |
|   }
 | |
|   
 | |
|   setContent(element: HTMLElement, content: string): void {
 | |
|     const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
 | |
|     if (editor) {
 | |
|       editor.value = content;
 | |
|       this.autoResize(editor);
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   getCursorPosition(element: HTMLElement): number | null {
 | |
|     const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
 | |
|     return editor ? editor.selectionStart : null;
 | |
|   }
 | |
|   
 | |
|   setCursorToStart(element: HTMLElement): void {
 | |
|     const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
 | |
|     if (editor) {
 | |
|       editor.selectionStart = editor.selectionEnd = 0;
 | |
|       editor.focus();
 | |
|     } else {
 | |
|       this.focus(element);
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   setCursorToEnd(element: HTMLElement): void {
 | |
|     const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
 | |
|     if (editor) {
 | |
|       const length = editor.value.length;
 | |
|       editor.selectionStart = editor.selectionEnd = length;
 | |
|       editor.focus();
 | |
|     } else {
 | |
|       this.focus(element);
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   focus(element: HTMLElement): void {
 | |
|     const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
 | |
|     if (editor) {
 | |
|       editor.focus();
 | |
|     } else {
 | |
|       const preview = element.querySelector('.markdown-preview') as HTMLElement;
 | |
|       preview?.focus();
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void {
 | |
|     const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
 | |
|     if (editor) {
 | |
|       if (position === 'start') {
 | |
|         this.setCursorToStart(element);
 | |
|       } else if (position === 'end') {
 | |
|         this.setCursorToEnd(element);
 | |
|       } else if (typeof position === 'number') {
 | |
|         editor.selectionStart = editor.selectionEnd = position;
 | |
|         editor.focus();
 | |
|       }
 | |
|     } else {
 | |
|       this.focus(element);
 | |
|     }
 | |
|   }
 | |
|   
 | |
|   getSplitContent(element: HTMLElement): { before: string; after: string } | null {
 | |
|     const editor = element.querySelector('.markdown-editor') as HTMLTextAreaElement;
 | |
|     if (!editor) return null;
 | |
|     
 | |
|     const cursorPos = editor.selectionStart;
 | |
|     return {
 | |
|       before: editor.value.substring(0, cursorPos),
 | |
|       after: editor.value.substring(cursorPos)
 | |
|     };
 | |
|   }
 | |
|   
 | |
|   getStyles(): string {
 | |
|     return `
 | |
|       /* Markdown Block Container */
 | |
|       .markdown-block-container {
 | |
|         position: relative;
 | |
|         margin: 12px 0;
 | |
|         border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
 | |
|         border-radius: 6px;
 | |
|         overflow: hidden;
 | |
|         transition: all 0.15s ease;
 | |
|         background: ${cssManager.bdTheme('#ffffff', '#111827')};
 | |
|       }
 | |
|       
 | |
|       .markdown-block-container.selected {
 | |
|         border-color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
 | |
|       }
 | |
|       
 | |
|       /* Header */
 | |
|       .markdown-header {
 | |
|         display: flex;
 | |
|         align-items: center;
 | |
|         gap: 8px;
 | |
|         padding: 8px 12px;
 | |
|         border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
 | |
|         background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
 | |
|       }
 | |
|       
 | |
|       .markdown-icon {
 | |
|         font-size: 14px;
 | |
|         font-weight: 600;
 | |
|         opacity: 0.8;
 | |
|       }
 | |
|       
 | |
|       .markdown-title {
 | |
|         flex: 1;
 | |
|         font-size: 13px;
 | |
|         font-weight: 500;
 | |
|         color: ${cssManager.bdTheme('#374151', '#e5e7eb')};
 | |
|       }
 | |
|       
 | |
|       .markdown-toggle-mode {
 | |
|         padding: 4px 8px;
 | |
|         background: transparent;
 | |
|         border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
 | |
|         border-radius: 4px;
 | |
|         font-size: 14px;
 | |
|         cursor: pointer;
 | |
|         transition: all 0.15s ease;
 | |
|       }
 | |
|       
 | |
|       .markdown-toggle-mode:hover {
 | |
|         background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
 | |
|         border-color: ${cssManager.bdTheme('#d1d5db', '#4b5563')};
 | |
|       }
 | |
|       
 | |
|       /* Content */
 | |
|       .markdown-content {
 | |
|         position: relative;
 | |
|         min-height: 120px;
 | |
|       }
 | |
|       
 | |
|       /* Editor */
 | |
|       .markdown-editor {
 | |
|         width: 100%;
 | |
|         min-height: 120px;
 | |
|         padding: 12px;
 | |
|         background: transparent;
 | |
|         border: none;
 | |
|         outline: none;
 | |
|         resize: none;
 | |
|         font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
 | |
|         font-size: 13px;
 | |
|         line-height: 1.6;
 | |
|         color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
 | |
|         overflow: hidden;
 | |
|       }
 | |
|       
 | |
|       .markdown-editor::placeholder {
 | |
|         color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
 | |
|       }
 | |
|       
 | |
|       /* Preview */
 | |
|       .markdown-preview {
 | |
|         padding: 12px;
 | |
|         min-height: 96px;
 | |
|         outline: none;
 | |
|         font-size: 14px;
 | |
|         line-height: 1.6;
 | |
|         color: ${cssManager.bdTheme('#1f2937', '#f3f4f6')};
 | |
|       }
 | |
|       
 | |
|       .preview-empty {
 | |
|         color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
 | |
|         font-style: italic;
 | |
|       }
 | |
|       
 | |
|       /* Markdown preview styles */
 | |
|       .markdown-preview h1 {
 | |
|         font-size: 24px;
 | |
|         font-weight: 600;
 | |
|         margin: 16px 0 8px 0;
 | |
|         color: ${cssManager.bdTheme('#111827', '#f9fafb')};
 | |
|       }
 | |
|       
 | |
|       .markdown-preview h2 {
 | |
|         font-size: 20px;
 | |
|         font-weight: 600;
 | |
|         margin: 14px 0 6px 0;
 | |
|         color: ${cssManager.bdTheme('#111827', '#f9fafb')};
 | |
|       }
 | |
|       
 | |
|       .markdown-preview h3 {
 | |
|         font-size: 18px;
 | |
|         font-weight: 600;
 | |
|         margin: 12px 0 4px 0;
 | |
|         color: ${cssManager.bdTheme('#111827', '#f9fafb')};
 | |
|       }
 | |
|       
 | |
|       .markdown-preview p {
 | |
|         margin: 8px 0;
 | |
|       }
 | |
|       
 | |
|       .markdown-preview ul,
 | |
|       .markdown-preview ol {
 | |
|         margin: 8px 0;
 | |
|         padding-left: 24px;
 | |
|       }
 | |
|       
 | |
|       .markdown-preview li {
 | |
|         margin: 4px 0;
 | |
|       }
 | |
|       
 | |
|       .markdown-preview code {
 | |
|         background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
 | |
|         padding: 2px 4px;
 | |
|         border-radius: 3px;
 | |
|         font-family: 'Monaco', 'Consolas', 'Courier New', monospace;
 | |
|         font-size: 0.9em;
 | |
|       }
 | |
|       
 | |
|       .markdown-preview pre {
 | |
|         background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
 | |
|         padding: 12px;
 | |
|         border-radius: 4px;
 | |
|         overflow-x: auto;
 | |
|         margin: 8px 0;
 | |
|       }
 | |
|       
 | |
|       .markdown-preview pre code {
 | |
|         background: transparent;
 | |
|         padding: 0;
 | |
|       }
 | |
|       
 | |
|       .markdown-preview strong {
 | |
|         font-weight: 600;
 | |
|         color: ${cssManager.bdTheme('#111827', '#f9fafb')};
 | |
|       }
 | |
|       
 | |
|       .markdown-preview em {
 | |
|         font-style: italic;
 | |
|       }
 | |
|       
 | |
|       .markdown-preview a {
 | |
|         color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
 | |
|         text-decoration: none;
 | |
|       }
 | |
|       
 | |
|       .markdown-preview a:hover {
 | |
|         text-decoration: underline;
 | |
|       }
 | |
|       
 | |
|       .markdown-preview blockquote {
 | |
|         border-left: 3px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
 | |
|         padding-left: 12px;
 | |
|         margin: 8px 0;
 | |
|         color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
 | |
|       }
 | |
|     `;
 | |
|   }
 | |
| } |