Skip to content

Commit ca0a7ff

Browse files
authored
feat(rich-markdown-editor): live media embeds + shared embed detection util (#5290)
* feat(rich-markdown-editor): live media embeds + shared embed detection util - 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 * fix(media-embed): gate provider detection on parsed hostname 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. * 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). * 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
1 parent 4298e57 commit ca0a7ff

13 files changed

Lines changed: 659 additions & 301 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Extensions } from '@tiptap/core'
22
import Placeholder from '@tiptap/extension-placeholder'
33
import { CodeBlockWithLanguage } from './code-block'
44
import { CodeBlockHighlight } from './code-highlight'
5+
import { LinkEmbed } from './embed/link-embed'
56
import { createMarkdownContentExtensions } from './extensions'
67
import { ResizableImage } from './image'
78
import { RichMarkdownKeymap } from './keymap'
@@ -12,19 +13,23 @@ import { SlashCommand } from './slash-command/slash-command'
1213

1314
interface MarkdownEditorExtensionOptions {
1415
placeholder: string
16+
/** Renders supported media links as live players beneath a standalone link. Off by default. */
17+
embeds?: boolean
1518
}
1619

1720
/**
1821
* The full extension set for the live editor: the content extensions with their React node-view nodes
1922
* injected (code-block language picker, resizable image, mention chip) plus the UI-only extensions —
2023
* `CodeBlockHighlight` (Prism), `SlashCommand` (the `/` block menu), `Mention` (the `@` menu),
21-
* `RichMarkdownKeymap`, `MarkdownPaste`, and `Placeholder`.
24+
* `RichMarkdownKeymap`, `MarkdownPaste`, `Placeholder`, and — when `embeds` is set — `LinkEmbed`
25+
* (media players for standalone links).
2226
*
2327
* Kept separate from `extensions.ts` so those node views (and the block registry the mention chip pulls
2428
* in for brand icons) stay out of the headless round-trip path, which only needs the schema.
2529
*/
2630
export function createMarkdownEditorExtensions({
2731
placeholder,
32+
embeds = false,
2833
}: MarkdownEditorExtensionOptions): Extensions {
2934
return [
3035
...createMarkdownContentExtensions({
@@ -38,5 +43,6 @@ export function createMarkdownEditorExtensions({
3843
RichMarkdownKeymap,
3944
MarkdownPaste,
4045
Placeholder.configure({ placeholder }),
46+
...(embeds ? [LinkEmbed] : []),
4147
]
4248
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { EmbedInfo } from '@sim/utils/media-embed'
2+
3+
/**
4+
* Iframes are rendered at native size then CSS-scaled down so embedded players keep their
5+
* intended layout inside the editor's reading column. Mirrors the note-block renderer.
6+
*/
7+
const EMBED_SCALE = 0.78
8+
const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
9+
10+
const IFRAME_ALLOW =
11+
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
12+
13+
/**
14+
* Build the DOM player for a resolved {@link EmbedInfo}, matching the note-block renderer's
15+
* markup. Returned as a non-editable element so it can back a ProseMirror widget decoration
16+
* without entering the editable content.
17+
*/
18+
export function createEmbedDom(embedInfo: EmbedInfo): HTMLElement {
19+
const container = document.createElement('div')
20+
container.className = 'my-2 block w-full overflow-hidden rounded-md'
21+
container.contentEditable = 'false'
22+
23+
if (embedInfo.type === 'iframe') {
24+
const frame = document.createElement('div')
25+
frame.className = 'block overflow-hidden'
26+
frame.style.width = '100%'
27+
frame.style.aspectRatio = embedInfo.aspectRatio || '16/9'
28+
29+
const iframe = document.createElement('iframe')
30+
iframe.src = embedInfo.url
31+
iframe.title = 'Media'
32+
iframe.allow = IFRAME_ALLOW
33+
iframe.allowFullscreen = true
34+
iframe.loading = 'lazy'
35+
iframe.className = 'origin-top-left'
36+
iframe.style.width = EMBED_INVERSE_SCALE
37+
iframe.style.height = EMBED_INVERSE_SCALE
38+
iframe.style.transform = `scale(${EMBED_SCALE})`
39+
40+
frame.appendChild(iframe)
41+
container.appendChild(frame)
42+
return container
43+
}
44+
45+
if (embedInfo.type === 'video') {
46+
const video = document.createElement('video')
47+
video.src = embedInfo.url
48+
video.controls = true
49+
video.preload = 'metadata'
50+
video.className = 'aspect-video w-full'
51+
container.appendChild(video)
52+
return container
53+
}
54+
55+
const audio = document.createElement('audio')
56+
audio.src = embedInfo.url
57+
audio.controls = true
58+
audio.preload = 'metadata'
59+
audio.className = 'w-full'
60+
container.appendChild(audio)
61+
return container
62+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { Editor } from '@tiptap/core'
5+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
6+
import { createMarkdownEditorExtensions } from '../editor-extensions'
7+
8+
// jsdom lacks elementFromPoint, which TipTap's Placeholder viewport tracking calls on mount.
9+
beforeAll(() => {
10+
document.elementFromPoint = vi.fn(() => null)
11+
})
12+
13+
let editor: Editor | null = null
14+
15+
function editorWith(content: string, embeds = true): Editor {
16+
editor = new Editor({
17+
extensions: createMarkdownEditorExtensions({ placeholder: '', embeds }),
18+
content,
19+
})
20+
return editor
21+
}
22+
23+
afterEach(() => {
24+
editor?.destroy()
25+
editor = null
26+
})
27+
28+
const YOUTUBE_LINK = '<p><a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">watch</a></p>'
29+
30+
describe('LinkEmbed', () => {
31+
it('renders a player beneath a standalone embeddable link', () => {
32+
const view = editorWith(YOUTUBE_LINK).view
33+
const iframe = view.dom.querySelector('iframe')
34+
expect(iframe?.getAttribute('src')).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ')
35+
})
36+
37+
it('renders one player per link when the same URL appears twice', () => {
38+
const view = editorWith(`${YOUTUBE_LINK}${YOUTUBE_LINK}`).view
39+
expect(view.dom.querySelectorAll('iframe')).toHaveLength(2)
40+
})
41+
42+
it('keeps the underlying document a plain markdown link (lossless round-trip)', () => {
43+
const markdown = editorWith(YOUTUBE_LINK).getMarkdown()
44+
expect(markdown).toContain('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
45+
expect(markdown).not.toContain('<iframe')
46+
})
47+
48+
it('does not embed an inline link inside surrounding text', () => {
49+
const view = editorWith(
50+
'<p>see <a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">here</a> now</p>'
51+
).view
52+
expect(view.dom.querySelector('iframe')).toBeNull()
53+
})
54+
55+
it('does not embed a non-embeddable standalone link', () => {
56+
const view = editorWith('<p><a href="https://example.com/article">read</a></p>').view
57+
expect(view.dom.querySelector('iframe')).toBeNull()
58+
})
59+
60+
it('does nothing when the embeds option is disabled', () => {
61+
const view = editorWith(YOUTUBE_LINK, false).view
62+
expect(view.dom.querySelector('iframe')).toBeNull()
63+
})
64+
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { getEmbedInfo } from '@sim/utils/media-embed'
2+
import { Extension } from '@tiptap/core'
3+
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
4+
import { Plugin, PluginKey } from '@tiptap/pm/state'
5+
import { Decoration, DecorationSet } from '@tiptap/pm/view'
6+
import { createEmbedDom } from './embed-dom'
7+
8+
const LINK_EMBED_PLUGIN_KEY = new PluginKey('linkEmbed')
9+
10+
/**
11+
* The href of a paragraph that is a single, whole-text link (a "standalone link"), or null if
12+
* the paragraph is empty, holds non-text content, or mixes a link with other text. Only
13+
* standalone links become media embeds — a link inline within a sentence stays a plain link,
14+
* matching how Notion and Linear auto-embed.
15+
*/
16+
function getStandaloneLinkHref(paragraph: ProseMirrorNode): string | null {
17+
if (paragraph.childCount === 0) return null
18+
let href: string | null = null
19+
let isStandalone = true
20+
paragraph.forEach((child) => {
21+
if (!isStandalone) return
22+
const linkMark = child.isText
23+
? child.marks.find((mark) => mark.type.name === 'link')
24+
: undefined
25+
if (!linkMark) {
26+
isStandalone = false
27+
return
28+
}
29+
const childHref = linkMark.attrs.href as string
30+
if (href === null) href = childHref
31+
else if (href !== childHref) isStandalone = false
32+
})
33+
return isStandalone ? href : null
34+
}
35+
36+
function buildDecorations(doc: ProseMirrorNode): DecorationSet {
37+
const decorations: Decoration[] = []
38+
/** Per-source occurrence count, so repeated embeds of the same URL get distinct, stable keys. */
39+
const sourceCounts = new Map<string, number>()
40+
doc.descendants((node, pos) => {
41+
if (node.type.name !== 'paragraph') return undefined
42+
const href = getStandaloneLinkHref(node)
43+
if (href) {
44+
const embedInfo = getEmbedInfo(href)
45+
if (embedInfo) {
46+
const source = `embed:${embedInfo.type}:${embedInfo.url}`
47+
const index = sourceCounts.get(source) ?? 0
48+
sourceCounts.set(source, index + 1)
49+
decorations.push(
50+
Decoration.widget(pos + node.nodeSize, () => createEmbedDom(embedInfo), {
51+
side: 1,
52+
key: `${source}:${index}`,
53+
})
54+
)
55+
}
56+
}
57+
// Paragraphs hold only inline content, so there is nothing more to descend into.
58+
return false
59+
})
60+
return DecorationSet.create(doc, decorations)
61+
}
62+
63+
/**
64+
* Renders supported media links (YouTube, Vimeo, Spotify, Dropbox, …) as live players beneath a
65+
* standalone link, in both the editing and read-only surfaces. Implemented as widget decorations
66+
* so the underlying document stays a plain markdown link — embeds never enter the schema or the
67+
* serialized markdown, keeping round-trips lossless.
68+
*/
69+
export const LinkEmbed = Extension.create({
70+
name: 'linkEmbed',
71+
72+
addProseMirrorPlugins() {
73+
return [
74+
new Plugin({
75+
key: LINK_EMBED_PLUGIN_KEY,
76+
state: {
77+
init: (_, { doc }) => buildDecorations(doc),
78+
apply: (tr, current) => (tr.docChanged ? buildDecorations(tr.doc) : current),
79+
},
80+
props: {
81+
decorations(state) {
82+
return LINK_EMBED_PLUGIN_KEY.getState(state)
83+
},
84+
},
85+
}),
86+
]
87+
},
88+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import './rich-markdown-editor.css'
3131

3232
const EXTENSIONS = createMarkdownEditorExtensions({
3333
placeholder: "Write something, or press '/' for commands…",
34+
embeds: true,
3435
})
3536

3637
/** Throttle the per-frame full re-parse above this body size so a large streaming file can't saturate the main thread. */

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ export function ChunkEditor({
205205
<div
206206
role='group'
207207
aria-label='Chunk content editor'
208-
className='flex min-h-0 flex-1 cursor-text overflow-hidden'
208+
className='flex min-h-0 flex-1 cursor-text flex-col overflow-hidden'
209209
onClick={(e) => {
210210
if (e.target === e.currentTarget) textareaRef.current?.focus()
211211
}}
@@ -217,7 +217,7 @@ export function ChunkEditor({
217217
{tokenizerOn ? (
218218
<div
219219
ref={tokenizedScrollRef}
220-
className='h-full w-full cursor-default overflow-y-auto whitespace-pre-wrap break-words p-6 font-sans text-[var(--text-body)] text-sm'
220+
className='mx-auto h-full w-full max-w-[48rem] cursor-default overflow-y-auto whitespace-pre-wrap break-words px-8 py-6 font-sans text-[var(--text-body)] text-sm'
221221
>
222222
{tokenStrings.map((token, index) => (
223223
<span
@@ -244,7 +244,7 @@ export function ChunkEditor({
244244
? 'This chunk is synced from a connector and cannot be edited'
245245
: 'Read-only view'
246246
}
247-
className='min-h-0 flex-1 resize-none border-0 bg-transparent p-6 font-sans text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-subtle)]'
247+
className='mx-auto min-h-0 w-full max-w-[48rem] flex-1 resize-none border-0 bg-transparent px-8 py-6 font-sans text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-subtle)]'
248248
disabled={!canEdit}
249249
readOnly={!canEdit}
250250
spellCheck={false}

bun.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/utils/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
"types": "./src/helpers.ts",
2727
"default": "./src/helpers.ts"
2828
},
29+
"./media-embed": {
30+
"types": "./src/media-embed.ts",
31+
"default": "./src/media-embed.ts"
32+
},
2933
"./formatting": {
3034
"types": "./src/formatting.ts",
3135
"default": "./src/formatting.ts"

packages/utils/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export {
1212
} from './formatting.js'
1313
export { noop, sleep } from './helpers.js'
1414
export { generateId, generateShortId, isValidUuid } from './id.js'
15+
export type { EmbedInfo } from './media-embed.js'
16+
export { getEmbedInfo } from './media-embed.js'
1517
export {
1618
filterUndefined,
1719
isPlainRecord,

0 commit comments

Comments
 (0)