Embed
The embed plugin adds Notion's embed block, a provider's player (YouTube, Vimeo, Loom, Figma, CodePen, Spotify) in a sandboxed frame, from an allowlist you can extend.
createEmbedPlugin() adds Notion’s embed block: an “Embed a link” panel (your empty snippet, createMediaEmptyPlugin({ empty })) until the block has a link, then the provider’s player in a sandboxed iframe, with an editable caption. It is not a default plugin: list it.
<script lang="ts">
import { Edytor, createEmbedPlugin } from 'edytor';
</script>
<Edytor plugins={[createEmbedPlugin()]} />
A slash menu then lists “Embed” under Media, and pasting a link a provider plays on an empty line offers to embed it (Pasting a link on an empty line).
Options
createEmbedPlugin(options) takes an EmbedPluginOptions:
providers?readonly EmbedProvider[]
The allowlist. Default EMBED_PROVIDERS. A link none of them plays shows no frame.
readonly EmbedProvider[]menu?Snippet<[UrlPasteController]>
The menu a URL pasted on an empty line opens (Link, Embed, Bookmark); the view's one menu takes the first menu its embed and bookmark plugins pass; without one, the link stays and nothing shows. Keep its ARIA with {@attach controller.popup} and {@attach controller.option(index)}. See Menus and handles.
Snippet<[UrlPasteController]>fallback?Snippet<[EmbedFallbackPayload]>
What an embed shows for a link no allowed provider plays (a collaborator may store any), replacing the plain link. The first embed plugin listed in a view gives it.
Snippet<[EmbedFallbackPayload]>fallback receives an EmbedFallbackPayload: block (undefined in a suggestion’s preview), url (an http(s) link) and link, the attachment of your a (target="_blank", rel="noopener noreferrer nofollow"), as a menu’s row takes its own; render its href={embed.url} yourself. It is never a frame: only an allowlisted provider plays.
<script lang="ts">
import { Edytor, createEmbedPlugin, type EmbedFallbackPayload } from 'edytor';
const embeds = createEmbedPlugin({ fallback });
</script>
{#snippet fallback(embed: EmbedFallbackPayload)}
<a class="unplayable" href={embed.url} {@attach embed.link}>Open {new URL(embed.url).host}</a>
{/snippet}
<Edytor plugins={[embeds]} />
Providers
Only an allowlisted provider plays. EMBED_PROVIDERS holds:
| Provider | Links | Player |
|---|---|---|
| YouTube | youtube.com/watch?v=…, youtu.be/…, /shorts/…, /embed/…, /live/… |
www.youtube-nocookie.com/embed/<id>, with a t start time |
| Vimeo | vimeo.com/<id>, player.vimeo.com/video/<id> |
player.vimeo.com/video/<id> |
| Loom | loom.com/share/<id>, loom.com/embed/<id> |
www.loom.com/embed/<id> |
| Figma | figma.com/design/… (also file, proto, board, slides, deck) |
www.figma.com/embed?url=… |
| CodePen | codepen.io/<user>/pen/<id> |
codepen.io/<user>/embed/<id> |
| Spotify | open.spotify.com/track/<id> (also albums, playlists, episodes, shows, artists) |
open.spotify.com/embed/<kind>/<id> |
A provider is an EmbedProvider: a name (the frame’s title), embed(url) that answers the player URL for a link of the provider or null, and an optional aspectRatio (CSS, default 16 / 9) or fixed height in pixels. Add your own after the bundled ones:
import { createEmbedPlugin, EMBED_PROVIDERS, type EmbedProvider } from 'edytor';
const miro: EmbedProvider = {
name: 'Miro',
embed: (url) => {
const [kind, board] = url.pathname.split('/').filter(Boolean);
return url.hostname === 'miro.com' && kind === 'app' && /^[\w=-]+$/.test(board ?? '')
? `https://miro.com/app/live-embed/${board}/`
: null;
}
};
export const embed = createEmbedPlugin({ providers: [...EMBED_PROVIDERS, miro] });
Match the host exactly (never a suffix or a substring) and check every id you copy into the player URL. embedSourceOf(link, providers?) answers { src, provider } for a link, or null.
Security
- The frame’s
srcis always the player URL the provider derives from the stored link, never the stored value: a collaborator or a pasted document can store anyurl, and a link no provider plays renders a plain link instead of a frame. - A player URL must be
https:on another origin than your page’s; a provider answering anything else is refused. The frame hasallow-scriptsandallow-same-origin, so a player on your page’s origin, or on one that can script it, could remove its own sandbox: a custom provider must never answer one. - A suggestion’s preview has no view to read the allowlist from, so it never plays: an embed there shows its link.
- The stored link must be an absolute
http:/https:URL with a host, read by the link sanitizer’s rules (control characters stripped, as the browser’s URL parser does):safeWebUrl(value). - The frame has
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-presentation"(EMBED_SANDBOX): the player runs in its own origin and may open its pages in a new tab, but never navigates your page, opens dialogs, downloads or submits forms. It never hassrcdoc. allow="fullscreen; picture-in-picture; encrypted-media; clipboard-write"(EMBED_ALLOW),referrerpolicy="strict-origin-when-cross-origin"(YouTube refuses a player without its embedder’s origin) andloading="lazy".
The kind
| Kind | Element | Data | Preset |
|---|---|---|---|
embed |
figure |
{ url: string } |
Embed (⧉, group Media, keywords iframe, youtube, vimeo…) |
The block is void like the image: it takes no children and is never merged, and its only text is the caption. The command id is block.embed. Its role is in defaultSemantics, so the room and headless documents treat it as void too.
In the empty block, empty.embed() (the demo’s link field, “Paste the link…”) embeds a link a provider plays as one data write, and sets failed for another (the demo says “No embed provider plays that link.”). A readonly view’s controller is readonly: show a passive placeholder.
Clipboard
- Copying exports
<figure><iframe src="<player>" title="…" sandbox="…" allowfullscreen></iframe><figcaption>caption</figcaption></figure>, or a link for a URL no provider plays; the plain text is the link, then the caption. - Pasting HTML imports an
iframewhosesrca provider plays (bare, or in afigurewhosefigcaptionbecomes the caption) as an embed of thatsrc. Any otheriframeis dropped, and nothing is imported from aniframewhen the plugin is not listed.
Styling
| Selector | Matches |
|---|---|
[data-edytor-type='embed'] |
The figure |
[data-edytor-embed] |
The frame’s wrapper (its aspect ratio or height, which the plugin sets) |
[data-edytor-embed-unsupported] |
The link (your fallback) shown for a URL no provider plays |
[data-edytor-media-empty] |
Where the empty state (your empty snippet) renders |
The Notion theme styles the blocks (the plugin keeps only the frame’s size: its provider’s aspect ratio or height); the empty state’s look is your markup’s (the demo’s MediaEmpty.svelte, shared by every media block: bookmark, file, video, audio).