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