From 647c4987471dc151cad3807a8cf191f00a6401c0 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 10:57:43 -0700 Subject: [PATCH 1/4] feat(rich-markdown-editor): live media embeds + shared embed detection util MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract getEmbedInfo/EmbedInfo into pure @sim/utils/media-embed (carries the PR #5288 dropbox host-validation hardening); repoint the note block to it - Add LinkEmbed: a ProseMirror widget-decoration plugin that renders media players (YouTube, Vimeo, Spotify, Dropbox, …) beneath standalone links in the rich markdown editor, in both editing and read-only surfaces. The document stays a plain markdown link, so markdown round-trips stay lossless - Gate embeds behind an opt-in flag (on for the file editor, off for modal fields) - Polish the knowledge chunk editor to the file editor's centered reading frame while keeping it plaintext for exact embedding fidelity --- .../rich-markdown-editor/editor-extensions.ts | 8 +- .../rich-markdown-editor/embed/embed-dom.ts | 62 ++++ .../embed/link-embed.test.ts | 59 ++++ .../rich-markdown-editor/embed/link-embed.ts | 85 +++++ .../rich-markdown-editor.tsx | 1 + .../components/chunk-editor/chunk-editor.tsx | 6 +- bun.lock | 2 + packages/utils/package.json | 4 + packages/utils/src/index.ts | 2 + packages/utils/src/media-embed.test.ts | 70 ++++ packages/utils/src/media-embed.ts | 308 ++++++++++++++++++ packages/workflow-renderer/package.json | 2 + .../src/note/note-block-view.tsx | 298 +---------------- 13 files changed, 606 insertions(+), 301 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/embed-dom.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.ts create mode 100644 packages/utils/src/media-embed.test.ts create mode 100644 packages/utils/src/media-embed.ts 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..b7d8f869de1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.test.ts @@ -0,0 +1,59 @@ +/** + * @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('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..f59356f2cc2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed.ts @@ -0,0 +1,85 @@ +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(node: ProseMirrorNode): string | null { + if (node.type.name !== 'paragraph' || node.childCount === 0) return null + let href: string | null = null + let isStandalone = true + node.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[] = [] + doc.descendants((node, pos) => { + if (node.type.name !== 'paragraph') return undefined + const href = getStandaloneLinkHref(node) + if (href) { + const embedInfo = getEmbedInfo(href) + if (embedInfo) { + // Render the player just after the link paragraph, keyed by source so the iframe/video + // DOM is reused across edits instead of reloading on every keystroke. + decorations.push( + Decoration.widget(pos + node.nodeSize, () => createEmbedDom(embedInfo), { + side: 1, + key: `embed:${embedInfo.type}:${embedInfo.url}`, + }) + ) + } + } + // Paragraphs hold only inline content — never another embeddable paragraph. + 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..312ac226b70 --- /dev/null +++ b/packages/utils/src/media-embed.test.ts @@ -0,0 +1,70 @@ +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() + }) + + 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..f4f025dfe18 --- /dev/null +++ b/packages/utils/src/media-embed.ts @@ -0,0 +1,308 @@ +/** + * 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 +} + +/** + * 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() +} + +/** + * 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. + */ +export 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 +} 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 */ From ef831301277f5a7ca8fcbb5cdec79244e2712e35 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 11:08:19 -0700 Subject: [PATCH 2/4] fix(media-embed): gate provider detection on parsed hostname MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate each platform against the URL's parsed host before extracting, so a look-alike host (youtube.com.evil.com) or a provider domain in the path (evil.com/youtube.com/...) can no longer render a trusted-looking embed. Dropbox is no longer a special case — all providers share the hostMatches gate. Also consolidates the five Spotify branches and orders Twitch clip before channel. --- packages/utils/src/media-embed.test.ts | 14 + packages/utils/src/media-embed.ts | 400 +++++++++++++------------ 2 files changed, 230 insertions(+), 184 deletions(-) diff --git a/packages/utils/src/media-embed.test.ts b/packages/utils/src/media-embed.test.ts index 312ac226b70..6422ebfa5c0 100644 --- a/packages/utils/src/media-embed.test.ts +++ b/packages/utils/src/media-embed.test.ts @@ -37,6 +37,20 @@ describe('getEmbedInfo', () => { 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({ diff --git a/packages/utils/src/media-embed.ts b/packages/utils/src/media-embed.ts index f4f025dfe18..27d2b2ed1eb 100644 --- a/packages/utils/src/media-embed.ts +++ b/packages/utils/src/media-embed.ts @@ -28,17 +28,23 @@ function parseUrl(url: string): URL | 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. + * 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 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 +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') @@ -50,250 +56,276 @@ function getDropboxDirectVideoUrl(url: string): string | null { * 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 the + * id-extracting regex runs, so a look-alike host can never produce a trusted-looking + * embed. The generic file-extension fallbacks are intentionally host-agnostic — any + * direct media file URL is embeddable. */ export 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 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' } } } - 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, 'vimeo.com')) { + const vimeoMatch = url.match(/vimeo\.com\/(\d+)/) + if (vimeoMatch) { + return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, 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' } + 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', + } + } } - 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, '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 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 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 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 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 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', + 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' } } } - 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', + 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' } } } - 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', + 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', + } } } - 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', + 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', + } } } - 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', + 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 } } } - 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', + 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 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 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 loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/) - if (loomMatch) { - return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' } + 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 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, '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' } } } - 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', + 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', + } } } - 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, '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 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 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 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, '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', + } + } } - 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, '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' } } } - 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, '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', + } + } } - 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, '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' } + } } - 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', + 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' } } } - 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, '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', + } } } - 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 (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', + } + } } - const dropboxDirectVideoUrl = getDropboxDirectVideoUrl(url) - if (dropboxDirectVideoUrl) { - return { url: dropboxDirectVideoUrl, type: 'video' } + if (parsed && hostMatches(host, 'dropbox.com')) { + const dropboxDirectVideoUrl = toDropboxDirectVideoUrl(parsed) + 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' } + 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' } + } } - 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 (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)) { From 4aa6e9de081b9ddc2a100e0dd17eea5093d627fd Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 11:15:23 -0700 Subject: [PATCH 3/4] fix(rich-markdown-editor): unique widget key per duplicate embed URL Key embed widgets by source + per-source occurrence index so two standalone links to the same URL render as two distinct players instead of collapsing into one, while keeping the key stable across unrelated edits (no iframe reload). --- .../rich-markdown-editor/embed/link-embed.test.ts | 5 +++++ .../rich-markdown-editor/embed/link-embed.ts | 12 +++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) 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 index b7d8f869de1..ad5437066ff 100644 --- 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 @@ -34,6 +34,11 @@ describe('LinkEmbed', () => { 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') 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 index f59356f2cc2..e4214c1c304 100644 --- 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 @@ -35,18 +35,24 @@ function getStandaloneLinkHref(node: ProseMirrorNode): string | 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) { - // Render the player just after the link paragraph, keyed by source so the iframe/video - // DOM is reused across edits instead of reloading on every keystroke. + // Key by source + occurrence index so the iframe/video DOM is reused across unrelated + // edits (no reload on keystroke) while two links to the same URL still render as two + // distinct widgets rather than collapsing into one. + 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: `embed:${embedInfo.type}:${embedInfo.url}`, + key: `${source}:${index}`, }) ) } From 427f8d496c97cace7021583a48d5bb4a4973448c Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 30 Jun 2026 11:22:07 -0700 Subject: [PATCH 4/4] refactor(media-embed): tighten comments and drop a redundant guard - Drop the redundant paragraph type-check in getStandaloneLinkHref (the caller already filters to paragraphs) and rename the param for clarity - Remove an inline comment and a TSDoc sentence that restated logic documented elsewhere --- .../rich-markdown-editor/embed/link-embed.ts | 11 ++++------- packages/utils/src/media-embed.ts | 7 +++---- 2 files changed, 7 insertions(+), 11 deletions(-) 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 index e4214c1c304..b76cf241562 100644 --- 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 @@ -13,11 +13,11 @@ const LINK_EMBED_PLUGIN_KEY = new PluginKey('linkEmbed') * standalone links become media embeds — a link inline within a sentence stays a plain link, * matching how Notion and Linear auto-embed. */ -function getStandaloneLinkHref(node: ProseMirrorNode): string | null { - if (node.type.name !== 'paragraph' || node.childCount === 0) return null +function getStandaloneLinkHref(paragraph: ProseMirrorNode): string | null { + if (paragraph.childCount === 0) return null let href: string | null = null let isStandalone = true - node.forEach((child) => { + paragraph.forEach((child) => { if (!isStandalone) return const linkMark = child.isText ? child.marks.find((mark) => mark.type.name === 'link') @@ -43,9 +43,6 @@ function buildDecorations(doc: ProseMirrorNode): DecorationSet { if (href) { const embedInfo = getEmbedInfo(href) if (embedInfo) { - // Key by source + occurrence index so the iframe/video DOM is reused across unrelated - // edits (no reload on keystroke) while two links to the same URL still render as two - // distinct widgets rather than collapsing into one. const source = `embed:${embedInfo.type}:${embedInfo.url}` const index = sourceCounts.get(source) ?? 0 sourceCounts.set(source, index + 1) @@ -57,7 +54,7 @@ function buildDecorations(doc: ProseMirrorNode): DecorationSet { ) } } - // Paragraphs hold only inline content — never another embeddable paragraph. + // Paragraphs hold only inline content, so there is nothing more to descend into. return false }) return DecorationSet.create(doc, decorations) diff --git a/packages/utils/src/media-embed.ts b/packages/utils/src/media-embed.ts index 27d2b2ed1eb..1432e22198d 100644 --- a/packages/utils/src/media-embed.ts +++ b/packages/utils/src/media-embed.ts @@ -57,10 +57,9 @@ function toDropboxDirectVideoUrl(parsed: URL): string | null { * 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 the - * id-extracting regex runs, so a look-alike host can never produce a trusted-looking - * embed. The generic file-extension fallbacks are intentionally host-agnostic — any - * direct media file URL is embeddable. + * 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)