Files
dees-catalog/ts_web/elements/wysiwyg/blocks/media/youtube.block.ts
Juergen Kunz 342bd7d7c2 update
2025-06-26 13:18:34 +00:00

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