import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw" import { useCallback, useState } from "react" import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper" import { usePinnedToView } from "../hooks/usePinnedToView" import { useMaximize } from "../hooks/useMaximize" export type IEmbedShape = TLBaseShape< "Embed", { w: number h: number url: string | null pinnedToView: boolean tags: string[] interactionState?: { scrollPosition?: { x: number; y: number } currentTime?: number } } > const transformUrl = (url: string): string => { // YouTube const youtubeMatch = url.match( /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/, ) if (youtubeMatch) { return `https://www.youtube.com/embed/${youtubeMatch[1]}` } // Google Maps if (url.includes("google.com/maps") || url.includes("goo.gl/maps")) { if (url.includes("google.com/maps/embed")) { return url } const directionsMatch = url.match(/dir\/([^\/]+)\/([^\/]+)/) if (directionsMatch || url.includes("/dir/")) { const origin = url.match(/origin=([^&]+)/)?.[1] || directionsMatch?.[1] const destination = url.match(/destination=([^&]+)/)?.[1] || directionsMatch?.[2] if (origin && destination) { return `https://www.google.com/maps/embed/v1/directions?key=${ import.meta.env["VITE_GOOGLE_MAPS_API_KEY"] }&origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent( destination, )}&mode=driving` } } const placeMatch = url.match(/[?&]place_id=([^&]+)/) if (placeMatch) { return `https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2!2d0!3d0!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s${placeMatch[1]}!2s!5e0!3m2!1sen!2s!4v1` } return `https://www.google.com/maps/embed/v1/place?key=${ import.meta.env.VITE_GOOGLE_MAPS_API_KEY }&q=${encodeURIComponent(url)}` } // Twitter/X const xMatch = url.match( /(?:twitter\.com|x\.com)\/([^\/\s?]+)(?:\/(?:status|tweets)\/(\d+)|$)/, ) if (xMatch) { const [, username, tweetId] = xMatch if (tweetId) { return `https://platform.x.com/embed/Tweet.html?id=${tweetId}` } else { return "about:blank" } } // Medium - return about:blank if (url.includes("medium.com")) { return "about:blank" } // Gather.town if (url.includes("app.gather.town")) { return url.replace("app.gather.town", "gather.town/embed") } return url } const getDefaultDimensions = (url: string): { w: number; h: number } => { if (url.match(/(?:youtube\.com|youtu\.be)/)) { return { w: 800, h: 450 } } if (url.match(/(?:twitter\.com|x\.com)/)) { if (url.match(/\/status\/|\/tweets\//)) { return { w: 800, h: 600 } } } if (url.includes("google.com/maps") || url.includes("goo.gl/maps")) { return { w: 800, h: 600 } } if (url.includes("gather.town")) { return { w: 800, h: 600 } } return { w: 800, h: 600 } } const getFaviconUrl = (url: string): string => { try { const urlObj = new URL(url) return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32` } catch { return '' } } const getDisplayTitle = (url: string): string => { try { const urlObj = new URL(url) if (urlObj.hostname.includes('youtube.com')) { return 'YouTube' } if (urlObj.hostname.includes('twitter.com') || urlObj.hostname.includes('x.com')) { return 'Twitter/X' } if (urlObj.hostname.includes('google.com/maps')) { return 'Google Maps' } return urlObj.hostname.replace('www.', '') } catch { return url } } export class EmbedShape extends BaseBoxShapeUtil { static override type = "Embed" // Embed theme color: Yellow (Rainbow) static readonly PRIMARY_COLOR = "#eab308" getDefaultProps(): IEmbedShape["props"] { return { url: null, w: 800, h: 600, pinnedToView: false, tags: ['embed'], } } indicator(shape: IEmbedShape) { return ( ) } component(shape: IEmbedShape) { const props = shape.props || {} const url = props.url || "" const [isMinimized, setIsMinimized] = useState(false) const isSelected = this.editor.getSelectedShapeIds().includes(shape.id) const [inputUrl, setInputUrl] = useState(url) const [error, setError] = useState("") // Use the pinning hook usePinnedToView(this.editor, shape.id, shape.props.pinnedToView) // Use the maximize hook for fullscreen functionality const { isMaximized, toggleMaximize } = useMaximize({ editor: this.editor, shapeId: shape.id, currentW: shape.props.w, currentH: shape.props.h, shapeType: 'Embed', }) const handleClose = () => { this.editor.deleteShape(shape.id) } const handleMinimize = () => { setIsMinimized(!isMinimized) } const handlePinToggle = () => { this.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, pinnedToView: !shape.props.pinnedToView, }, }) } const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault() let completedUrl = inputUrl.startsWith("http://") || inputUrl.startsWith("https://") ? inputUrl : `https://${inputUrl}` const isValidUrl = completedUrl.match(/(^\w+:|^)\/\//) if (!isValidUrl) { setError("Invalid URL") return } this.editor.updateShape({ id: shape.id, type: "Embed", props: { ...shape.props, url: completedUrl }, }) setError("") }, [inputUrl], ) const handleIframeInteraction = ( newState: typeof shape.props.interactionState, ) => { this.editor.updateShape({ id: shape.id, type: "Embed", props: { ...shape.props, interactionState: newState, }, }) } // Custom header content with URL info const headerContent = url ? (
{ (e.target as HTMLImageElement).style.display = 'none' }} /> {getDisplayTitle(url)}
) : ( Embed ) // For empty state - URL input form if (!url) { return ( { this.editor.updateShape({ id: shape.id, type: 'Embed', props: { ...shape.props, tags: newTags, } }) }} tagsEditable={true} >
{ e.preventDefault() e.stopPropagation() const input = e.currentTarget.querySelector('input') input?.focus() }} >
e.stopPropagation()} > setInputUrl(e.target.value)} placeholder="Enter URL to embed..." style={{ width: "100%", padding: "15px", border: "1px solid #ccc", borderRadius: "4px", fontSize: "16px", touchAction: 'manipulation', }} onKeyDown={(e) => { if (e.key === "Enter") { handleSubmit(e) } }} onPointerDown={(e) => { e.stopPropagation() e.currentTarget.focus() }} /> {error && (
{error}
)}
) } // For medium.com and twitter profile views if (url.includes("medium.com") || (url && url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/))) { return ( { this.editor.updateShape({ id: shape.id, type: 'Embed', props: { ...shape.props, tags: newTags, } }) }} tagsEditable={true} >

This content cannot be embedded in an iframe.

) } // For normal embed view return ( { this.editor.updateShape({ id: shape.id, type: 'Embed', props: { ...shape.props, tags: newTags, } }) }} tagsEditable={true} >