import { NodeViewWrapper } from '@tiptap/react'
import React, { useState, useRef, useEffect, useMemo } from 'react'
import { Upload, Link as LinkIcon, GripVertical, GripHorizontal, AlignCenter, Cuboid, Code } from 'lucide-react'
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
import { SiGithub, SiReplit, SiSpotify, SiLoom, SiGooglemaps, SiCodepen, SiCanva, SiNotion, SiGoogledocs, SiGitlab, SiX, SiFigma, SiGiphy, SiYoutube } from '@icons-pack/react-simple-icons'
import { useRouter } from 'next/navigation'
import DOMPurify from 'dompurify'
// Add new type for script-based embeds
const SCRIPT_BASED_EMBEDS = {
twitter: { src: 'https://platform.twitter.com/widgets.js', identifier: 'twitter-tweet' },
instagram: { src: 'https://www.instagram.com/embed.js', identifier: 'instagram-media' },
tiktok: { src: 'https://www.tiktok.com/embed.js', identifier: 'tiktok-embed' },
// Add more platforms as needed
};
// Helper function to convert YouTube URLs to embed format
const getYouTubeEmbedUrl = (url: string): string => {
try {
// First validate that this is a proper URL
const parsedUrl = new URL(url);
// Ensure the hostname is actually YouTube
const isYoutubeHostname =
parsedUrl.hostname === 'youtube.com' ||
parsedUrl.hostname === 'www.youtube.com' ||
parsedUrl.hostname === 'youtu.be' ||
parsedUrl.hostname === 'www.youtu.be';
if (!isYoutubeHostname) {
return url; // Not a YouTube URL, return as is
}
// Handle different YouTube URL formats with a more precise regex
const youtubeRegex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/i;
const match = url.match(youtubeRegex);
if (match && match[1]) {
// Validate the video ID format (should be exactly 11 characters)
const videoId = match[1];
if (videoId.length === 11) {
// Return the embed URL with the video ID and secure protocol
return `https://www.youtube.com/embed/${videoId}?autoplay=0&rel=0`;
}
}
// If no valid match found, return the original URL
return url;
} catch (e) {
// If URL parsing fails, return the original URL
return url;
}
};
// Add new memoized component for the embed content
const MemoizedEmbed = React.memo(({ embedUrl, sanitizedEmbedCode, embedType }: {
embedUrl: string;
sanitizedEmbedCode: string;
embedType: 'url' | 'code';
}) => {
useEffect(() => {
if (embedType === 'code' && sanitizedEmbedCode) {
// Check for any matching script-based embeds
const matchingPlatform = Object.entries(SCRIPT_BASED_EMBEDS).find(([_, config]) =>
sanitizedEmbedCode.includes(config.identifier)
);
if (matchingPlatform) {
const [_, config] = matchingPlatform;
const script = document.createElement('script');
script.src = config.src;
script.async = true;
script.charset = 'utf-8';
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
};
}
}
}, [embedType, sanitizedEmbedCode]);
if (embedType === 'url' && embedUrl) {
// Process the URL if it's a YouTube URL - using proper URL validation
let isYoutubeUrl = false;
try {
const url = new URL(embedUrl);
// Check if the hostname is exactly youtube.com or youtu.be (or www variants)
isYoutubeUrl = url.hostname === 'youtube.com' ||
url.hostname === 'www.youtube.com' ||
url.hostname === 'youtu.be' ||
url.hostname === 'www.youtu.be';
} catch (e) {
// Invalid URL format, not a YouTube URL
isYoutubeUrl = false;
}
const processedUrl = isYoutubeUrl ? getYouTubeEmbedUrl(embedUrl) : embedUrl;
return (
);
}
if (embedType === 'code' && sanitizedEmbedCode) {
return
;
}
return null;
});
MemoizedEmbed.displayName = 'MemoizedEmbed';
function EmbedObjectsComponent(props: any) {
const [embedType, setEmbedType] = useState<'url' | 'code'>(props.node.attrs.embedType || 'url')
const [embedUrl, setEmbedUrl] = useState(props.node.attrs.embedUrl || '')
const [embedCode, setEmbedCode] = useState(props.node.attrs.embedCode || '')
const [embedHeight, setEmbedHeight] = useState(props.node.attrs.embedHeight || 300)
const [embedWidth, setEmbedWidth] = useState(props.node.attrs.embedWidth || '100%')
const [alignment, setAlignment] = useState(props.node.attrs.alignment || 'left')
const [isResizing, setIsResizing] = useState(false)
const [parentWidth, setParentWidth] = useState(null)
const [isMobile, setIsMobile] = useState(false)
const resizeRef = useRef(null)
const containerRef = useRef(null)
const editorState = useEditorProvider() as any
const isEditable = editorState.isEditable
const router = useRouter()
// Add ResizeObserver to track parent container size changes
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current && containerRef.current.parentElement) {
const parentElement = containerRef.current.parentElement;
const newParentWidth = parentElement.offsetWidth;
setParentWidth(newParentWidth);
// Check if we're in a mobile viewport
setIsMobile(newParentWidth < 640); // 640px is a common breakpoint for small screens
// If embedWidth is set to a percentage, maintain that percentage
// Otherwise, adjust to fit parent width
if (typeof embedWidth === 'string' && embedWidth.endsWith('%')) {
const percentage = parseInt(embedWidth, 10);
const newWidth = `${Math.min(100, percentage)}%`;
setEmbedWidth(newWidth);
props.updateAttributes({ embedWidth: newWidth });
} else if (newParentWidth < parseInt(String(embedWidth), 10)) {
// If parent is smaller than current width, adjust to fit
setEmbedWidth('100%');
props.updateAttributes({ embedWidth: '100%' });
}
}
};
// Initialize dimensions
updateDimensions();
// Set up ResizeObserver
const resizeObserver = new ResizeObserver(() => {
updateDimensions();
});
if (containerRef.current && containerRef.current.parentElement) {
resizeObserver.observe(containerRef.current.parentElement);
}
// Clean up
return () => {
resizeObserver.disconnect();
};
}, []);
const supportedProducts = [
{ name: 'YouTube', icon: SiYoutube, color: '#FF0000', guide: 'https://support.google.com/youtube/answer/171780?hl=en' },
{ name: 'GitHub', icon: SiGithub, color: '#181717', guide: 'https://emgithub.com/' },
{ name: 'Replit', icon: SiReplit, color: '#F26207', guide: 'https://docs.replit.com/hosting/embedding-repls' },
{ name: 'Spotify', icon: SiSpotify, color: '#1DB954', guide: 'https://developer.spotify.com/documentation/embeds' },
{ name: 'Loom', icon: SiLoom, color: '#625DF5', guide: 'https://support.loom.com/hc/en-us/articles/360002208317-How-to-embed-your-video-into-a-webpage' },
{ name: 'GMaps', icon: SiGooglemaps, color: '#4285F4', guide: 'https://developers.google.com/maps/documentation/embed/get-started' },
{ name: 'CodePen', icon: SiCodepen, color: '#000000', guide: 'https://blog.codepen.io/documentation/embedded-pens/' },
{ name: 'Canva', icon: SiCanva, color: '#00C4CC', guide: 'https://www.canva.com/help/article/embed-designs' },
{ name: 'Notion', icon: SiNotion, color: '#878787', guide: 'https://www.notion.so/help/embed-and-connect-other-apps#7a70ac4b5c5f4ec889e69d262e0de9e7' },
{ name: 'G Docs', icon: SiGoogledocs, color: '#4285F4', guide: 'https://support.google.com/docs/answer/183965?hl=en&co=GENIE.Platform%3DDesktop' },
{ name: 'X', icon: SiX, color: '#000000', guide: 'https://help.twitter.com/en/using-twitter/how-to-embed-a-tweet' },
{ name: 'Figma', icon: SiFigma, color: '#F24E1E', guide: 'https://help.figma.com/hc/en-us/articles/360041057214-Embed-files-and-prototypes' },
{ name: 'Giphy', icon: SiGiphy, color: '#FF6666', guide: 'https://developers.giphy.com/docs/embed/' },
]
const [sanitizedEmbedCode, setSanitizedEmbedCode] = useState('')
useEffect(() => {
if (embedType === 'code' && embedCode) {
const sanitized = DOMPurify.sanitize(embedCode, {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['*']
})
setSanitizedEmbedCode(sanitized)
}
}, [embedCode, embedType])
const handleEmbedTypeChange = (type: 'url' | 'code') => {
setEmbedType(type)
props.updateAttributes({ embedType: type })
}
const handleUrlChange = (event: React.ChangeEvent) => {
const newUrl = event.target.value;
const trimmedUrl = newUrl.trim();
// Only update if URL is not just whitespace
if (newUrl === '' || trimmedUrl) {
// First sanitize with DOMPurify
const sanitizedUrl = DOMPurify.sanitize(newUrl);
// Additional URL validation for security
let validatedUrl = sanitizedUrl;
if (sanitizedUrl) {
try {
// Ensure it's a valid URL by parsing it
const url = new URL(sanitizedUrl);
// Only allow http and https protocols
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
// If invalid protocol, default to https
url.protocol = 'https:';
validatedUrl = url.toString();
}
} catch (e) {
// If it's not a valid URL, prepend https:// to make it valid
// Only do this if it's not empty and doesn't already start with a protocol
if (sanitizedUrl && !sanitizedUrl.match(/^[a-zA-Z]+:\/\//)) {
validatedUrl = `https://${sanitizedUrl}`;
}
}
}
setEmbedUrl(validatedUrl);
props.updateAttributes({
embedUrl: validatedUrl,
embedType: 'url',
});
}
};
const handleCodeChange = (event: React.ChangeEvent) => {
const newCode = event.target.value;
const trimmedCode = newCode.trim();
// Only update if code is not just whitespace
if (newCode === '' || trimmedCode) {
setEmbedCode(newCode);
props.updateAttributes({
embedCode: newCode,
embedType: 'code',
});
}
};
// Add refs for storing dimensions during resize
const dimensionsRef = useRef({
width: props.node.attrs.embedWidth || '100%',
height: props.node.attrs.embedHeight || 300
})
const handleResizeStart = (event: React.MouseEvent, direction: 'horizontal' | 'vertical') => {
event.preventDefault()
setIsResizing(true)
const startX = event.clientX
const startY = event.clientY
const startWidth = resizeRef.current?.offsetWidth || 0
const startHeight = resizeRef.current?.offsetHeight || 0
const handleMouseMove = (e: MouseEvent) => {
if (resizeRef.current) {
if (direction === 'horizontal') {
const newWidth = startWidth + e.clientX - startX
const parentWidth = resizeRef.current.parentElement?.offsetWidth || 1
const widthPercentage = Math.min(100, Math.max(10, (newWidth / parentWidth) * 100))
const newWidthValue = `${widthPercentage}%`
// Update ref and DOM directly during resize
dimensionsRef.current.width = newWidthValue
resizeRef.current.style.width = newWidthValue
} else {
const newHeight = Math.max(100, startHeight + e.clientY - startY)
// Update ref and DOM directly during resize
dimensionsRef.current.height = newHeight
resizeRef.current.style.height = `${newHeight}px`
}
}
}
const handleMouseUp = () => {
setIsResizing(false)
// Only update state and attributes after resize is complete
setEmbedWidth(dimensionsRef.current.width)
setEmbedHeight(dimensionsRef.current.height)
props.updateAttributes({
embedWidth: dimensionsRef.current.width,
embedHeight: dimensionsRef.current.height
})
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
const handleCenterBlock = () => {
const newAlignment = alignment === 'center' ? 'left' : 'center'
setAlignment(newAlignment)
props.updateAttributes({ alignment: newAlignment })
}
const handleProductClick = (guide: string) => {
window.open(guide, '_blank', 'noopener,noreferrer')
}
// Calculate responsive styles based on parent width
const getResponsiveStyles = () => {
// Default styles
const styles: React.CSSProperties = {
height: `${embedHeight}px`,
width: embedWidth,
};
// If parent width is available, ensure we don't exceed it
if (parentWidth) {
// For mobile viewports, always use 100% width
if (isMobile) {
styles.width = '100%';
styles.minWidth = 'unset';
} else {
// For desktop, use the set width but ensure it's not wider than parent
styles.minWidth = Math.min(parentWidth, 400) + 'px';
styles.maxWidth = '100%';
}
}
return styles;
};
// Memoize the embed content
const embedContent = useMemo(() => (
!isResizing && (embedUrl || sanitizedEmbedCode) ? (
) : (
)
), [embedUrl, sanitizedEmbedCode, embedType, isResizing]);
// Input states
const [activeInput, setActiveInput] = useState<'none' | 'url' | 'code'>('none');
const [selectedProduct, setSelectedProduct] = useState(null);
const urlInputRef = useRef(null);
const codeInputRef = useRef(null);
// Handle direct input from product selection
const handleProductSelection = (product: typeof supportedProducts[0]) => {
// Set the input type to URL by default
setEmbedType('url');
setActiveInput('url');
// Store the selected product for the popup
setSelectedProduct(product);
// Focus the URL input after a short delay to allow rendering
setTimeout(() => {
if (urlInputRef.current) {
urlInputRef.current.focus();
}
}, 50);
};
// Handle input submission
const handleInputSubmit = (e: React.FormEvent) => {
e.preventDefault();
setActiveInput('none');
};
// Handle escape key to cancel input
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setActiveInput('none');
}
};
// Handle opening documentation
const handleOpenDocs = (guide: string) => {
window.open(guide, '_blank', 'noopener,noreferrer');
};
return (
{(embedUrl || sanitizedEmbedCode) ? (
// Show the embed content if we have a URL or code
<>
{embedContent}
{/* Minimal toolbar for existing embeds */}
{isEditable && (
)}
>
) : (
// Show the embed selection UI if we don't have content yet
Add an embed from :
{supportedProducts.map((product) => (
))}
Click a service to add an embed
{/* Direct input options */}
{isEditable && (
)}
)}
{/* Inline input UI - appears in place without covering content */}
{isEditable && activeInput !== 'none' && (