Skip to main content

developing-content-embeds

Use when building or changing an embed in a text note — an included note, an attachment embed such as a canvas drawing (`CanvasDrawing`), or a source embed such as a Mermaid diagram (`MermaidDiagram`, `SOURCE_EMBED_TYPES`) — or adding an embed kind. Covers the storage forms (`data-note-id`, `data-attachment-id`, inline `<pre><code class="language-…">`), the editor plugin (`packages/ckeditor5/src/plugins/content_embed/`: `content_embed.ts`, `inline_source.ts`, toolbar, box-size dropdown), the host contract (`contentEmbed.loadAttachment()`, `contentEmbed.loadInline()`), the client box (`ContentEmbed`, `text/utils.ts`), the content component contract (`useEditableEmbed()`, `ContentEmbedToolProvider`, Editable, display modes, fullscreen), reusing the note type's component as canvas and Mermaid do, the read-only paths (AI chat, Markdown preview, print, share), and traps hit (detached content, CodeMirror read-only fixed at creation, the CKEditor `:first-child` separator rule, untitled box sizes, class clashes).

Source facts

Repository
TriliumNext/Trilium
Last source activity
November 1, 2026 at 21:09
Detected SKILL.md language
English
Stars
38,663
Forks
2,611

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
developing-content-embeds
description
Use when building or changing an embed in a text note — an included note, an attachment embed such as a canvas drawing (`CanvasDrawing`), a source embed such as a Mermaid diagram (`MermaidDiagram`, `SOURCE_EMBED_TYPES`), or a YouTube video (`ContentEmbedVideo`) — or adding an embed kind. Covers the storage forms (`data-note-id`, `data-attachment-id`, inline `<pre><code class="language-…">`), the editor plugin (`packages/ckeditor5/src/plugins/content_embed/`: `content_embed.ts`, `inline_source.ts`, toolbar, box-size dropdown), the host contract (`contentEmbed.loadAttachment()`, `contentEmbed.loadInline()`), the client box (`ContentEmbed`, `text/utils.ts`), the content component contract (`useEditableEmbed()`, `ContentEmbedToolProvider`, Editable, display modes, fullscreen), reusing the note type's component as canvas and Mermaid do, the read-only paths (AI chat, Markdown preview, print, share), and traps hit (detached content, CodeMirror read-only fixed at creation, the CKEditor `:first-child` separator rule, untitled box sizes, class clashes).
# Developing content embeds An embed is a box in a text note that shows content other than text: another note, an attachment of the note, or a source kept in the note itself. The box (title row, size, caption, fullscreen, toolbar, menu) is shared; what it shows is a **content component** chosen by the kind of content. Two components show what a fully featured embed looks like: - **Canvas drawing** — `apps/client/src/widgets/type_widgets/canvas/CanvasDrawing.tsx`. Excalidraw scene in an attachment (`CANVAS_ATTACHMENT_MIME`). A picture while not edited, Excalidraw while Editable is on. Its own tools in the embed toolbar (`CanvasEmbedTools.tsx`). - **Mermaid diagram** — `apps/client/src/widgets/type_widgets/mermaid/MermaidDiagram.tsx`. A *source embed*: the source sits inline as a code block, or in an attachment (`MERMAID_ATTACHMENT_MIME`); one component serves both. Source, split or preview view; zoom and syntax-help tools. **The rule both follow: the embed is built from the note type's own component, not beside it.** `CanvasDrawing` renders `CanvasEditor` from the canvas note (`canvas/Canvas.tsx`); `MermaidDiagram` renders `SplitLayout` from `helpers/SplitEditor.tsx` and `SvgPreview` / `SvgZoomControls` / `useSvgRender()` / `useSvgZoom()` from `helpers/SvgSplitEditor.tsx`, the same parts the Mermaid note uses. A first version of the Mermaid embed reimplemented its panes, viewer and error box and drifted at once (a raw error box instead of the note's error card, no divider). When the note type's component is bound to a note (labels, `noteContext`, `EditableCode`), **extract the note-independent part into an exported component in the same module** and have the note type use it too — `SplitEditor` → `SplitLayout` + `useMountedPanes()` is the worked example. Only the parts that are about storage stay separate (the embed's source pane is `CodeEmbedEditor` bound to a `ContentEditor`, the note's is `EditableCode` bound to the note). ## The storage forms What the text note saves, and what the editor model holds (`contentEmbed` element): | Form | Saved HTML | Model attributes | |---|---|---| | Included note | `<figure class="include-note" data-note-id="…" [data-block="…"]>` | `noteId`, `block` | | Attachment | `<figure class="include-note" data-attachment-id="…">` | `attachmentId` | | Inline source | `<pre data-box-size=… [data-display-mode=…] data-hide-title data-editable><code class="language-mermaid">…</code></pre>` | `language`, `source` | | Video | `<section class="link-embed" data-url=… data-embed-type="youtube" … [data-position=…]>` | the link preview fields: `url`, `embedType`, `title`, `image`, … | | Later kind | `<section data-trilium-embed="<kind>" …>fallback</section>` | `unknownContentEmbed`: `embedAttributes`, `embedContent` | All three carry `boxSize`, `hideTitle`, `editable`, `displayMode` (`data-box-size`, `data-hide-title`, `data-editable`, `data-display-mode`) and the resized size as `--include-note-width` / `--include-note-height` in `style`. The figure keeps an optional `<figcaption>`. **Captions of an embed whose element is not a `<figure>`** (`_defineCaptionedEmbedConverters()` in `content_embed.ts`). While such an embed has a caption, the note saves its element in a `<figure class="captioned-content-embed">` (`CAPTIONED_CONTENT_EMBED_CLASS`), followed by the `<figcaption>`, and without a caption it saves the bare element again. Every kind gets this without code of its own: a `low` data downcast listener wraps whatever element the kind's converter bound, a data mapper hook puts the caption after that element, and an upcast at `high` converts the element and turns the `<figcaption>` into the `caption` of the embed (`isEmbedCaptionToggleable()`), or into text after content that takes none. The figure is bound to the embed as well, view to model only, so the mapper counts it as one node; the embed still maps to its element, where the attribute converters (width, position) write. A kind opts in through `hasCaptionSupport()`; only inline source embeds stay out. Read-only views replace the figure with the box and move the caption into it (`replaceWithEmbedBox()` in `content_renderer_text.ts`); share pages move `style`, `data-box-size` and `data-position` from the element to the figure (`moveBoxToCaptionedFigure()`), which the share CSS places; Markdown export keeps the figure as raw HTML. **An embed whose caption has the selection looks selected**, as one whose player or editor has the focus (which selects it): `markEmbedOfSelectedCaption()` adds `ck-widget_selected` after `Widget` marks the selection and takes it away on `cleanSelection`. `Widget` itself marks only widgets inside the selection. - **Inline source** (`inline_source.ts`, `ContentEmbedInlineSource`) keeps the document readable elsewhere: the note exports to Markdown as a fenced `mermaid` block, other apps render it, and undo in the note undoes source edits through the shared undo history (see _Undo history_ below). `setSource()` writes outside the undo stack and does **not** redraw the embed, which would remount its editor. The plugin hands out one `InlineEmbedSource` per embed element, which keys its history. It is converted only for languages in `contentEmbed.inlineLanguages` (from `SOURCE_EMBED_TYPES` in `text/config.ts`); a code block of another language stays a code block. - **Video** (`video_embed.ts`, `ContentEmbedVideo`) is the _Embed_ display mode of a link preview whose URL has a video (`getLinkPreviewVideoId()` in commons). It keeps the saved form of the link preview, so released versions still play it, and adds `data-box-size`, `data-hide-title`, `data-position` and `--include-note-width`. Its sizes are Medium and Full (`VIDEO_EMBED_BOX_SIZES`); `readVideoEmbedBox()` reads a video saved before as Full and untitled, and `createVideoEmbed()` makes a new one Medium at `getVideoMediumWidth()` (20em high), which the box size command sets again. Height follows width (`isHeightResizable` is per element). A size change only restyles the figure: the `attribute:boxSize` converter skips `reloadEmbeddedContent()` for a video, since a new player stops the playback. A caption is saved in the captioned figure (above). The video toolbar also shows while the caret is in the caption (`findRelatedVideo()`), and its Copy address (URL) and Display act on the video through `ChangeLinkDisplayCommand._getTarget()`: the selected preview, else, unless the caret is on a link, the video of the caption (`getCaptionedEmbed()`). `getSelectedLinkWidget()` itself reads only the selected element, as the link balloon shares the command and Ctrl+K in a caption must edit the caption's own link. `rebuildLinkWidget()` copies the caption into a preview that keeps its shape; a Card, Icon & text or Text link has no caption, so `keepCaptionText()` moves its content into a paragraph after the video. The shape is the link preview field `aspectRatio` (`data-aspect-ratio="9/16"`), which the server reads from oEmbed for the video's `/shorts/` address (the watch address of a Short answers 16:9). `getVideoAspectRatio()` validates it; every box that draws a player (the editing figure, `applyVideoEmbeds()`, `renderLinkEmbedHtml()`, the share renderer's `setVideoBox()`) writes it as `--video-aspect-ratio`, which the player's `aspect-ratio` and the 70vh cap of Full read. Only Full is capped; Medium keeps its saved width. `setVideoBox()` also writes `data-box-size` on the shared section, Full for a video saved before box sizes. A preview without one is 16:9. A new link preview field goes into `LINK_PREVIEW_DATA_ATTRIBUTES` and `readLinkPreviewData()`, both `LinkEmbedMetadata` types (commons and the editor's `augmentation.ts`) and the client's `fetchMetadata()`; `META_KEYS`, the schemas and the converters follow the map. It renders through `contentEmbed.loadVideo()` (`loadVideoEmbed()`, whose title row opens the embed menu only in the editor) and has a toolbar of its own (`videoEmbed` in `content_embed_toolbar.ts`): Copy address (URL), the link preview's Display dropdown and ⋮. The caption is toggled from ⋮ only. Its ⋮ menu (`getVideoItems()`, picked by the `url` of `ContentEmbedState`) puts _Open externally_ (`openUrlExternally()` in `services/open.ts`) above the embed's commands and _Delete_ below. `ReadOnlyText`'s `applyVideoEmbeds()` swaps the section for the same box. The box has no backdrop (`DIRECT_CONTENT_TYPES`), so one click plays and selects. The facade takes the focus on click and `playVideo()` hands it to the player once loaded: Chromium blurs the editor when a focused element is removed. A click into a frame fires no focus event in the page, so `fireFrameFocusEvents()` in `content_embed.ts` fires `focus` at a frame in an embed that a window `blur` leaves focused, and `blur` on the next window `focus`; the embed's own `focus` listener and `FocusTracker` do the rest. A click from one frame into another fires no window event either, so after a window `blur` one shared interval (`watchFrameFocus()`, 200 ms, for every editor's check in `frameFocusChecks`) reads `document.activeElement` until no frame has the focus or the window has it back. These synthetic events neither bubble nor fire `focusin`/`focusout`, so the box's `useFocusWithin()`, which draws the `.include-note-body.active` outline, also listens to `focus`/`blur` in the capture phase. `createLinkPreview()` in `link_embed_commands.ts` is the one place that picks `linkMention`, `linkEmbed` or a video embed for a display mode. The YouTube video prompt (`youtube_video.ts`) inserts the player only once `fetchMetadata()` has returned, with no link meanwhile: its `onInsert()` rejects on unresolved metadata, and the host's prompt keeps Insert disabled while it waits and shows an error toast when it rejects. - **Legacy blocks** — a `<pre>` without `data-box-size` predates the embed (the old Mermaid plugin). `getModeOfSavedView()` loads it editable, in the view of its `data-trilium-display-mode` or split, at Medium. A saved embed keeps exactly what it has. - **Later kind** (`CONTENT_EMBED_KIND_ATTRIBUTE` in `packages/commons/src/lib/content_embeds.ts`) is the form for any new kind that is neither a note/attachment reference nor a code block, so that an older version keeps it instead of dropping it on save, without a sync version bump. `UnknownContentEmbed` (`unknown_embed.ts`) keeps the section raw through `registerRawContentMatcher()`, renders it as an `.include-note` box that the host fills through `contentEmbed.loadUnknown()` (`ContentEmbedError`, the box of a missing note, with the `bx-error` icon), and writes it back with its `class` and `data-*` attributes (anything else, `style` included, is dropped) and its content, sanitized by the host's `contentEmbed.sanitizeHtml`. Its upcast runs at `high`, before General HTML Support. A new kind must follow the contract older versions rely on: - Settings go in `data-*` attributes on the section, never in `style` (the import sanitizer allows `--include-note-*` only on `figure` and `pre`). - A note reference is `data-note-id` on the section itself, which `findIncludeNoteLinks()` turns into the `includeNoteLink` backlink. - An attachment reference is `data-attachment-id` on any element (or an `api/attachments/<id>/image` URL), which `checkImageAttachments()` keeps from erasure and rewrites when the attachment is copied. - The content is a static fallback (a link, a picture, the source in a `<pre>`): old read-only views, share pages and Markdown export show it. Read-only views never show the fallback either: `emptyUnknownEmbeds()` (`content_renderer_text.ts`) empties each section into an `.include-note` box, keeping only the kind, `data-box-size` and the block ID. `ReadOnlyText` then mounts the same error box (`loadUnknownEmbed()`), and `postProcessRichContent()` leaves the box empty, as it leaves a missing note's. Share pages replace the section, and an include of a missing or unshared note or attachment, with `<div class="include-note-unavailable">` and a generic message (`replaceWithUnavailable()` in core's `share/content_renderer.ts`), as the page is public. The version that adds a kind registers its converter above `high`. If that converter needs the section's children, narrow `ENVELOPE` in `unknown_embed.ts` to leave the kind out: the raw matcher removes the children before any converter runs. - **Attachment** suits large or binary content and content with its own save cycle (Excalidraw). The attachment's content type comes from `getFileContentType()` in `packages/commons/src/lib/notes.ts` (`canvasDrawing`, `mermaidDiagram`), which `getRenderedContent()` dispatches on. ## How a box gets its content 1. **Editor plugin** (`packages/ckeditor5/src/plugins/content_embed/`). `content_embed.ts` owns the model, the converters, the commands (`contentEmbedBoxSize`, `toggleContentEmbedTitle`, `toggleContentEmbedCaption`, `toggleContentEmbedEditable`, `contentEmbedDisplayMode`, `convertEmbedToLink`, `deleteContentEmbed`), the box-size rules and observables such as `isSelectedEmbedInline`. `deleteContentEmbed` serves any kind: `getDeleteEmbedGroup()` in `menus/link_context_menu.ts` ends the ⋮ menu of a note, an attachment, a video and an extended code block with it. Other inline sources (Mermaid) do not offer it. `content_embed_toolbar.ts` lists the toolbar items; `content_embed_tools.ts` turns the content's tools into buttons; `content_embed_box_size_dropdown.ts` the size menu. The editing view renders an `.include-note-wrapper` and asks the **host** to fill it. 2. **Host contract** (`ContentEmbedHost` in `content_embed.ts`, read from the editor config as `contentEmbed`): `loadNote()`, `loadAttachment()`, `loadInline(source, wrapper, boxSize, { isFocused })`, `loadUnknown()`, `focusAttachment()`, `openMenu()`, `getTools()`, `enter()` and `getAttachmentHref()`. `EditableText.tsx` implements them as `contentEmbedHandlers`, which `CKEditor.tsx` hands to `buildConfig()` through a getter. `sanitizeHtml()` is set in `buildConfig()` itself. The plugin never imports client code. 3. **Box** (`text/utils.ts`). `loadEmbeddedAttachment()` / `loadInlineEmbed()` render the content with `content_renderer`, wrap it in `ContentEmbed` (`text/ContentEmbed.tsx`: title row, fullscreen, exit-fullscreen overlay) and `mountEmbedBox()`. Content that left the page while it loaded is disposed; `watchContentEmbeds()` disposes boxes removed later. 4. **Content** (`services/content_renderer.ts`). `renderCanvasDrawing()` and `renderSourceEmbed()` mount the component through `mountInteractiveWidget()` when `options.interactive`, with `getContentEditor()` as its `ContentEditor`; otherwise a static rendering (the drawing's picture). Lazy modules are listed in `CONTENT_RENDERERS.file` so read-only pages and the share theme never load the editors. ## The content component contract A content component receives the content and an optional `ContentEditor` (`canEdit()`, `getUnsavedContent()`, `scheduleSave()`, `release()`; no editor = read-only). Source embeds receive `SourceEmbedProps` (`content`, `editor`) whichever storage the source has: `InlineSourceEmbed` and `AttachmentSourceEmbed` in `text/SourceEmbed.tsx` adapt the two. - **`useEditableEmbed(rootRef, { editor, note, tools, focusTarget })`** (`text/editable_embed.tsx`) returns `canEdit` (an editor that can save, no `#readOnly`, database writable) and `isEditing` (`canEdit` and the embed's Editable toggle on). It registers `tools` while `canEdit` and moves the focus to `focusTarget` when the box takes it. Nested embeds stay read-only (`NestedEmbedContext`). - **Toolbar tools** — a `ContentEmbedToolProvider` (`text/content_embed_tools.ts`): `getTools()`, `execute(id)`, `subscribe()`, plus - `hasEditableFlag` — shows the Editable toggle; the embed carries `data-editable="true"`. - `hasDisplayModes` — shows Source/Split/Preview; the embed carries `data-display-mode`. - `hasPositions` — gives an inline source the Position submenu of the ⋮ menu and the toolbar's position dropdown; the embed carries `data-position`. A note, attachment or video embed has the submenu without it (`hasEmbedPositions()`), and the dropdown only with it. - `hiddenToolbarItems` — moves box size, title, convert-to-link or Editable from the toolbar to the ⋮ menu (an extended code block hides box size and Editable). - `boxSizeIcon` — an SVG the box size dropdown shows in place of the size name, which moves to its tooltip (`Box size: %0`). Code and Markdown embeds set it even while read-only (`hasEditableFlag: false`), so every one of them shows the icon; code embeds through `useContentEmbedMenus()`. - `getMenuItems()` — items of the ⋮ menu after Editable, in a group of their own
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub