632 lines
18 KiB
TypeScript
632 lines
18 KiB
TypeScript
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
|
|
import { useCallback, useState } from "react"
|
|
//import Embed from "react-embed"
|
|
|
|
|
|
//TODO: FIX PEN AND MOBILE INTERACTION WITH EDITING EMBED URL - DEFAULT TO TEXT SELECTED
|
|
|
|
export type IEmbedShape = TLBaseShape<
|
|
"Embed",
|
|
{
|
|
w: number
|
|
h: number
|
|
url: string | null
|
|
isMinimized?: boolean
|
|
interactionState?: {
|
|
scrollPosition?: { x: number; y: number }
|
|
currentTime?: number // for videos
|
|
// other state you want to sync
|
|
}
|
|
}
|
|
>
|
|
|
|
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 it's already an embed URL, return as is
|
|
if (url.includes("google.com/maps/embed")) {
|
|
return url
|
|
}
|
|
|
|
// Handle directions
|
|
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`
|
|
}
|
|
}
|
|
|
|
// Extract place ID
|
|
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`
|
|
}
|
|
|
|
// For all other map URLs
|
|
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) {
|
|
// For tweets
|
|
return `https://platform.x.com/embed/Tweet.html?id=${tweetId}`
|
|
} else {
|
|
// For profiles, return about:blank and handle display separately
|
|
return "about:blank"
|
|
}
|
|
}
|
|
|
|
// Medium - return about:blank to prevent iframe loading
|
|
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 } => {
|
|
// YouTube default dimensions (16:9 ratio)
|
|
if (url.match(/(?:youtube\.com|youtu\.be)/)) {
|
|
return { w: 800, h: 450 }
|
|
}
|
|
|
|
// Twitter/X default dimensions
|
|
if (url.match(/(?:twitter\.com|x\.com)/)) {
|
|
if (url.match(/\/status\/|\/tweets\//)) {
|
|
return { w: 800, h: 600 } // For individual tweets
|
|
}
|
|
}
|
|
|
|
// Google Maps default dimensions
|
|
if (url.includes("google.com/maps") || url.includes("goo.gl/maps")) {
|
|
return { w: 800, h: 600 }
|
|
}
|
|
|
|
// Gather.town default dimensions
|
|
if (url.includes("gather.town")) {
|
|
return { w: 800, h: 600 }
|
|
}
|
|
|
|
// Default dimensions for other embeds
|
|
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 '' // Return empty if URL is invalid
|
|
}
|
|
}
|
|
|
|
const getDisplayTitle = (url: string): string => {
|
|
try {
|
|
const urlObj = new URL(url)
|
|
// Handle special cases
|
|
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'
|
|
}
|
|
// Default: return clean hostname
|
|
return urlObj.hostname.replace('www.', '')
|
|
} catch {
|
|
return url // Return original URL if parsing fails
|
|
}
|
|
}
|
|
|
|
export class EmbedShape extends BaseBoxShapeUtil<IEmbedShape> {
|
|
static override type = "Embed"
|
|
|
|
getDefaultProps(): IEmbedShape["props"] {
|
|
return {
|
|
url: null,
|
|
w: 800,
|
|
h: 600,
|
|
isMinimized: false,
|
|
}
|
|
}
|
|
|
|
indicator(shape: IEmbedShape) {
|
|
return (
|
|
<rect
|
|
x={0}
|
|
y={0}
|
|
width={shape.props.w}
|
|
height={shape.props.isMinimized ? 40 : shape.props.h}
|
|
fill="none"
|
|
/>
|
|
)
|
|
}
|
|
|
|
component(shape: IEmbedShape) {
|
|
// Ensure shape props exist with defaults
|
|
const props = shape.props || {}
|
|
const url = props.url || ""
|
|
const isMinimized = props.isMinimized || false
|
|
|
|
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
|
|
|
const [inputUrl, setInputUrl] = useState(url)
|
|
const [error, setError] = useState("")
|
|
const [copyStatus, setCopyStatus] = useState(false)
|
|
|
|
const handleSubmit = useCallback(
|
|
(e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
let completedUrl =
|
|
inputUrl.startsWith("http://") || inputUrl.startsWith("https://")
|
|
? inputUrl
|
|
: `https://${inputUrl}`
|
|
|
|
// Basic URL validation
|
|
const isValidUrl = completedUrl.match(/(^\w+:|^)\/\//)
|
|
if (!isValidUrl) {
|
|
setError("Invalid URL")
|
|
return
|
|
}
|
|
|
|
this.editor.updateShape<IEmbedShape>({
|
|
id: shape.id,
|
|
type: "Embed",
|
|
props: { ...shape.props, url: completedUrl },
|
|
})
|
|
setError("")
|
|
},
|
|
[inputUrl],
|
|
)
|
|
|
|
const handleIframeInteraction = (
|
|
newState: typeof shape.props.interactionState,
|
|
) => {
|
|
this.editor.updateShape<IEmbedShape>({
|
|
id: shape.id,
|
|
type: "Embed",
|
|
props: {
|
|
...shape.props,
|
|
interactionState: newState,
|
|
},
|
|
})
|
|
}
|
|
|
|
const contentStyle = {
|
|
pointerEvents: isSelected ? "none" as const : "all" as const,
|
|
width: "100%",
|
|
height: "100%",
|
|
border: "1px solid #D3D3D3",
|
|
backgroundColor: "#FFFFFF",
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
overflow: "hidden",
|
|
}
|
|
|
|
const wrapperStyle = {
|
|
position: 'relative' as const,
|
|
width: `${shape.props.w}px`,
|
|
height: `${shape.props.isMinimized ? 40 : shape.props.h}px`,
|
|
backgroundColor: "#F0F0F0",
|
|
borderRadius: "4px",
|
|
transition: "height 0.3s, width 0.3s",
|
|
overflow: "hidden",
|
|
}
|
|
|
|
// Update control button styles
|
|
const controlButtonStyle = {
|
|
border: "none",
|
|
background: "#666666", // Grey background
|
|
color: "white", // White text
|
|
padding: "4px 12px",
|
|
margin: "0 4px",
|
|
borderRadius: "4px",
|
|
cursor: "pointer",
|
|
fontSize: "12px",
|
|
pointerEvents: "all" as const,
|
|
whiteSpace: "nowrap" as const,
|
|
transition: "background-color 0.2s",
|
|
"&:hover": {
|
|
background: "#4D4D4D", // Darker grey on hover
|
|
}
|
|
}
|
|
|
|
const controlsContainerStyle = {
|
|
position: "absolute" as const,
|
|
top: "8px",
|
|
right: "8px",
|
|
display: "flex",
|
|
gap: "8px",
|
|
zIndex: 1,
|
|
}
|
|
|
|
const handleToggleMinimize = (e: React.MouseEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
this.editor.updateShape<IEmbedShape>({
|
|
id: shape.id,
|
|
type: "Embed",
|
|
props: {
|
|
...shape.props,
|
|
isMinimized: !shape.props.isMinimized,
|
|
},
|
|
})
|
|
}
|
|
|
|
const controls = (url: string) => (
|
|
<div style={controlsContainerStyle}>
|
|
<button
|
|
onClick={() => navigator.clipboard.writeText(url)}
|
|
style={controlButtonStyle}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
Copy Link
|
|
</button>
|
|
<button
|
|
onClick={() => window.open(url, '_blank')}
|
|
style={controlButtonStyle}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
Open in Tab
|
|
</button>
|
|
<button
|
|
onClick={handleToggleMinimize}
|
|
style={controlButtonStyle}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
{shape.props.isMinimized ? "Maximize" : "Minimize"}
|
|
</button>
|
|
</div>
|
|
)
|
|
|
|
// For minimized state, show URL and all controls
|
|
if (shape.props.url && shape.props.isMinimized) {
|
|
return (
|
|
<div style={wrapperStyle}>
|
|
<div
|
|
style={{
|
|
...contentStyle,
|
|
height: "40px",
|
|
alignItems: "center",
|
|
padding: "0 15px",
|
|
position: "relative",
|
|
display: "flex",
|
|
gap: "8px",
|
|
}}
|
|
>
|
|
<img
|
|
src={getFaviconUrl(shape.props.url)}
|
|
alt=""
|
|
style={{
|
|
width: "16px",
|
|
height: "16px",
|
|
flexShrink: 0,
|
|
}}
|
|
onError={(e) => {
|
|
// Hide broken favicon
|
|
(e.target as HTMLImageElement).style.display = 'none'
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflow: "hidden",
|
|
flex: 1,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontWeight: 500,
|
|
color: "#333",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{getDisplayTitle(shape.props.url)}
|
|
</span>
|
|
<span
|
|
style={{
|
|
fontSize: "11px",
|
|
color: "#666",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{shape.props.url}
|
|
</span>
|
|
</div>
|
|
{controls(shape.props.url)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// For empty state
|
|
if (!shape.props.url) {
|
|
return (
|
|
<div style={wrapperStyle}>
|
|
{controls("")}
|
|
<div
|
|
style={{
|
|
...contentStyle,
|
|
cursor: 'text', // Add text cursor to indicate clickable
|
|
touchAction: 'none', // Prevent touch scrolling
|
|
}}
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
const input = e.currentTarget.querySelector('input')
|
|
input?.focus()
|
|
}}
|
|
>
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
padding: "10px",
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'center',
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<input
|
|
type="text"
|
|
value={inputUrl}
|
|
onChange={(e) => setInputUrl(e.target.value)}
|
|
placeholder="Enter URL to embed"
|
|
style={{
|
|
width: "100%",
|
|
padding: "15px", // Increased padding for better touch target
|
|
border: "1px solid #ccc",
|
|
borderRadius: "4px",
|
|
fontSize: "16px", // Increased font size for better visibility
|
|
touchAction: 'none',
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
handleSubmit(e)
|
|
}
|
|
}}
|
|
onPointerDown={(e) => {
|
|
e.stopPropagation()
|
|
e.currentTarget.focus()
|
|
}}
|
|
/>
|
|
{error && (
|
|
<div style={{ color: "red", marginTop: "10px" }}>{error}</div>
|
|
)}
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// For medium.com and twitter profile views
|
|
if (shape.props.url?.includes("medium.com") ||
|
|
(shape.props.url && shape.props.url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/))) {
|
|
return (
|
|
<div style={wrapperStyle}>
|
|
{controls(shape.props.url)}
|
|
<div
|
|
style={{
|
|
...contentStyle,
|
|
flexDirection: "column",
|
|
gap: "12px",
|
|
padding: "20px",
|
|
textAlign: "center",
|
|
pointerEvents: "all",
|
|
}}
|
|
>
|
|
<p>
|
|
Medium's content policy does not allow for embedding articles in
|
|
iframes.
|
|
</p>
|
|
<a
|
|
href={shape.props.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{
|
|
color: "#1976d2",
|
|
textDecoration: "none",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Open article in new tab →
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// For normal embed view
|
|
return (
|
|
<div style={wrapperStyle}>
|
|
<div
|
|
style={{
|
|
height: "40px",
|
|
position: "relative",
|
|
backgroundColor: "#F0F0F0",
|
|
borderTopLeftRadius: "4px",
|
|
borderTopRightRadius: "4px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
padding: "0 8px",
|
|
}}
|
|
>
|
|
{controls(shape.props.url)}
|
|
</div>
|
|
{!shape.props.isMinimized && (
|
|
<>
|
|
<div style={{
|
|
...contentStyle,
|
|
height: `${shape.props.h - 80}px`,
|
|
}}>
|
|
<iframe
|
|
src={transformUrl(shape.props.url)}
|
|
width="100%"
|
|
height="100%"
|
|
style={{ border: "none" }}
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
allowFullScreen
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
onLoad={(e) => {
|
|
// Only add listener if we have a valid iframe
|
|
const iframe = e.currentTarget as HTMLIFrameElement
|
|
if (!iframe) return;
|
|
|
|
const messageHandler = (event: MessageEvent) => {
|
|
if (event.source === iframe.contentWindow) {
|
|
handleIframeInteraction(event.data)
|
|
}
|
|
}
|
|
|
|
window.addEventListener("message", messageHandler)
|
|
|
|
// Clean up listener when iframe changes
|
|
return () => window.removeEventListener("message", messageHandler)
|
|
}}
|
|
/>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
padding: "8px",
|
|
height: "40px",
|
|
fontSize: "12px",
|
|
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
|
borderRadius: "4px",
|
|
position: "absolute",
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
flex: 1,
|
|
marginRight: "8px",
|
|
color: "#666",
|
|
}}
|
|
>
|
|
{shape.props.url}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
override onDoubleClick = (shape: IEmbedShape) => {
|
|
// If no URL is set, focus the input field
|
|
if (!shape.props.url) {
|
|
const input = document.querySelector('input')
|
|
input?.focus()
|
|
return
|
|
}
|
|
|
|
// For Medium articles and Twitter profiles that show alternative content
|
|
if (
|
|
shape.props.url.includes('medium.com') ||
|
|
(shape.props.url && shape.props.url.match(/(?:twitter\.com|x\.com)\/[^\/]+$/))
|
|
) {
|
|
window.top?.open(shape.props.url, '_blank', 'noopener,noreferrer')
|
|
return
|
|
}
|
|
|
|
// For other embeds, enable interaction by temporarily removing pointer-events: none
|
|
const iframe = document.querySelector(`[data-shape-id="${shape.id}"] iframe`) as HTMLIFrameElement
|
|
if (iframe) {
|
|
iframe.style.pointerEvents = 'all'
|
|
// Reset pointer-events after interaction
|
|
const cleanup = () => {
|
|
iframe.style.pointerEvents = 'none'
|
|
window.removeEventListener('pointerdown', cleanup)
|
|
}
|
|
window.addEventListener('pointerdown', cleanup)
|
|
}
|
|
}
|
|
|
|
// Update the pointer down handler
|
|
onPointerDown = (shape: IEmbedShape) => {
|
|
if (!shape.props.url) {
|
|
const input = document.querySelector('input')
|
|
input?.focus()
|
|
}
|
|
}
|
|
|
|
// Add a method to handle URL updates
|
|
override onBeforeCreate = (shape: IEmbedShape) => {
|
|
if (shape.props.url) {
|
|
const dimensions = getDefaultDimensions(shape.props.url)
|
|
return {
|
|
...shape,
|
|
props: {
|
|
...shape.props,
|
|
w: dimensions.w,
|
|
h: dimensions.h,
|
|
},
|
|
}
|
|
}
|
|
return shape
|
|
}
|
|
|
|
// Handle URL updates after creation
|
|
override onBeforeUpdate = (prev: IEmbedShape, next: IEmbedShape) => {
|
|
if (next.props.url && prev.props.url !== next.props.url) {
|
|
const dimensions = getDefaultDimensions(next.props.url)
|
|
return {
|
|
...next,
|
|
props: {
|
|
...next.props,
|
|
w: dimensions.w,
|
|
h: dimensions.h,
|
|
},
|
|
}
|
|
}
|
|
return next
|
|
}
|
|
}
|