UNPKG

claritykit-svelte

Version:

A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility

352 lines (351 loc) 14.1 kB
import { Node, mergeAttributes } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; export const VideoEmbedExtension = Node.create({ name: 'videoEmbed', addOptions() { return { HTMLAttributes: { class: 'video-embed', }, allowedDomains: [ 'youtube.com', 'youtu.be', 'vimeo.com', 'dailymotion.com', 'twitch.tv', ], autoplay: false, controls: true, width: 640, height: 360, responsive: true, }; }, group: 'block', atom: true, addAttributes() { return { src: { default: null, parseHTML: element => { const iframe = element.querySelector('iframe'); const video = element.querySelector('video'); return iframe?.getAttribute('src') || video?.getAttribute('src'); }, renderHTML: attributes => { if (!attributes.src) return {}; return { 'data-src': attributes.src }; }, }, title: { default: '', parseHTML: element => element.getAttribute('data-title'), renderHTML: attributes => { if (!attributes.title) return {}; return { 'data-title': attributes.title }; }, }, width: { default: this.options.width, parseHTML: element => { const width = element.getAttribute('data-width'); return width ? parseInt(width) : this.options.width; }, renderHTML: attributes => { return { 'data-width': attributes.width.toString() }; }, }, height: { default: this.options.height, parseHTML: element => { const height = element.getAttribute('data-height'); return height ? parseInt(height) : this.options.height; }, renderHTML: attributes => { return { 'data-height': attributes.height.toString() }; }, }, autoplay: { default: this.options.autoplay, parseHTML: element => element.getAttribute('data-autoplay') === 'true', renderHTML: attributes => { return { 'data-autoplay': attributes.autoplay.toString() }; }, }, controls: { default: this.options.controls, parseHTML: element => element.getAttribute('data-controls') !== 'false', renderHTML: attributes => { return { 'data-controls': attributes.controls.toString() }; }, }, provider: { default: 'direct', parseHTML: element => element.getAttribute('data-provider') || 'direct', renderHTML: attributes => { return { 'data-provider': attributes.provider }; }, }, embedUrl: { default: null, parseHTML: element => element.getAttribute('data-embed-url'), renderHTML: attributes => { if (!attributes.embedUrl) return {}; return { 'data-embed-url': attributes.embedUrl }; }, }, }; }, parseHTML() { return [ { tag: 'div.video-embed', }, ]; }, renderHTML({ node, HTMLAttributes }) { const { src, title, width, height, autoplay, controls, provider, embedUrl } = node.attrs; const videoMetadata = this.getVideoMetadata(src); const finalEmbedUrl = embedUrl || videoMetadata.embedUrl || src; const containerStyle = this.options.responsive ? `position: relative; width: 100%; max-width: ${width}px; height: 0; padding-bottom: ${(height / width) * 100}%;` : `width: ${width}px; height: ${height}px;`; const mediaStyle = this.options.responsive ? 'position: absolute; top: 0; left: 0; width: 100%; height: 100%;' : `width: ${width}px; height: ${height}px;`; return [ 'div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-src': src, 'data-title': title, 'data-width': width.toString(), 'data-height': height.toString(), 'data-autoplay': autoplay.toString(), 'data-controls': controls.toString(), 'data-provider': provider, 'data-embed-url': finalEmbedUrl, style: containerStyle, }), [ 'div', { class: 'video-header' }, title && [ 'div', { class: 'video-title' }, title, ], [ 'div', { class: 'video-controls' }, [ 'button', { class: 'video-control-btn', 'data-action': 'toggle-autoplay', title: autoplay ? 'Disable autoplay' : 'Enable autoplay', }, autoplay ? '⏸️' : '▶️', ], [ 'button', { class: 'video-control-btn', 'data-action': 'toggle-controls', title: controls ? 'Hide controls' : 'Show controls', }, controls ? '🎛️' : '📱', ], [ 'button', { class: 'video-control-btn', 'data-action': 'open-external', title: 'Open in new tab', }, '🔗', ], ], ].filter(Boolean), this.isEmbeddableUrl(src) ? [ 'iframe', { src: this.buildEmbedUrl(finalEmbedUrl, { autoplay, controls }), title: title || 'Video player', frameborder: '0', allowfullscreen: 'true', allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture', style: mediaStyle, }, ] : [ 'video', { src: finalEmbedUrl, controls: controls ? 'true' : undefined, autoplay: autoplay ? 'true' : undefined, style: mediaStyle, preload: 'metadata', }, 'Your browser does not support the video tag.', ], [ 'div', { class: 'video-info' }, [ 'span', { class: 'video-provider' }, provider.charAt(0).toUpperCase() + provider.slice(1), ], videoMetadata.duration && [ 'span', { class: 'video-duration' }, this.formatDuration(videoMetadata.duration), ], ].filter(Boolean), ]; }, addCommands() { return { insertVideoEmbed: (options) => ({ commands }) => { const metadata = this.getVideoMetadata(options.src); return commands.insertContent({ type: this.name, attrs: { ...options, provider: metadata.provider, embedUrl: metadata.embedUrl, }, }); }, updateVideoSettings: (src, settings) => ({ tr, state }) => { const { doc } = state; let updated = false; doc.descendants((node, pos) => { if (node.type === this.type && node.attrs.src === src) { const newAttrs = { ...node.attrs, ...settings }; tr.setNodeMarkup(pos, undefined, newAttrs); updated = true; return false; } }); return updated; }, }; }, addProseMirrorPlugins() { return [ new Plugin({ key: new PluginKey('videoEmbed'), props: { handleClick: (view, pos, event) => { const target = event.target; const videoEmbed = target.closest('.video-embed'); if (!videoEmbed) return false; const src = videoEmbed.getAttribute('data-src'); const action = target.getAttribute('data-action'); if (action) { event.preventDefault(); event.stopPropagation(); switch (action) { case 'toggle-autoplay': const currentAutoplay = videoEmbed.getAttribute('data-autoplay') === 'true'; this.editor.commands.updateVideoSettings(src, { autoplay: !currentAutoplay }); break; case 'toggle-controls': const currentControls = videoEmbed.getAttribute('data-controls') !== 'false'; this.editor.commands.updateVideoSettings(src, { controls: !currentControls }); break; case 'open-external': window.open(src, '_blank', 'noopener,noreferrer'); break; } return true; } return false; }, }, }), ]; }, // Helper methods getVideoMetadata(url) { const metadata = { provider: 'direct', }; // YouTube if (url.includes('youtube.com') || url.includes('youtu.be')) { metadata.provider = 'youtube'; const videoId = this.extractYouTubeId(url); if (videoId) { metadata.embedUrl = `https://www.youtube.com/embed/${videoId}`; metadata.thumbnail = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`; } } // Vimeo else if (url.includes('vimeo.com')) { metadata.provider = 'vimeo'; const videoId = this.extractVimeoId(url); if (videoId) { metadata.embedUrl = `https://player.vimeo.com/video/${videoId}`; } } // Direct video file else if (url.match(/\.(mp4|webm|ogg|mov|avi|mkv)$/i)) { metadata.provider = 'direct'; } return metadata; }, extractYouTubeId(url) { const patterns = [ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, /youtube\.com\/v\/([^&\n?#]+)/, ]; for (const pattern of patterns) { const match = url.match(pattern); if (match) return match[1]; } return null; }, extractVimeoId(url) { const match = url.match(/vimeo\.com\/(?:video\/)?(\d+)/); return match ? match[1] : null; }, isEmbeddableUrl(url) { return this.options.allowedDomains.some(domain => url.includes(domain)); }, buildEmbedUrl(url, options) { const urlObj = new URL(url); // YouTube parameters if (url.includes('youtube.com')) { urlObj.searchParams.set('autoplay', options.autoplay ? '1' : '0'); urlObj.searchParams.set('controls', options.controls ? '1' : '0'); urlObj.searchParams.set('rel', '0'); // Don't show related videos urlObj.searchParams.set('modestbranding', '1'); // Minimal YouTube branding } // Vimeo parameters else if (url.includes('vimeo.com')) { urlObj.searchParams.set('autoplay', options.autoplay ? '1' : '0'); urlObj.searchParams.set('controls', options.controls ? '1' : '0'); urlObj.searchParams.set('title', '0'); // Hide title urlObj.searchParams.set('byline', '0'); // Hide byline urlObj.searchParams.set('portrait', '0'); // Hide portrait } return urlObj.toString(); }, formatDuration(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); if (hours > 0) { return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } return `${minutes}:${secs.toString().padStart(2, '0')}`; }, }); export default VideoEmbedExtension;