diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index 675a1f487cc..205da75a97d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -2,6 +2,7 @@ import type { Extensions } from '@tiptap/core' import Placeholder from '@tiptap/extension-placeholder' import { CodeBlockWithLanguage } from './code-block' import { CodeBlockHighlight } from './code-highlight' +import { LinkEmbed } from './embed/link-embed' import { createMarkdownContentExtensions } from './extensions' import { ResizableImage } from './image' import { RichMarkdownKeymap } from './keymap' @@ -12,19 +13,23 @@ import { SlashCommand } from './slash-command/slash-command' interface MarkdownEditorExtensionOptions { placeholder: string + /** Renders supported media links as live players beneath a standalone link. Off by default. */ + embeds?: boolean } /** * The full extension set for the live editor: the content extensions with their React node-view nodes * injected (code-block language picker, resizable image, mention chip) plus the UI-only extensions — * `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), `Mention` (the `@` menu), - * `RichMarkdownKeymap`, `MarkdownPaste`, and `Placeholder`. + * `RichMarkdownKeymap`, `MarkdownPaste`, `Placeholder`, and — when `embeds` is set — `LinkEmbed` + * (media players for standalone links). * * Kept separate from `extensions.ts` so those node views (and the block registry the mention chip pulls * in for brand icons) stay out of the headless round-trip path, which only needs the schema. */ export function createMarkdownEditorExtensions({ placeholder, + embeds = false, }: MarkdownEditorExtensionOptions): Extensions { return [ ...createMarkdownContentExtensions({ @@ -38,5 +43,6 @@ export function createMarkdownEditorExtensions({ RichMarkdownKeymap, MarkdownPaste, Placeholder.configure({ placeholder }), + ...(embeds ? [LinkEmbed] : []), ] } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/embed-dom.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/embed-dom.ts new file mode 100644 index 00000000000..122bccf58e8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/embed-dom.ts @@ -0,0 +1,62 @@ +import type { EmbedInfo } from '@sim/utils/media-embed' + +/** + * Iframes are rendered at native size then CSS-scaled down so embedded players keep their + * intended layout inside the editor's reading column. Mirrors the note-block renderer. + */ +const EMBED_SCALE = 0.78 +const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%` + +const IFRAME_ALLOW = + 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share' + +/** + * Build the DOM player for a resolved {@link EmbedInfo}, matching the note-block renderer's + * markup. Returned as a non-editable element so it can back a ProseMirror widget decoration + * without entering the editable content. + */ +export function createEmbedDom(embedInfo: EmbedInfo): HTMLElement { + const container = document.createElement('div') + container.className = 'my-2 block w-full overflow-hidden rounded-md' + container.contentEditable = 'false' + + if (embedInfo.type === 'iframe') { + const frame = document.createElement('div') + frame.className = 'block overflow-hidden' + frame.style.width = '100%' + frame.style.aspectRatio = embedInfo.aspectRatio || '16/9' + + const iframe = document.createElement('iframe') + iframe.src = embedInfo.url + iframe.title = 'Media' + iframe.allow = IFRAME_ALLOW + iframe.allowFullscreen = true + iframe.loading = 'lazy' + iframe.className = 'origin-top-left' + iframe.style.width = EMBED_INVERSE_SCALE + iframe.style.height = EMBED_INVERSE_SCALE + iframe.style.transform = `scale(${EMBED_SCALE})` + + frame.appendChild(iframe) + container.appendChild(frame) + return container + } + + if (embedInfo.type === 'video') { + const video = document.createElement('video') + video.src = embedInfo.url + video.controls = true + video.preload = 'metadata' + video.className = 'aspect-video w-full' + container.appendChild(video) + return container + } + + const audio = document.createElement('audio') + audio.src = embedInfo.url + audio.controls = true + audio.preload = 'metadata' + audio.className = 'w-full' + container.appendChild(audio) + return container +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.test.ts new file mode 100644 index 00000000000..ad5437066ff --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.test.ts @@ -0,0 +1,64 @@ +/** + * @vitest-environment jsdom + */ +import { Editor } from '@tiptap/core' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { createMarkdownEditorExtensions } from '../editor-extensions' + +// jsdom lacks elementFromPoint, which TipTap's Placeholder viewport tracking calls on mount. +beforeAll(() => { + document.elementFromPoint = vi.fn(() => null) +}) + +let editor: Editor | null = null + +function editorWith(content: string, embeds = true): Editor { + editor = new Editor({ + extensions: createMarkdownEditorExtensions({ placeholder: '', embeds }), + content, + }) + return editor +} + +afterEach(() => { + editor?.destroy() + editor = null +}) + +const YOUTUBE_LINK = '

watch

' + +describe('LinkEmbed', () => { + it('renders a player beneath a standalone embeddable link', () => { + const view = editorWith(YOUTUBE_LINK).view + const iframe = view.dom.querySelector('iframe') + expect(iframe?.getAttribute('src')).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ') + }) + + it('renders one player per link when the same URL appears twice', () => { + const view = editorWith(`${YOUTUBE_LINK}${YOUTUBE_LINK}`).view + expect(view.dom.querySelectorAll('iframe')).toHaveLength(2) + }) + + it('keeps the underlying document a plain markdown link (lossless round-trip)', () => { + const markdown = editorWith(YOUTUBE_LINK).getMarkdown() + expect(markdown).toContain('https://www.youtube.com/watch?v=dQw4w9WgXcQ') + expect(markdown).not.toContain(' { + const view = editorWith( + '

see here now

' + ).view + expect(view.dom.querySelector('iframe')).toBeNull() + }) + + it('does not embed a non-embeddable standalone link', () => { + const view = editorWith('

read

').view + expect(view.dom.querySelector('iframe')).toBeNull() + }) + + it('does nothing when the embeds option is disabled', () => { + const view = editorWith(YOUTUBE_LINK, false).view + expect(view.dom.querySelector('iframe')).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.ts new file mode 100644 index 00000000000..b76cf241562 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.ts @@ -0,0 +1,88 @@ +import { getEmbedInfo } from '@sim/utils/media-embed' +import { Extension } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { createEmbedDom } from './embed-dom' + +const LINK_EMBED_PLUGIN_KEY = new PluginKey('linkEmbed') + +/** + * The href of a paragraph that is a single, whole-text link (a "standalone link"), or null if + * the paragraph is empty, holds non-text content, or mixes a link with other text. Only + * standalone links become media embeds — a link inline within a sentence stays a plain link, + * matching how Notion and Linear auto-embed. + */ +function getStandaloneLinkHref(paragraph: ProseMirrorNode): string | null { + if (paragraph.childCount === 0) return null + let href: string | null = null + let isStandalone = true + paragraph.forEach((child) => { + if (!isStandalone) return + const linkMark = child.isText + ? child.marks.find((mark) => mark.type.name === 'link') + : undefined + if (!linkMark) { + isStandalone = false + return + } + const childHref = linkMark.attrs.href as string + if (href === null) href = childHref + else if (href !== childHref) isStandalone = false + }) + return isStandalone ? href : null +} + +function buildDecorations(doc: ProseMirrorNode): DecorationSet { + const decorations: Decoration[] = [] + /** Per-source occurrence count, so repeated embeds of the same URL get distinct, stable keys. */ + const sourceCounts = new Map() + doc.descendants((node, pos) => { + if (node.type.name !== 'paragraph') return undefined + const href = getStandaloneLinkHref(node) + if (href) { + const embedInfo = getEmbedInfo(href) + if (embedInfo) { + const source = `embed:${embedInfo.type}:${embedInfo.url}` + const index = sourceCounts.get(source) ?? 0 + sourceCounts.set(source, index + 1) + decorations.push( + Decoration.widget(pos + node.nodeSize, () => createEmbedDom(embedInfo), { + side: 1, + key: `${source}:${index}`, + }) + ) + } + } + // Paragraphs hold only inline content, so there is nothing more to descend into. + return false + }) + return DecorationSet.create(doc, decorations) +} + +/** + * Renders supported media links (YouTube, Vimeo, Spotify, Dropbox, …) as live players beneath a + * standalone link, in both the editing and read-only surfaces. Implemented as widget decorations + * so the underlying document stays a plain markdown link — embeds never enter the schema or the + * serialized markdown, keeping round-trips lossless. + */ +export const LinkEmbed = Extension.create({ + name: 'linkEmbed', + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: LINK_EMBED_PLUGIN_KEY, + state: { + init: (_, { doc }) => buildDecorations(doc), + apply: (tr, current) => (tr.docChanged ? buildDecorations(tr.doc) : current), + }, + props: { + decorations(state) { + return LINK_EMBED_PLUGIN_KEY.getState(state) + }, + }, + }), + ] + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index f8a8286d221..db394d23174 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -31,6 +31,7 @@ import './rich-markdown-editor.css' const EXTENSIONS = createMarkdownEditorExtensions({ placeholder: "Write something, or press '/' for commands…", + embeds: true, }) /** Throttle the per-frame full re-parse above this body size so a large streaming file can't saturate the main thread. */ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx index cc194295460..bdab7e2a328 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx @@ -205,7 +205,7 @@ export function ChunkEditor({
{ if (e.target === e.currentTarget) textareaRef.current?.focus() }} @@ -217,7 +217,7 @@ export function ChunkEditor({ {tokenizerOn ? (
{tokenStrings.map((token, index) => ( =0.479.0", "react": "^19", "reactflow": "^11.11.4", diff --git a/packages/utils/package.json b/packages/utils/package.json index 9de01b4a73e..6413ed1d0eb 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -26,6 +26,10 @@ "types": "./src/helpers.ts", "default": "./src/helpers.ts" }, + "./media-embed": { + "types": "./src/media-embed.ts", + "default": "./src/media-embed.ts" + }, "./formatting": { "types": "./src/formatting.ts", "default": "./src/formatting.ts" diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index eb89a3de69a..fe2ab2f3b98 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -12,6 +12,8 @@ export { } from './formatting.js' export { noop, sleep } from './helpers.js' export { generateId, generateShortId, isValidUuid } from './id.js' +export type { EmbedInfo } from './media-embed.js' +export { getEmbedInfo } from './media-embed.js' export { filterUndefined, isPlainRecord, diff --git a/packages/utils/src/media-embed.test.ts b/packages/utils/src/media-embed.test.ts new file mode 100644 index 00000000000..6422ebfa5c0 --- /dev/null +++ b/packages/utils/src/media-embed.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { getEmbedInfo } from './media-embed' + +describe('getEmbedInfo', () => { + it('maps YouTube watch/short/embed URLs to the embed iframe', () => { + const expected = { url: 'https://www.youtube.com/embed/dQw4w9WgXcQ', type: 'iframe' } + expect(getEmbedInfo('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toEqual(expected) + expect(getEmbedInfo('https://youtu.be/dQw4w9WgXcQ')).toEqual(expected) + expect(getEmbedInfo('https://www.youtube.com/embed/dQw4w9WgXcQ')).toEqual(expected) + }) + + it('maps Vimeo and Spotify URLs with their aspect ratios', () => { + expect(getEmbedInfo('https://vimeo.com/123456')).toEqual({ + url: 'https://player.vimeo.com/video/123456', + type: 'iframe', + }) + expect(getEmbedInfo('https://open.spotify.com/track/abc123')).toEqual({ + url: 'https://open.spotify.com/embed/track/abc123', + type: 'iframe', + aspectRatio: '3.7/1', + }) + }) + + it('treats bare media file extensions as native video/audio', () => { + expect(getEmbedInfo('https://cdn.example.com/clip.mp4')).toEqual({ + url: 'https://cdn.example.com/clip.mp4', + type: 'video', + }) + expect(getEmbedInfo('https://cdn.example.com/sound.mp3')).toEqual({ + url: 'https://cdn.example.com/sound.mp3', + type: 'audio', + }) + }) + + it('returns null for non-embeddable URLs', () => { + expect(getEmbedInfo('https://example.com/article')).toBeNull() + expect(getEmbedInfo('not a url')).toBeNull() + }) + + it('only embeds when the parsed host belongs to the provider', () => { + // A provider domain in the path or as a subdomain prefix of an attacker host + // must not be treated as that provider. + expect(getEmbedInfo('https://evil.com/youtube.com/watch?v=dQw4w9WgXcQ')).toBeNull() + expect(getEmbedInfo('https://youtube.com.evil.com/watch?v=dQw4w9WgXcQ')).toBeNull() + expect(getEmbedInfo('https://evil.com/open.spotify.com/track/abc123')).toBeNull() + expect(getEmbedInfo('https://vimeo.com.evil.com/123456')).toBeNull() + // Legitimate subdomains of a provider still embed. + expect(getEmbedInfo('https://m.youtube.com/watch?v=dQw4w9WgXcQ')).toEqual({ + url: 'https://www.youtube.com/embed/dQw4w9WgXcQ', + type: 'iframe', + }) + }) + + describe('Dropbox', () => { + it('rewrites a Dropbox video share link to a direct streamable URL', () => { + expect(getEmbedInfo('https://www.dropbox.com/s/abc/clip.mp4?dl=0')).toEqual({ + url: 'https://dl.dropboxusercontent.com/s/abc/clip.mp4', + type: 'video', + }) + }) + + it('handles non-www and scheme-less Dropbox hosts', () => { + expect(getEmbedInfo('https://m.dropbox.com/s/abc/clip.mov')).toEqual({ + url: 'https://dl.dropboxusercontent.com/s/abc/clip.mov', + type: 'video', + }) + expect(getEmbedInfo('dropbox.com/s/abc/clip.webm')).toEqual({ + url: 'https://dl.dropboxusercontent.com/s/abc/clip.webm', + type: 'video', + }) + }) + + it('does not apply the Dropbox direct-link rewrite to look-alike hosts', () => { + // Look-alike hosts fall through to the generic video handler with their + // original (untrusted) host intact — never rewritten as if trusted Dropbox. + expect(getEmbedInfo('https://dropbox.com.evil.com/clip.mp4')?.url).not.toContain( + 'dropboxusercontent.com' + ) + expect(getEmbedInfo('https://evil.com/?x=dropbox.com/clip.mp4')?.url).not.toContain( + 'dropboxusercontent.com' + ) + }) + }) +}) diff --git a/packages/utils/src/media-embed.ts b/packages/utils/src/media-embed.ts new file mode 100644 index 00000000000..1432e22198d --- /dev/null +++ b/packages/utils/src/media-embed.ts @@ -0,0 +1,339 @@ +/** + * Resolved embed for a media URL: the iframe/video/audio source to render plus + * an optional aspect ratio hint. Renderers own the surrounding markup; this + * module only decides whether a URL is embeddable and what source to use. + */ +export interface EmbedInfo { + url: string + type: 'iframe' | 'video' | 'audio' + aspectRatio?: string +} + +/** + * The `parent` query param required by Twitch embeds. Reads the current host in + * the browser and falls back to `localhost` during SSR. + */ +function getTwitchParent(): string { + return typeof window !== 'undefined' ? window.location.hostname : 'localhost' +} + +/** Parse a URL, tolerating scheme-less inputs (https is assumed). Returns null if unparseable. */ +function parseUrl(url: string): URL | null { + for (const candidate of [url, `https://${url}`]) { + try { + return new URL(candidate) + } catch {} + } + return null +} + +/** + * Whether `host` is one of `domains` or a subdomain of one (e.g. `m.youtube.com` + * matches `youtube.com`). A null host (unparseable URL) never matches. This is the + * security boundary for provider detection: a link is only treated as a given + * platform when its parsed host actually belongs to that platform, so look-alikes + * like `youtube.com.evil.com` or `evil.com/youtube.com/...` are rejected. + */ +function hostMatches(host: string | null, ...domains: string[]): boolean { + if (host === null) return false + return domains.some((domain) => host === domain || host.endsWith(`.${domain}`)) +} + +/** + * Rewrite a Dropbox share URL's host to `dl.dropboxusercontent.com` so the file + * streams as media, returning null for a non-video path. The caller has already + * verified the host is Dropbox. + */ +function toDropboxDirectVideoUrl(parsed: URL): string | null { + if (!/\.(mp4|mov|webm)$/i.test(parsed.pathname)) return null + parsed.hostname = 'dl.dropboxusercontent.com' + parsed.searchParams.delete('dl') + return parsed.toString() +} + +/** + * Map a URL to its embeddable form across supported media platforms (YouTube, + * Vimeo, Spotify, Apple Music, Twitch, Dropbox, Giphy, and many more), plus + * generic video/audio file extensions. Returns null when the URL is not a + * recognized embeddable source. + * + * Each platform is gated on its parsed hostname via {@link hostMatches} before its + * id-extracting regex runs. The generic file-extension fallbacks are intentionally + * host-agnostic — any direct media file URL is embeddable. + */ +export function getEmbedInfo(url: string): EmbedInfo | null { + const parsed = parseUrl(url) + const host = parsed?.hostname.toLowerCase() ?? null + if (hostMatches(host, 'youtube.com', 'youtu.be')) { + const youtubeMatch = url.match( + /(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/ + ) + if (youtubeMatch) { + return { url: `https://www.youtube.com/embed/${youtubeMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'vimeo.com')) { + const vimeoMatch = url.match(/vimeo\.com\/(\d+)/) + if (vimeoMatch) { + return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'dailymotion.com')) { + const dailymotionMatch = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/) + if (dailymotionMatch) { + return { + url: `https://www.dailymotion.com/embed/video/${dailymotionMatch[1]}`, + type: 'iframe', + } + } + } + + if (hostMatches(host, 'twitch.tv')) { + const twitchVideoMatch = url.match(/twitch\.tv\/videos\/(\d+)/) + if (twitchVideoMatch) { + return { + url: `https://player.twitch.tv/?video=${twitchVideoMatch[1]}&parent=${getTwitchParent()}`, + type: 'iframe', + } + } + + const twitchClipMatch = + url.match(/clips\.twitch\.tv\/([a-zA-Z0-9_-]+)/) || + url.match(/twitch\.tv\/[^/]+\/clip\/([a-zA-Z0-9_-]+)/) + if (twitchClipMatch) { + return { + url: `https://clips.twitch.tv/embed?clip=${twitchClipMatch[1]}&parent=${getTwitchParent()}`, + type: 'iframe', + } + } + + const twitchChannelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\/|$)/) + if (twitchChannelMatch && !url.includes('/videos/') && !url.includes('/clip/')) { + return { + url: `https://player.twitch.tv/?channel=${twitchChannelMatch[1]}&parent=${getTwitchParent()}`, + type: 'iframe', + } + } + } + + if (hostMatches(host, 'streamable.com')) { + const streamableMatch = url.match(/streamable\.com\/([a-zA-Z0-9]+)/) + if (streamableMatch) { + return { url: `https://streamable.com/e/${streamableMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'wistia.com', 'wistia.net')) { + const wistiaMatch = url.match(/(?:wistia\.com|wistia\.net)\/(?:medias|embed)\/([a-zA-Z0-9]+)/) + if (wistiaMatch) { + return { url: `https://fast.wistia.net/embed/iframe/${wistiaMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'tiktok.com')) { + const tiktokMatch = url.match(/tiktok\.com\/@[^/]+\/video\/(\d+)/) + if (tiktokMatch) { + return { + url: `https://www.tiktok.com/embed/v2/${tiktokMatch[1]}`, + type: 'iframe', + aspectRatio: '9/16', + } + } + } + + if (hostMatches(host, 'soundcloud.com')) { + const soundcloudMatch = url.match(/soundcloud\.com\/([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/) + if (soundcloudMatch) { + return { + url: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`, + type: 'iframe', + aspectRatio: '3/2', + } + } + } + + if (hostMatches(host, 'spotify.com')) { + const spotifyMatch = url.match( + /open\.spotify\.com\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/ + ) + if (spotifyMatch) { + const [, kind, id] = spotifyMatch + const aspectRatio = + kind === 'track' || kind === 'show' ? '3.7/1' : kind === 'episode' ? '2.5/1' : '2/3' + return { url: `https://open.spotify.com/embed/${kind}/${id}`, type: 'iframe', aspectRatio } + } + } + + if (hostMatches(host, 'apple.com')) { + const appleMusicSongMatch = url.match(/music\.apple\.com\/([a-z]{2})\/song\/[^/]+\/(\d+)/) + if (appleMusicSongMatch) { + const [, country, songId] = appleMusicSongMatch + return { + url: `https://embed.music.apple.com/${country}/song/${songId}`, + type: 'iframe', + aspectRatio: '3/2', + } + } + + const appleMusicAlbumMatch = url.match( + /music\.apple\.com\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/ + ) + if (appleMusicAlbumMatch) { + const [, country, albumId] = appleMusicAlbumMatch + return { + url: `https://embed.music.apple.com/${country}/album/${albumId}`, + type: 'iframe', + aspectRatio: '2/3', + } + } + + const appleMusicPlaylistMatch = url.match( + /music\.apple\.com\/([a-z]{2})\/playlist\/[^/]+\/(pl\.[a-zA-Z0-9]+)/ + ) + if (appleMusicPlaylistMatch) { + const [, country, playlistId] = appleMusicPlaylistMatch + return { + url: `https://embed.music.apple.com/${country}/playlist/${playlistId}`, + type: 'iframe', + aspectRatio: '2/3', + } + } + } + + if (hostMatches(host, 'loom.com')) { + const loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/) + if (loomMatch) { + return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'facebook.com', 'fb.watch')) { + const facebookVideoMatch = + url.match(/facebook\.com\/.*\/videos\/(\d+)/) || url.match(/fb\.watch\/([a-zA-Z0-9_-]+)/) + if (facebookVideoMatch) { + return { + url: `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false`, + type: 'iframe', + } + } + } + + if (hostMatches(host, 'instagram.com')) { + const instagramReelMatch = url.match(/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/) + if (instagramReelMatch) { + return { + url: `https://www.instagram.com/reel/${instagramReelMatch[1]}/embed`, + type: 'iframe', + aspectRatio: '9/16', + } + } + + const instagramPostMatch = url.match(/instagram\.com\/p\/([a-zA-Z0-9_-]+)/) + if (instagramPostMatch) { + return { + url: `https://www.instagram.com/p/${instagramPostMatch[1]}/embed`, + type: 'iframe', + aspectRatio: '4/5', + } + } + } + + if (hostMatches(host, 'twitter.com', 'x.com')) { + const twitterMatch = url.match(/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/) + if (twitterMatch) { + return { + url: `https://platform.twitter.com/embed/Tweet.html?id=${twitterMatch[1]}`, + type: 'iframe', + aspectRatio: '3/4', + } + } + } + + if (hostMatches(host, 'rumble.com')) { + const rumbleMatch = + url.match(/rumble\.com\/embed\/([a-zA-Z0-9]+)/) || url.match(/rumble\.com\/([a-zA-Z0-9]+)-/) + if (rumbleMatch) { + return { url: `https://rumble.com/embed/${rumbleMatch[1]}/`, type: 'iframe' } + } + } + + if (hostMatches(host, 'bilibili.com')) { + const bilibiliMatch = url.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/) + if (bilibiliMatch) { + return { + url: `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&high_quality=1`, + type: 'iframe', + } + } + } + + if (hostMatches(host, 'vidyard.com')) { + const vidyardMatch = url.match(/(?:vidyard\.com|share\.vidyard\.com)\/watch\/([a-zA-Z0-9]+)/) + if (vidyardMatch) { + return { url: `https://play.vidyard.com/${vidyardMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'cloudflarestream.com', 'videodelivery.net')) { + const cfStreamMatch = + url.match(/cloudflarestream\.com\/([a-zA-Z0-9]+)/) || + url.match(/videodelivery\.net\/([a-zA-Z0-9]+)/) + if (cfStreamMatch) { + return { url: `https://iframe.cloudflarestream.com/${cfStreamMatch[1]}`, type: 'iframe' } + } + } + + if (hostMatches(host, 'mixcloud.com')) { + const mixcloudMatch = url.match(/mixcloud\.com\/([^/]+\/[^/]+)/) + if (mixcloudMatch) { + return { + url: `https://www.mixcloud.com/widget/iframe/?feed=%2F${encodeURIComponent(mixcloudMatch[1])}%2F&hide_cover=1`, + type: 'iframe', + aspectRatio: '2/1', + } + } + } + + if (hostMatches(host, 'google.com')) { + const googleDriveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/) + if (googleDriveMatch) { + return { + url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`, + type: 'iframe', + } + } + } + + if (parsed && hostMatches(host, 'dropbox.com')) { + const dropboxDirectVideoUrl = toDropboxDirectVideoUrl(parsed) + if (dropboxDirectVideoUrl) { + return { url: dropboxDirectVideoUrl, type: 'video' } + } + } + + if (hostMatches(host, 'tenor.com')) { + const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/) + if (tenorMatch) { + return { url: `https://tenor.com/embed/${tenorMatch[1]}`, type: 'iframe', aspectRatio: '1/1' } + } + } + + if (hostMatches(host, 'giphy.com')) { + const giphyMatch = url.match(/giphy\.com\/(?:gifs|embed)\/(?:.*-)?([a-zA-Z0-9]+)/) + if (giphyMatch) { + return { url: `https://giphy.com/embed/${giphyMatch[1]}`, type: 'iframe', aspectRatio: '1/1' } + } + } + + if (/\.(mp4|webm|ogg|mov)(\?|$)/i.test(url)) { + return { url, type: 'video' } + } + + if (/\.(mp3|wav|m4a|aac)(\?|$)/i.test(url)) { + return { url, type: 'audio' } + } + + return null +} diff --git a/packages/workflow-renderer/package.json b/packages/workflow-renderer/package.json index 64f08ccb7f7..2fbefefdbbe 100644 --- a/packages/workflow-renderer/package.json +++ b/packages/workflow-renderer/package.json @@ -27,6 +27,7 @@ }, "peerDependencies": { "@sim/emcn": "workspace:*", + "@sim/utils": "workspace:*", "lucide-react": ">=0.479.0", "react": "^19", "reactflow": "^11.11.4", @@ -36,6 +37,7 @@ "devDependencies": { "@sim/emcn": "workspace:*", "@sim/tsconfig": "workspace:*", + "@sim/utils": "workspace:*", "@types/react": "^19", "lucide-react": "^0.479.0", "react": "19.2.4", diff --git a/packages/workflow-renderer/src/note/note-block-view.tsx b/packages/workflow-renderer/src/note/note-block-view.tsx index 70f6be2fbbb..4b0a3e50159 100644 --- a/packages/workflow-renderer/src/note/note-block-view.tsx +++ b/packages/workflow-renderer/src/note/note-block-view.tsx @@ -3,307 +3,11 @@ import remarkBreaks from 'remark-breaks' import { Streamdown } from 'streamdown' import 'streamdown/styles.css' import { cn, handleKeyboardActivation } from '@sim/emcn' - -type EmbedInfo = { - url: string - type: 'iframe' | 'video' | 'audio' - aspectRatio?: string -} +import { getEmbedInfo } from '@sim/utils/media-embed' const EMBED_SCALE = 0.78 const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%` -function getTwitchParent(): string { - return typeof window !== 'undefined' ? window.location.hostname : 'localhost' -} - -/** Parse a URL, tolerating scheme-less inputs (https is assumed). Returns null if unparseable. */ -function parseUrl(url: string): URL | null { - for (const candidate of [url, `https://${url}`]) { - try { - return new URL(candidate) - } catch {} - } - return null -} - -/** - * Resolve a Dropbox share link to a direct, embeddable video URL. Accepts only URLs - * whose host is `dropbox.com` or a `*.dropbox.com` subdomain (so attacker-controlled - * hosts like `dropbox.com.evil.com` are rejected), then rewrites the host to - * `dl.dropboxusercontent.com` so the file streams as media. Returns null for any - * non-Dropbox host or non-video path. - */ -function getDropboxDirectVideoUrl(url: string): string | null { - const parsed = parseUrl(url) - if (!parsed) return null - const host = parsed.hostname.toLowerCase() - if (host !== 'dropbox.com' && !host.endsWith('.dropbox.com')) return null - if (!/\.(mp4|mov|webm)$/i.test(parsed.pathname)) return null - parsed.hostname = 'dl.dropboxusercontent.com' - parsed.searchParams.delete('dl') - return parsed.toString() -} - -/** - * Get embed info for supported media platforms - */ -function getEmbedInfo(url: string): EmbedInfo | null { - const youtubeMatch = url.match( - /(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/ - ) - if (youtubeMatch) { - return { url: `https://www.youtube.com/embed/${youtubeMatch[1]}`, type: 'iframe' } - } - - const vimeoMatch = url.match(/vimeo\.com\/(\d+)/) - if (vimeoMatch) { - return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, type: 'iframe' } - } - - const dailymotionMatch = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/) - if (dailymotionMatch) { - return { url: `https://www.dailymotion.com/embed/video/${dailymotionMatch[1]}`, type: 'iframe' } - } - - const twitchVideoMatch = url.match(/twitch\.tv\/videos\/(\d+)/) - if (twitchVideoMatch) { - return { - url: `https://player.twitch.tv/?video=${twitchVideoMatch[1]}&parent=${getTwitchParent()}`, - type: 'iframe', - } - } - - const twitchChannelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\/|$)/) - if (twitchChannelMatch && !url.includes('/videos/') && !url.includes('/clip/')) { - return { - url: `https://player.twitch.tv/?channel=${twitchChannelMatch[1]}&parent=${getTwitchParent()}`, - type: 'iframe', - } - } - - const streamableMatch = url.match(/streamable\.com\/([a-zA-Z0-9]+)/) - if (streamableMatch) { - return { url: `https://streamable.com/e/${streamableMatch[1]}`, type: 'iframe' } - } - - const wistiaMatch = url.match(/(?:wistia\.com|wistia\.net)\/(?:medias|embed)\/([a-zA-Z0-9]+)/) - if (wistiaMatch) { - return { url: `https://fast.wistia.net/embed/iframe/${wistiaMatch[1]}`, type: 'iframe' } - } - - const tiktokMatch = url.match(/tiktok\.com\/@[^/]+\/video\/(\d+)/) - if (tiktokMatch) { - return { - url: `https://www.tiktok.com/embed/v2/${tiktokMatch[1]}`, - type: 'iframe', - aspectRatio: '9/16', - } - } - - const soundcloudMatch = url.match(/soundcloud\.com\/([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/) - if (soundcloudMatch) { - return { - url: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`, - type: 'iframe', - aspectRatio: '3/2', - } - } - - const spotifyTrackMatch = url.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/) - if (spotifyTrackMatch) { - return { - url: `https://open.spotify.com/embed/track/${spotifyTrackMatch[1]}`, - type: 'iframe', - aspectRatio: '3.7/1', - } - } - - const spotifyAlbumMatch = url.match(/open\.spotify\.com\/album\/([a-zA-Z0-9]+)/) - if (spotifyAlbumMatch) { - return { - url: `https://open.spotify.com/embed/album/${spotifyAlbumMatch[1]}`, - type: 'iframe', - aspectRatio: '2/3', - } - } - - const spotifyPlaylistMatch = url.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/) - if (spotifyPlaylistMatch) { - return { - url: `https://open.spotify.com/embed/playlist/${spotifyPlaylistMatch[1]}`, - type: 'iframe', - aspectRatio: '2/3', - } - } - - const spotifyEpisodeMatch = url.match(/open\.spotify\.com\/episode\/([a-zA-Z0-9]+)/) - if (spotifyEpisodeMatch) { - return { - url: `https://open.spotify.com/embed/episode/${spotifyEpisodeMatch[1]}`, - type: 'iframe', - aspectRatio: '2.5/1', - } - } - - const spotifyShowMatch = url.match(/open\.spotify\.com\/show\/([a-zA-Z0-9]+)/) - if (spotifyShowMatch) { - return { - url: `https://open.spotify.com/embed/show/${spotifyShowMatch[1]}`, - type: 'iframe', - aspectRatio: '3.7/1', - } - } - - const appleMusicSongMatch = url.match(/music\.apple\.com\/([a-z]{2})\/song\/[^/]+\/(\d+)/) - if (appleMusicSongMatch) { - const [, country, songId] = appleMusicSongMatch - return { - url: `https://embed.music.apple.com/${country}/song/${songId}`, - type: 'iframe', - aspectRatio: '3/2', - } - } - - const appleMusicAlbumMatch = url.match(/music\.apple\.com\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/) - if (appleMusicAlbumMatch) { - const [, country, albumId] = appleMusicAlbumMatch - return { - url: `https://embed.music.apple.com/${country}/album/${albumId}`, - type: 'iframe', - aspectRatio: '2/3', - } - } - - const appleMusicPlaylistMatch = url.match( - /music\.apple\.com\/([a-z]{2})\/playlist\/[^/]+\/(pl\.[a-zA-Z0-9]+)/ - ) - if (appleMusicPlaylistMatch) { - const [, country, playlistId] = appleMusicPlaylistMatch - return { - url: `https://embed.music.apple.com/${country}/playlist/${playlistId}`, - type: 'iframe', - aspectRatio: '2/3', - } - } - - const loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/) - if (loomMatch) { - return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' } - } - - const facebookVideoMatch = - url.match(/facebook\.com\/.*\/videos\/(\d+)/) || url.match(/fb\.watch\/([a-zA-Z0-9_-]+)/) - if (facebookVideoMatch) { - return { - url: `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false`, - type: 'iframe', - } - } - - const instagramReelMatch = url.match(/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/) - if (instagramReelMatch) { - return { - url: `https://www.instagram.com/reel/${instagramReelMatch[1]}/embed`, - type: 'iframe', - aspectRatio: '9/16', - } - } - - const instagramPostMatch = url.match(/instagram\.com\/p\/([a-zA-Z0-9_-]+)/) - if (instagramPostMatch) { - return { - url: `https://www.instagram.com/p/${instagramPostMatch[1]}/embed`, - type: 'iframe', - aspectRatio: '4/5', - } - } - - const twitterMatch = url.match(/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/) - if (twitterMatch) { - return { - url: `https://platform.twitter.com/embed/Tweet.html?id=${twitterMatch[1]}`, - type: 'iframe', - aspectRatio: '3/4', - } - } - - const rumbleMatch = - url.match(/rumble\.com\/embed\/([a-zA-Z0-9]+)/) || url.match(/rumble\.com\/([a-zA-Z0-9]+)-/) - if (rumbleMatch) { - return { url: `https://rumble.com/embed/${rumbleMatch[1]}/`, type: 'iframe' } - } - - const bilibiliMatch = url.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/) - if (bilibiliMatch) { - return { - url: `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&high_quality=1`, - type: 'iframe', - } - } - - const vidyardMatch = url.match(/(?:vidyard\.com|share\.vidyard\.com)\/watch\/([a-zA-Z0-9]+)/) - if (vidyardMatch) { - return { url: `https://play.vidyard.com/${vidyardMatch[1]}`, type: 'iframe' } - } - - const cfStreamMatch = - url.match(/cloudflarestream\.com\/([a-zA-Z0-9]+)/) || - url.match(/videodelivery\.net\/([a-zA-Z0-9]+)/) - if (cfStreamMatch) { - return { url: `https://iframe.cloudflarestream.com/${cfStreamMatch[1]}`, type: 'iframe' } - } - - const twitchClipMatch = - url.match(/clips\.twitch\.tv\/([a-zA-Z0-9_-]+)/) || - url.match(/twitch\.tv\/[^/]+\/clip\/([a-zA-Z0-9_-]+)/) - if (twitchClipMatch) { - return { - url: `https://clips.twitch.tv/embed?clip=${twitchClipMatch[1]}&parent=${getTwitchParent()}`, - type: 'iframe', - } - } - - const mixcloudMatch = url.match(/mixcloud\.com\/([^/]+\/[^/]+)/) - if (mixcloudMatch) { - return { - url: `https://www.mixcloud.com/widget/iframe/?feed=%2F${encodeURIComponent(mixcloudMatch[1])}%2F&hide_cover=1`, - type: 'iframe', - aspectRatio: '2/1', - } - } - - const googleDriveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/) - if (googleDriveMatch) { - return { url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`, type: 'iframe' } - } - - const dropboxDirectVideoUrl = getDropboxDirectVideoUrl(url) - if (dropboxDirectVideoUrl) { - return { url: dropboxDirectVideoUrl, type: 'video' } - } - - const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/) - if (tenorMatch) { - return { url: `https://tenor.com/embed/${tenorMatch[1]}`, type: 'iframe', aspectRatio: '1/1' } - } - - const giphyMatch = url.match(/giphy\.com\/(?:gifs|embed)\/(?:.*-)?([a-zA-Z0-9]+)/) - if (giphyMatch) { - return { url: `https://giphy.com/embed/${giphyMatch[1]}`, type: 'iframe', aspectRatio: '1/1' } - } - - if (/\.(mp4|webm|ogg|mov)(\?|$)/i.test(url)) { - return { url, type: 'video' } - } - - if (/\.(mp3|wav|m4a|aac)(\?|$)/i.test(url)) { - return { url, type: 'audio' } - } - - return null -} - /** * Compact markdown renderer for note blocks with tight spacing */