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