337 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			337 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | import { BaseBlockHandler, type IBlockEventHandlers } from '../block.base.js'; | ||
|  | import type { IBlock } from '../../wysiwyg.types.js'; | ||
|  | import { cssManager } from '@design.estate/dees-element'; | ||
|  | 
 | ||
|  | /** | ||
|  |  * YouTubeBlockHandler - Handles YouTube video embedding | ||
|  |  *  | ||
|  |  * Features: | ||
|  |  * - YouTube URL parsing and validation | ||
|  |  * - Video ID extraction from various YouTube URL formats | ||
|  |  * - Embedded iframe player | ||
|  |  * - Clean minimalist design | ||
|  |  */ | ||
|  | export class YouTubeBlockHandler extends BaseBlockHandler { | ||
|  |   type = 'youtube'; | ||
|  |    | ||
|  |   render(block: IBlock, isSelected: boolean): string { | ||
|  |     const videoId = block.metadata?.videoId; | ||
|  |     const url = block.metadata?.url || ''; | ||
|  |      | ||
|  |     return `
 | ||
|  |       <div class="youtube-block-container${isSelected ? ' selected' : ''}"  | ||
|  |            data-block-id="${block.id}" | ||
|  |            data-has-video="${!!videoId}"> | ||
|  |         ${videoId ? this.renderVideo(videoId) : this.renderPlaceholder(url)} | ||
|  |       </div> | ||
|  |     `;
 | ||
|  |   } | ||
|  |    | ||
|  |   private renderPlaceholder(url: string): string { | ||
|  |     return `
 | ||
|  |       <div class="youtube-placeholder"> | ||
|  |         <div class="placeholder-icon"> | ||
|  |           <svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor"> | ||
|  |             <path d="M19.615 3.184c-3.604-.246-11.631-.245-15.23 0-3.897.266-4.356 2.62-4.385 8.816.029 6.185.484 8.549 4.385 8.816 3.6.245 11.626.246 15.23 0 3.897-.266 4.356-2.62 4.385-8.816-.029-6.185-.484-8.549-4.385-8.816zm-10.615 12.816v-8l8 3.993-8 4.007z"/> | ||
|  |           </svg> | ||
|  |         </div> | ||
|  |         <div class="placeholder-text">Enter YouTube URL</div> | ||
|  |         <input type="url"  | ||
|  |                class="youtube-url-input"  | ||
|  |                placeholder="https://youtube.com/watch?v=..."  | ||
|  |                value="${this.escapeHtml(url)}" /> | ||
|  |         <button class="youtube-embed-btn">Embed Video</button> | ||
|  |       </div> | ||
|  |     `;
 | ||
|  |   } | ||
|  |    | ||
|  |   private renderVideo(videoId: string): string { | ||
|  |     return `
 | ||
|  |       <div class="youtube-container"> | ||
|  |         <iframe  | ||
|  |           src="https://www.youtube.com/embed/${videoId}" | ||
|  |           frameborder="0" | ||
|  |           allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" | ||
|  |           allowfullscreen | ||
|  |         ></iframe> | ||
|  |       </div> | ||
|  |     `;
 | ||
|  |   } | ||
|  |    | ||
|  |   setup(element: HTMLElement, block: IBlock, handlers: IBlockEventHandlers): void { | ||
|  |     const container = element.querySelector('.youtube-block-container') as HTMLElement; | ||
|  |     if (!container) return; | ||
|  |      | ||
|  |     // If video is already embedded, just handle focus/blur
 | ||
|  |     if (block.metadata?.videoId) { | ||
|  |       container.setAttribute('tabindex', '0'); | ||
|  |       container.addEventListener('focus', () => handlers.onFocus()); | ||
|  |       container.addEventListener('blur', () => handlers.onBlur()); | ||
|  |        | ||
|  |       // Handle deletion
 | ||
|  |       container.addEventListener('keydown', (e) => { | ||
|  |         if (e.key === 'Delete' || e.key === 'Backspace') { | ||
|  |           e.preventDefault(); | ||
|  |           handlers.onKeyDown(e); | ||
|  |         } else { | ||
|  |           handlers.onKeyDown(e); | ||
|  |         } | ||
|  |       }); | ||
|  |       return; | ||
|  |     } | ||
|  |      | ||
|  |     // Setup placeholder interactions
 | ||
|  |     const urlInput = element.querySelector('.youtube-url-input') as HTMLInputElement; | ||
|  |     const embedBtn = element.querySelector('.youtube-embed-btn') as HTMLButtonElement; | ||
|  |      | ||
|  |     if (!urlInput || !embedBtn) return; | ||
|  |      | ||
|  |     // Focus management
 | ||
|  |     urlInput.addEventListener('focus', () => handlers.onFocus()); | ||
|  |     urlInput.addEventListener('blur', () => handlers.onBlur()); | ||
|  |      | ||
|  |     // Handle embed button click
 | ||
|  |     embedBtn.addEventListener('click', () => { | ||
|  |       this.embedVideo(urlInput.value, block, handlers); | ||
|  |     }); | ||
|  |      | ||
|  |     // Handle Enter key in input
 | ||
|  |     urlInput.addEventListener('keydown', (e) => { | ||
|  |       if (e.key === 'Enter') { | ||
|  |         e.preventDefault(); | ||
|  |         this.embedVideo(urlInput.value, block, handlers); | ||
|  |       } else if (e.key === 'Escape') { | ||
|  |         e.preventDefault(); | ||
|  |         urlInput.blur(); | ||
|  |       } | ||
|  |     }); | ||
|  |      | ||
|  |     // Handle paste event
 | ||
|  |     urlInput.addEventListener('paste', (e) => { | ||
|  |       // Allow paste to complete first
 | ||
|  |       setTimeout(() => { | ||
|  |         const pastedUrl = urlInput.value; | ||
|  |         if (this.extractYouTubeVideoId(pastedUrl)) { | ||
|  |           // Auto-embed if valid YouTube URL was pasted
 | ||
|  |           this.embedVideo(pastedUrl, block, handlers); | ||
|  |         } | ||
|  |       }, 0); | ||
|  |     }); | ||
|  |      | ||
|  |     // Update URL in metadata as user types
 | ||
|  |     urlInput.addEventListener('input', () => { | ||
|  |       if (!block.metadata) block.metadata = {}; | ||
|  |       block.metadata.url = urlInput.value; | ||
|  |     }); | ||
|  |   } | ||
|  |    | ||
|  |   private embedVideo(url: string, block: IBlock, handlers: IBlockEventHandlers): void { | ||
|  |     const videoId = this.extractYouTubeVideoId(url); | ||
|  |      | ||
|  |     if (!videoId) { | ||
|  |       // Could show an error message here
 | ||
|  |       console.error('Invalid YouTube URL'); | ||
|  |       return; | ||
|  |     } | ||
|  |      | ||
|  |     // Update block metadata
 | ||
|  |     if (!block.metadata) block.metadata = {}; | ||
|  |     block.metadata.videoId = videoId; | ||
|  |     block.metadata.url = url; | ||
|  |      | ||
|  |     // Set content as video title (could be fetched from API in the future)
 | ||
|  |     block.content = `YouTube Video: ${videoId}`; | ||
|  |      | ||
|  |     // Request immediate UI update to show embedded video
 | ||
|  |     handlers.onRequestUpdate?.(); | ||
|  |   } | ||
|  |    | ||
|  |   private extractYouTubeVideoId(url: string): string | null { | ||
|  |     // Handle various YouTube URL formats
 | ||
|  |     const patterns = [ | ||
|  |       /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/, | ||
|  |       /youtube\.com\/embed\/([^"&?\/ ]{11})/, | ||
|  |       /youtube\.com\/watch\?v=([^"&?\/ ]{11})/, | ||
|  |       /youtu\.be\/([^"&?\/ ]{11})/ | ||
|  |     ]; | ||
|  |      | ||
|  |     for (const pattern of patterns) { | ||
|  |       const match = url.match(pattern); | ||
|  |       if (match) { | ||
|  |         return match[1]; | ||
|  |       } | ||
|  |     } | ||
|  |      | ||
|  |     return null; | ||
|  |   } | ||
|  |    | ||
|  |   private escapeHtml(text: string): string { | ||
|  |     const div = document.createElement('div'); | ||
|  |     div.textContent = text; | ||
|  |     return div.innerHTML; | ||
|  |   } | ||
|  |    | ||
|  |   getContent(element: HTMLElement): string { | ||
|  |     // Content is the video description/title
 | ||
|  |     const block = this.getBlockFromElement(element); | ||
|  |     return block?.content || ''; | ||
|  |   } | ||
|  |    | ||
|  |   setContent(element: HTMLElement, content: string): void { | ||
|  |     // Content is the video description/title
 | ||
|  |     const block = this.getBlockFromElement(element); | ||
|  |     if (block) { | ||
|  |       block.content = content; | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   private getBlockFromElement(element: HTMLElement): IBlock | null { | ||
|  |     const container = element.querySelector('.youtube-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: 'youtube', | ||
|  |       content: '', | ||
|  |       metadata: {} | ||
|  |     }; | ||
|  |   } | ||
|  |    | ||
|  |   getCursorPosition(element: HTMLElement): number | null { | ||
|  |     return null; // YouTube 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('.youtube-block-container') as HTMLElement; | ||
|  |     const urlInput = element.querySelector('.youtube-url-input') as HTMLInputElement; | ||
|  |      | ||
|  |     if (urlInput) { | ||
|  |       urlInput.focus(); | ||
|  |     } else if (container) { | ||
|  |       container.focus(); | ||
|  |     } | ||
|  |   } | ||
|  |    | ||
|  |   focusWithCursor(element: HTMLElement, position: 'start' | 'end' | number = 'end'): void { | ||
|  |     this.focus(element); | ||
|  |   } | ||
|  |    | ||
|  |   getSplitContent(element: HTMLElement): { before: string; after: string } | null { | ||
|  |     return null; // YouTube blocks can't be split
 | ||
|  |   } | ||
|  |    | ||
|  |   getStyles(): string { | ||
|  |     return `
 | ||
|  |       /* YouTube Block Container */ | ||
|  |       .youtube-block-container { | ||
|  |         position: relative; | ||
|  |         margin: 12px 0; | ||
|  |         border-radius: 6px; | ||
|  |         overflow: hidden; | ||
|  |         transition: all 0.15s ease; | ||
|  |         outline: none; | ||
|  |       } | ||
|  |        | ||
|  |       .youtube-block-container.selected { | ||
|  |         box-shadow: 0 0 0 2px ${cssManager.bdTheme('#6366f1', '#818cf8')}; | ||
|  |       } | ||
|  |        | ||
|  |       /* YouTube Placeholder */ | ||
|  |       .youtube-placeholder { | ||
|  |         display: flex; | ||
|  |         flex-direction: column; | ||
|  |         align-items: center; | ||
|  |         justify-content: center; | ||
|  |         padding: 32px 24px; | ||
|  |         border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; | ||
|  |         border-radius: 6px; | ||
|  |         background: ${cssManager.bdTheme('#fafafa', '#0a0a0a')}; | ||
|  |         gap: 12px; | ||
|  |       } | ||
|  |        | ||
|  |       .placeholder-icon { | ||
|  |         color: ${cssManager.bdTheme('#dc2626', '#ef4444')}; | ||
|  |         opacity: 0.8; | ||
|  |       } | ||
|  |        | ||
|  |       .placeholder-text { | ||
|  |         font-size: 14px; | ||
|  |         font-weight: 500; | ||
|  |         color: ${cssManager.bdTheme('#374151', '#e5e7eb')}; | ||
|  |       } | ||
|  |        | ||
|  |       .youtube-url-input { | ||
|  |         width: 100%; | ||
|  |         max-width: 400px; | ||
|  |         padding: 8px 12px; | ||
|  |         border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')}; | ||
|  |         border-radius: 4px; | ||
|  |         background: ${cssManager.bdTheme('#ffffff', '#111827')}; | ||
|  |         color: ${cssManager.bdTheme('#111827', '#f9fafb')}; | ||
|  |         font-size: 13px; | ||
|  |         font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; | ||
|  |         transition: all 0.15s ease; | ||
|  |         outline: none; | ||
|  |       } | ||
|  |        | ||
|  |       .youtube-url-input:focus { | ||
|  |         border-color: ${cssManager.bdTheme('#6b7280', '#9ca3af')}; | ||
|  |         background: ${cssManager.bdTheme('#ffffff', '#1f2937')}; | ||
|  |       } | ||
|  |        | ||
|  |       .youtube-url-input::placeholder { | ||
|  |         color: ${cssManager.bdTheme('#9ca3af', '#4b5563')}; | ||
|  |       } | ||
|  |        | ||
|  |       .youtube-embed-btn { | ||
|  |         padding: 6px 16px; | ||
|  |         background: ${cssManager.bdTheme('#111827', '#f9fafb')}; | ||
|  |         color: ${cssManager.bdTheme('#f9fafb', '#111827')}; | ||
|  |         border: 1px solid transparent; | ||
|  |         border-radius: 4px; | ||
|  |         font-size: 13px; | ||
|  |         font-weight: 500; | ||
|  |         font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; | ||
|  |         cursor: pointer; | ||
|  |         transition: all 0.15s ease; | ||
|  |         outline: none; | ||
|  |       } | ||
|  |        | ||
|  |       .youtube-embed-btn:hover { | ||
|  |         background: ${cssManager.bdTheme('#374151', '#e5e7eb')}; | ||
|  |       } | ||
|  |        | ||
|  |       .youtube-embed-btn:active { | ||
|  |         transform: scale(0.98); | ||
|  |       } | ||
|  |        | ||
|  |       /* YouTube Container */ | ||
|  |       .youtube-container { | ||
|  |         position: relative; | ||
|  |         width: 100%; | ||
|  |         padding-bottom: 56.25%; /* 16:9 aspect ratio */ | ||
|  |         background: ${cssManager.bdTheme('#000000', '#000000')}; | ||
|  |       } | ||
|  |        | ||
|  |       .youtube-container iframe { | ||
|  |         position: absolute; | ||
|  |         top: 0; | ||
|  |         left: 0; | ||
|  |         width: 100%; | ||
|  |         height: 100%; | ||
|  |         border: 0; | ||
|  |         border-radius: 6px; | ||
|  |       } | ||
|  |     `;
 | ||
|  |   } | ||
|  | } |