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
JavaScript
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;