Skip to content

Commit 647c498

Browse files
committed
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
1 parent 4298e57 commit 647c498

13 files changed

Lines changed: 606 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: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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('keeps the underlying document a plain markdown link (lossless round-trip)', () => {
38+
const markdown = editorWith(YOUTUBE_LINK).getMarkdown()
39+
expect(markdown).toContain('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
40+
expect(markdown).not.toContain('<iframe')
41+
})
42+
43+
it('does not embed an inline link inside surrounding text', () => {
44+
const view = editorWith(
45+
'<p>see <a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">here</a> now</p>'
46+
).view
47+
expect(view.dom.querySelector('iframe')).toBeNull()
48+
})
49+
50+
it('does not embed a non-embeddable standalone link', () => {
51+
const view = editorWith('<p><a href="https://example.com/article">read</a></p>').view
52+
expect(view.dom.querySelector('iframe')).toBeNull()
53+
})
54+
55+
it('does nothing when the embeds option is disabled', () => {
56+
const view = editorWith(YOUTUBE_LINK, false).view
57+
expect(view.dom.querySelector('iframe')).toBeNull()
58+
})
59+
})
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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(node: ProseMirrorNode): string | null {
17+
if (node.type.name !== 'paragraph' || node.childCount === 0) return null
18+
let href: string | null = null
19+
let isStandalone = true
20+
node.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+
doc.descendants((node, pos) => {
39+
if (node.type.name !== 'paragraph') return undefined
40+
const href = getStandaloneLinkHref(node)
41+
if (href) {
42+
const embedInfo = getEmbedInfo(href)
43+
if (embedInfo) {
44+
// Render the player just after the link paragraph, keyed by source so the iframe/video
45+
// DOM is reused across edits instead of reloading on every keystroke.
46+
decorations.push(
47+
Decoration.widget(pos + node.nodeSize, () => createEmbedDom(embedInfo), {
48+
side: 1,
49+
key: `embed:${embedInfo.type}:${embedInfo.url}`,
50+
})
51+
)
52+
}
53+
}
54+
// Paragraphs hold only inline content — never another embeddable paragraph.
55+
return false
56+
})
57+
return DecorationSet.create(doc, decorations)
58+
}
59+
60+
/**
61+
* Renders supported media links (YouTube, Vimeo, Spotify, Dropbox, …) as live players beneath a
62+
* standalone link, in both the editing and read-only surfaces. Implemented as widget decorations
63+
* so the underlying document stays a plain markdown link — embeds never enter the schema or the
64+
* serialized markdown, keeping round-trips lossless.
65+
*/
66+
export const LinkEmbed = Extension.create({
67+
name: 'linkEmbed',
68+
69+
addProseMirrorPlugins() {
70+
return [
71+
new Plugin({
72+
key: LINK_EMBED_PLUGIN_KEY,
73+
state: {
74+
init: (_, { doc }) => buildDecorations(doc),
75+
apply: (tr, current) => (tr.docChanged ? buildDecorations(tr.doc) : current),
76+
},
77+
props: {
78+
decorations(state) {
79+
return LINK_EMBED_PLUGIN_KEY.getState(state)
80+
},
81+
},
82+
}),
83+
]
84+
},
85+
})

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,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { getEmbedInfo } from './media-embed'
3+
4+
describe('getEmbedInfo', () => {
5+
it('maps YouTube watch/short/embed URLs to the embed iframe', () => {
6+
const expected = { url: 'https://www.youtube.com/embed/dQw4w9WgXcQ', type: 'iframe' }
7+
expect(getEmbedInfo('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toEqual(expected)
8+
expect(getEmbedInfo('https://youtu.be/dQw4w9WgXcQ')).toEqual(expected)
9+
expect(getEmbedInfo('https://www.youtube.com/embed/dQw4w9WgXcQ')).toEqual(expected)
10+
})
11+
12+
it('maps Vimeo and Spotify URLs with their aspect ratios', () => {
13+
expect(getEmbedInfo('https://vimeo.com/123456')).toEqual({
14+
url: 'https://player.vimeo.com/video/123456',
15+
type: 'iframe',
16+
})
17+
expect(getEmbedInfo('https://open.spotify.com/track/abc123')).toEqual({
18+
url: 'https://open.spotify.com/embed/track/abc123',
19+
type: 'iframe',
20+
aspectRatio: '3.7/1',
21+
})
22+
})
23+
24+
it('treats bare media file extensions as native video/audio', () => {
25+
expect(getEmbedInfo('https://cdn.example.com/clip.mp4')).toEqual({
26+
url: 'https://cdn.example.com/clip.mp4',
27+
type: 'video',
28+
})
29+
expect(getEmbedInfo('https://cdn.example.com/sound.mp3')).toEqual({
30+
url: 'https://cdn.example.com/sound.mp3',
31+
type: 'audio',
32+
})
33+
})
34+
35+
it('returns null for non-embeddable URLs', () => {
36+
expect(getEmbedInfo('https://example.com/article')).toBeNull()
37+
expect(getEmbedInfo('not a url')).toBeNull()
38+
})
39+
40+
describe('Dropbox', () => {
41+
it('rewrites a Dropbox video share link to a direct streamable URL', () => {
42+
expect(getEmbedInfo('https://www.dropbox.com/s/abc/clip.mp4?dl=0')).toEqual({
43+
url: 'https://dl.dropboxusercontent.com/s/abc/clip.mp4',
44+
type: 'video',
45+
})
46+
})
47+
48+
it('handles non-www and scheme-less Dropbox hosts', () => {
49+
expect(getEmbedInfo('https://m.dropbox.com/s/abc/clip.mov')).toEqual({
50+
url: 'https://dl.dropboxusercontent.com/s/abc/clip.mov',
51+
type: 'video',
52+
})
53+
expect(getEmbedInfo('dropbox.com/s/abc/clip.webm')).toEqual({
54+
url: 'https://dl.dropboxusercontent.com/s/abc/clip.webm',
55+
type: 'video',
56+
})
57+
})
58+
59+
it('does not apply the Dropbox direct-link rewrite to look-alike hosts', () => {
60+
// Look-alike hosts fall through to the generic video handler with their
61+
// original (untrusted) host intact — never rewritten as if trusted Dropbox.
62+
expect(getEmbedInfo('https://dropbox.com.evil.com/clip.mp4')?.url).not.toContain(
63+
'dropboxusercontent.com'
64+
)
65+
expect(getEmbedInfo('https://evil.com/?x=dropbox.com/clip.mp4')?.url).not.toContain(
66+
'dropboxusercontent.com'
67+
)
68+
})
69+
})
70+
})

0 commit comments

Comments
 (0)