Skip to content

Blob Editor

The Blob Editor is the authoring surface for a blob body. It is a contenteditable editor that stores content as text. Formatting is markdown syntax, and rich inline items (link previews, mentions, inserted media) are embed tokens. Apps extend it with plugins, which can hook keys and input, draw UI over the text, and use app services through a host object.

Where it is used

Three components work together. Most apps mount the outer one:

can edit

read-only

BlobPage (full page)

BlobView

picks edit vs read-only from canEditBlob(blob)

BlobSurface (modal · dock · expand)

BlobContentEdit

status bar, Ctrl/Cmd+S, Escape, optional own header

BlobContentView

rendered body + metadata

BlobContentEditor

contenteditable + serializer + plugins

ComponentRole
BlobViewHost-facing wrapper. Shows the editor when the viewer can edit the blob, and the read-only view when they cannot (there is no edit/preview toggle). Also owns the media hero and the files dock. Builds the EditorHost that plugins receive.
BlobContentEditWraps the editor with the status bar (characters, lines, last saved), the Ctrl/Cmd+S and Escape keys, and an optional title and Save row (showHeader).
BlobContentEditorThe editor itself. Use it directly when you need an editor outside a blob surface.
BlobContentViewThe read-only half. It renders the same content, including embed tokens, through the viewer for the blob’s type hint.

Usage

Inside a blob surface. Give plugins and host services to BlobView. It sends them through BlobContentEdit to the editor:

import type { Blob, BlobsInstance } from '@blobs/sdk';
import { BlobView, createLinkLookupPlugin, createToolbarPlugin } from '@blobs/sdk-solid';
import { useNavigate } from '@solidjs/router';
// Create plugins once (module level or a memo), not on every render.
const plugins = [createToolbarPlugin(), createLinkLookupPlugin()];
// setBlob / save / me come from your app's state.
function BlobBody(props: { blob: Blob; sdk: BlobsInstance }) {
const navigate = useNavigate();
return (
<BlobView
blob={props.blob}
sdk={props.sdk}
onContentChange={(content) => setBlob({ ...props.blob, content })}
onSave={(blob) => save(blob)}
editorPlugins={plugins}
editorHost={{ navigate, currentUserId: me.id }}
/>
);
}

BlobView fills in sdk, network, blob, files, folders, uploadFile, openFile (from onFileClick) and notify (from onNotify). Any value in editorHost replaces the value that BlobView supplies.

Standalone.

import { BlobContentEditor } from '@blobs/sdk-solid';
<BlobContentEditor
content={initialText}
onChange={(text) => setDraft(text)}
placeholder="Write something…"
plugins={plugins}
host={{ sdk, notify: toast }}
/>

Props

BlobContentEditor

NameTypeDefaultDescription
contentrequiredstring—

Initial content string. Read once on mount (the editor is uncontrolled).

contentTypestring—

Content format: text (default), markdown, code, json, mixed. Picks the serializer and the paste behaviour (text/code paste as plain text).

onChange(content: string) => void—

Serialized content, emitted on blur and on ctx.commit() — only when it differs from the last emitted value.

onBlur() => void—

Called on blur

onFocus() => void—

Called on focus

placeholderstring—

Placeholder text when empty

readOnlyboolean—

Whether the editor is read-only

serializerConfigSerializerConfig—

Custom serializer configuration

classstring—

Custom class name

autoFocusboolean—

Auto-focus on mount

pluginsreadonly EditorPlugin[]—

Optional input plugins. Each plugin opts into a set of triggers (space, enter, paste, blur) and receives a context that can read/mutate the contenteditable DOM. The built-in createLinkPreviewPlugin swaps pasted URLs for rich preview cards; consumer apps can add their own.

hostEditorHost—

App services for plugins (SDK, current blob and its files, upload, open-file, navigation). Read live — plugins always see the current value. See EditorHost.

modeEditorEngine—

Editing engine: raw (default) edits the stored text with markdown syntax visible; rich renders the markdown and edits it in place. Read once on mount — remount to switch.

Source: components/core/BlobContentEditor/BlobContentEditor.tsx:83

BlobContentEdit

Edit-mode body of a blob: wraps BlobContentEditor with loading / error states, an optional own header (title + Save) and a status bar (characters, lines, last saved). Rendered by BlobView when the viewer can edit the blob.

NameTypeDefaultDescription
blobrequiredBlob—

The blob being edited. Its content seeds the editor once on mount; on Save the component writes the latest content (and, when showing its own header, the title) back onto this object before onSave.

onContentChange(content: string) => void—

Latest serialized content — fired when the editor emits (on blur, on a plugin ctx.commit(), and before a keyboard save). Hosts use it to mark the blob dirty. Not fired per keystroke.

onSave() => Promise<void>—

Persist the blob. Called by Save and Ctrl/Cmd+S; awaited for the saving state.

onCancel() => void—

Leave edit mode without saving (own-header Cancel button / Escape).

onAddChild() => void—

Create a child blob (shown as an action when supplied).

isDirtyboolean—

There are unsaved edits — drives the unsaved indicator and Save state.

loadingboolean—

Show a loading state instead of the editor.

errorstring—

Show this error instead of the editor.

currentUserIdstring—

Signed-in user id (reserved for author-specific affordances).

hideCancelboolean—

Hide the cancel button (for always-edit mode)

pluginsreadonly EditorPlugin[]—

Editor input plugins. Pass-through to BlobContentEditor. The web-client wires createLinkPreviewPlugin here when smart link previews are enabled in user settings.

editorHostEditorHost—

App services for editor plugins. Pass-through to BlobContentEditor.

editorModeBlobEditorMode—

Editing engine. auto (default): blobs with type_hint: markdown edit RENDERED (rich engine), everything else edits as raw text. raw / rich force one engine. See BlobContentEditor mode.

showHeaderboolean—

Render the editor's own title + actions header.

Hosts that supply their own blob header (the full page and the dashboard modal both now do) pass false so the title and Save aren't duplicated. Defaults true so any other consumer is unaffected.

onEscape() => void—

Escape pressed inside the editor. When supplied it REPLACES the default cancel behaviour — the full page uses it to offer a rollback while staying in the editor, rather than leaving edit mode outright.

Source: components/core/BlobContentEdit/BlobContentEdit.tsx:15

For BlobView, the editor-related props are editorPlugins (sent through as plugins), editorHost (a Partial<EditorHost> that replaces the services BlobView supplies), editorMode (see Editing engines), showEditorHeader, onEscape, onContentChange and onSave. BlobSurface and BlobOverlay forward editorMode too.

Editing engines

The editor has two engines. Both store the same thing — markdown text — and both run the same extensions.

EngineWhat the author seesFormattingPicked when
rawThe stored text. Markdown characters (**, #, - ) are visible.The toolbar writes markdown syntax around the selection.mode="raw", or editorMode="auto" and the blob’s type_hint is not markdown.
richThe markdown rendered — headings, lists, quotes, tables, embeds — and editable in place. It looks like the read view (.markdown-body).The toolbar and the keys apply real formatting. Typing markdown syntax formats it at once (auto-markdown).mode="rich", or editorMode="auto" (the default) and type_hint is markdown.

BlobContentEditor takes mode (raw | rich) and reads it once when it mounts. BlobContentEdit / BlobView / BlobSurface / BlobOverlay take editorMode (auto | raw | rich, default auto) and remount the editor when the resolved engine changes — for example after the blob’s content type changes to Markdown. resolveEditorEngine(mode, typeHint) gives the same answer outside a component. In the web client, Settings → General → Markdown editing sets it: Rendered = auto, Raw syntax = raw.

Rich engine: auto-markdown and keys

Type / pressResult
# … ###### at the start of a lineHeading 1–6
- * + / 1. Bulleted / numbered list
[ ] or [x] (in or out of a list)Task item. Click the box to toggle it.
> Quote
``` + space, or + EnterCode block
--- (or --- + Enter)Divider
**b** *i* _i_ `c` ~~s~~ [text](url)Bold, italic, code, strike, link — on the closing marker
Backspace just after one of theseUndo it — the literal characters come back
Enter in a listNew item (a task item starts unchecked). Enter on an empty item leaves the list.
Shift+Enter in a listBreak out of the list: the text after the caret becomes a paragraph below it. Outside a list: a line break. In a code block: leave the block.
Mod+EnterLine break, also inside a list item
Tab / Shift+Tab in a listIndent / outdent
Mod+B / I / E, Mod+Shift+XBold / italic / inline code / strike
Mod+Z, Mod+Shift+Z / Mod+YUndo / redo (one history for typing, the toolbar and extensions)

Semantic editing commands (ctx.run). Toggle commands (marks, lists, quote, heading) turn off when already active.

type EditorCommand = { type: 'bold' | 'italic' | 'strike' | 'code' | 'small' } | { type: 'clearFormatting' } | { type: 'heading'; level: 1 | 2 | 3 | 4 | 5 | 6 } | { type: 'paragraph' } | { type: 'bulletList' | 'numberedList' | 'taskList' | 'quote' | 'codeBlock' } | { type: 'divider' } | { type: 'color'; color: string | null } | { type: 'link'; href: string | null; text?: string } | { type: 'undo' | 'redo' }

Source: components/core/BlobContentEditor/plugins/types.ts:195

Pasted plain text is parsed as markdown, so a pasted list or heading renders. Copy gives markdown as plain text.

What the rich engine keeps as written

The rich engine saves markdown again on commit, so the stored text is normalized: *italic* (not _italic_), - bullets, and characters that would otherwise format are escaped (\*). The first onChange after mount compares against the normalized text, so opening and closing a blob that you did not edit never makes it dirty. Some markdown has no editable form, and the engine keeps it exactly as written:

  • Tables and raw HTML blocks render as read-only blocks. Double-click one to edit its markdown source; leave the field to apply it, or press Escape to discard the change.
  • Embed tokens (%%embed:KIND:…%%) become atoms rendered by the same parser as the read view. An unknown kind is shown as its literal token and saved unchanged.
  • Colour spans and <small> become marks and are written back as the same HTML. Any other inline HTML stays literal text.

Single line breaks follow the viewer: unless the text looks hard-wrapped, a single newline is a line break while you edit, and it is saved as one newline.

Content model

Storage is plain text

The editor gets a serializer from @blobs/sdk through contentType (text default, markdown, code, json, mixed). The serializer renders the content to HTML when the editor mounts, and converts the HTML back when the editor emits a change. BlobContentEdit uses the default, text, so blob bodies are stored as plain text and any HTML is discarded.

Because of this, formatting is written as markdown syntax in the text:

FormattingWritten as
Bold / italic / strikethrough / inline code**b**, _i_, ~~s~~, `c`
Heading (text size)# , ## , ###
Lists / tasks / quote- , 1. , - [ ] , >
Code block / rule / linkfenced ```, ---, [text](url)
Colour<span style="color: var(--color-primary)">text</span> (the markdown sanitizer keeps it; use theme variables so the colour follows the theme)

Embed tokens

Rich inline items are stored as embed tokens:

%%embed:KIND:BASE64URL_JSON%%
  • KIND matches /^[a-z][a-z0-9_-]*$/ and selects the parser.
  • The payload is the parser’s data as JSON, then base64url-encoded. A token has no whitespace, so no serializer changes it.
  • Parsers are registered once, in a module-level registry:
import { registerEmbedParser } from '@blobs/sdk-solid';
registerEmbedParser<{ keys: string[] }>({
kind: 'kbd',
render: ({ keys }) => {
const el = document.createElement('span');
el.className = 'acme-kbd';
el.textContent = keys.join(' + ');
return el;
},
});

The same parsers render tokens everywhere:

SurfaceHow
EditorOn mount, decorateEmbedsInElement replaces each token with the rendered node. On save, extractEmbedsFromElement runs on a detached clone and changes the nodes back to tokens.
PlainTextDetailViewer (plain-text blobs)renderEmbedsInHtml on the escaped text.
MarkdownView (markdown blobs, .md files)Tokens are protected from marked, then decorated after the render.
BlobCard / BlobRow excerptsSame decoration pass.

The rules for parsers:

  • render is synchronous and must not fetch. Put network data into the payload while the user authors the content (the link-preview plugin stores the fetched LinkMetadata).
  • The decoration stamps every rendered node with data-embed-kind, data-embed-payload and contenteditable="false". The caret moves over the node, and the save pass can rebuild the token from it.
  • A plugin that inserts a live node must stamp these attributes itself (or build the node with the parser and stamp it). If it does not, the node is serialized as plain text and the embed is lost. See Inserting an embed.
  • Unknown kinds and payloads that are not valid stay as literal text. If a later app version registers the parser, the token renders on the next mount.
  • Register parsers at app start (or when the module loads), not in mount(). Read-only viewers do not run plugins. A parser that is registered only when an editor mounts does not render for visitors.

The three built-in kinds register their parsers when embeds/builtins loads. The editor, MarkdownView, PlainTextDetailViewer and the media viewer all import it, so saved embeds render on every surface even when no plugin is mounted:

kindPayloadRendersInserted by
linkLinkEmbedPayload: LinkMetadata (url, title?, description?, image?, siteName?, favicon?, author?, publishedAt?) plus display? (tout · card · link)A preview in the stored display variant.Link Lookup
mentionMentionEmbedPayload: { type: 'user' | 'blob' | 'file', id, name, href?, blobId?, mime? }<a class="blob-mention blob-mention--{type}"> with a type icon and the name.Mentions
mediaMediaEmbedData: { id, name, mime, blobId? }. There is no URL in the payload.image/* → <img>. Other types → a file chip (icon and name, linked when a URL is available).Toolbar media item

The media payload does not store a URL, because media URLs contain short-lived access tokens. Each render asks a module-level resolver for the URL. BlobView installs the resolver from its sdk (setMediaEmbedUrlResolver(createSdkMediaUrlResolver(sdk, { network }))). Outside BlobView, install it yourself at app start. Atoms that render before a resolver exists show as chips with the name only.

Saving, dirty state and drafts

The editor is uncontrolled

  • content is read once, on mount. If you change the prop later, the text on screen does not change. To reset the editor (for example, after a rollback), remount it. Wrap it in a keyed <Show> and change the key.
  • onChange fires when the editor loses focus, and when a plugin calls ctx.commit(). It fires only when the serialized content is different from the last value that was sent. There is no change event for each keystroke. If a host needs live updates, add a plugin that subscribes to the input trigger and calls ctx.commit().

The host learns about an edit (and marks itself dirty) when the editor commits. Clicking Save moves focus out of the editor first, so a click always saves the latest text.

Keyboard save flushes first. Ctrl/Cmd+S in BlobContentEdit and on BlobPage calls flushActiveEditor() before it saves. This function blurs the focused editor and focuses it again, restoring the caret and selection, so the edits typed since the last blur are committed and saved. If your own host has a keyboard save, call it too:

import { flushActiveEditor } from '@blobs/sdk-solid';
flushActiveEditor(); // no-op when no editor has focus
if (isDirty()) await save();

Full page — BlobPage (/blob/:id)

  • Dirty state. isDirty becomes true when the editor commits a change (onContentChange), on each keystroke in the header’s title field, and when files are queued on a draft. A successful save sets it back to false.
  • Save. The header Save button and Ctrl/Cmd+S (only when dirty) call blobs.update(id, { title, content, type_hint, is_public, inherit_permissions }). The response is merged into the current blob, so lists that the update response does not contain (children, files) are kept.
  • Escape = rollback. When nothing is unsaved, Escape does nothing. When there are unsaved edits, Escape asks the user to confirm. If the user confirms, the page reloads the blob from the server (or resets to the blank draft) and increments editorEpoch. editorEpoch is the key of a keyed <Show> around BlobView, so the editor remounts and shows the reverted text. The remount is necessary because the editor is uncontrolled.
  • Leaving the page. The Back button and the global drop band (through setUnsavedGuard) ask for confirmation when there are unsaved changes.

Drafts — /blob/new and /blob/:parentId/new

  • The page mounts with an in-memory blank blob that has no id. The child route sets parent_id, so the first save nests the new blob under the parent.
  • The first Save creates the blob (blobs.create, stamped with the active organization). The URL then changes to /blob/{id} (replace: true), and later saves use the normal update path.
  • Files dropped on a draft are queued (draftFiles). They upload when the create succeeds, because there is no blob to attach them to before that. The first queued file also fills in an empty title.
  • On the first text that is not empty, the page runs the text detector, so (for example) a pasted YouTube URL gets the correct type_hint.
  • For plugins, a draft means host.blob.id is undefined and host.uploadFile is undefined. Extensions must allow for this.

BlobSurface contains the whole blob UI and its own load and save logic. The host component only supplies a frame:

  • Save works the same as on BlobPage (an update, then a merge, then a short ”✓ Saved” state). BlobSurface does not handle drafts.
  • The surface never closes itself. It reports changes through onDirtyChange(dirty), and the host decides what to do:
    • Modal (BlobOverlay): Escape, the Close button and a backdrop click all go through one close function. When there are unsaved edits, it shows a discard confirmation and stays open if the user cancels. Escape first closes the top inner layer (file viewer, then file sheet, then settings) through apiRef.closeTopLayer().
    • Dashboard dock / expand: changing to a different blob while there are unsaved edits asks “Discard unsaved changes to this blob?”.
  • Escape inside the editor is not captured in the modal (onEscape is not set). It moves up to the modal’s close handling.

Extensions

A plugin is a plain object. Give a list of plugins to BlobContentEditor.plugins (or BlobView.editorPlugins). The plugins run in the order of the list. The editor catches errors that a hook throws and writes them to the log, so a plugin that fails cannot stop the editor. With zero plugins, the editor still works.

EditorPlugin

NameTypeDefaultDescription
idrequiredstring—

Stable id for debugging / dedup (core.toolbar, acme.emoji).

triggersreadonly EditorPluginTrigger[]—

Triggers this plugin reacts to via onTrigger.

onTrigger(trigger: EditorPluginTrigger, ctx: EditorPluginContext, event?: Event) => void—

Called for each subscribed trigger. Return quickly; long work (network) belongs in a detached promise.

onKeyDown(event: KeyboardEvent, ctx: EditorPluginContext) => boolean | void—

Keydown hook, called BEFORE the editor's own key handling, in plugin order. Return true to claim the key: the editor calls preventDefault() and stops offering it to later plugins. This is how the mentions dropdown owns ↑ / ↓ / Enter / Escape while open.

mount(ctx: EditorPluginContext) => void | (() => void)—

Called once per editor instance after it mounts. Render persistent UI into ctx.overlay (or around the editor) here and keep per-editor state in this closure. Return a cleanup function; it runs when the editor unmounts.

Source: components/core/BlobContentEditor/plugins/types.ts:228

Triggers

One of: 'space' · 'enter' · 'paste' · 'blur' · 'focus' · 'input' · 'selectionchange' · 'contextmenu'

Source: components/core/BlobContentEditor/plugins/types.ts:218

TriggerWhen it firesevent
space / enterOn key-up of that key, so the character is already in the DOM.KeyboardEvent
pasteIn a microtask after the paste, so the pasted content is already in the DOM.ClipboardEvent
inputOn any content change (typing, deleting, end of IME composition).InputEvent
selectionchangeWhen the caret or selection moves inside this editor.—
focus / blurWhen the editor gets or loses focus. blur runs before the commit, so a plugin can make a final change.—
contextmenuOn a right-click in the editor. Call event.preventDefault() to replace the browser menu.MouseEvent

The onKeyDown hook is separate from the triggers. It runs on keydown before the editor’s own key handling, in plugin order. If it returns true, it claims the key: the editor calls preventDefault() and stopPropagation(), and later plugins do not get the key. A dropdown uses this to own ↑ / ↓ / Enter / Escape while it is open.

Plugin context (ctx)

Each editor has one ctx object, and it does not change while the editor is mounted. You can keep it from mount(). ctx.host, ctx.format and ctx.readOnly are getters, so they always return the current values.

NameTypeDefaultDescription
editorrequiredHTMLElement—

The contenteditable root element.

overlayrequiredHTMLElement—

Absolutely-positioned layer covering the editor wrapper, for plugin UI (toolbars, dropdowns, popovers). Children are positioned with ctx.toOverlayCoords(); the layer itself has pointer-events: none — give interactive children pointer-events: auto.

topSlotrequiredHTMLElement—

In-flow container rendered directly ABOVE the text area (empty and zero-height until a plugin puts something in it). For chrome that should push content down rather than float over it — e.g. a static toolbar.

hostrequiredEditorHost—

Host services; see EditorHost. Always an object (possibly empty).

formatrequiredstring—

Content format of this editor (text, markdown, code, …).

readOnlyrequiredboolean—

True when the editor is read-only (plugins should not mutate).

notifyChangedrequired() => void—

Re-runs the editor's "is empty" / placeholder probe. Call after mutating the DOM so the editor's UI stays in sync.

commitrequired() => void—

Serialize the editor now and emit onChange if the content changed. The editor normally only emits on blur; plugins that insert content from their own UI (a toolbar click, a mention pick) call this so the host sees the edit — and its dirty / Save state updates — immediately.

getPreviousWordrequired() => { text: string; node: Text; start: number; end: number } | null—

The run of non-whitespace characters ending at the caret, or null when the caret isn't in a text node / the selection isn't collapsed.

replaceTextRangerequired(node: Text, start: number, end: number, replacement: Node) => void—

Replace [start, end) of a text node with a DOM node.

insertAtrequired(anchor: Node | null, node: Node) => void—

Insert a node just before anchor, or at the end of the editor.

insertNodeAtCaretrequired(node: Node) => void—

Insert a node at the caret (replacing any selection) and place the caret after it. Restores the last known editor selection first, so it works from a toolbar click that moved focus.

insertTextrequired(text: string) => void—

Insert plain text at the caret (replacing any selection).

wrapSelectionrequired(before: string, after: string, placeholder?: string) => void—

Wrap the current selection in before / after text (markdown style: **, _, `). With an empty selection, inserts before + placeholder + after and selects the placeholder.

prefixLinesrequired(prefix: string, options?: { numbered?: boolean }) => void—

Prefix every line touched by the selection with prefix (- , 1. , > , # ). Toggles off when every line already has it. numbered: true renumbers 1. 2. 3..

getSelectedTextrequired() => string—

Selected text inside the editor ('' when collapsed / outside).

getSelectionRectrequired() => EditorRect | null—

Viewport rect of the caret / selection, or null when outside.

toOverlayCoordsrequired(rect: EditorRect) => EditorRect—

Convert a viewport rect into overlay-local coordinates.

saveSelectionrequired() => void—

Save the current editor selection (before focus moves to plugin UI).

restoreSelectionrequired() => void—

Restore the last saved editor selection and focus the editor.

enginerequiredEditorEngine—

Which engine renders this editor:

  • raw — plain contenteditable showing the stored text (markdown syntax visible).
  • rich — ProseMirror document rendered as formatted markdown (headings, lists, bold…), serialized back to markdown.

Plugins should prefer run / isActive / markPending over direct DOM edits: those work in both engines. Direct DOM mutation of the rich editor's content is overwritten by ProseMirror.

runrequired(command: EditorCommand) => boolean—

Apply a semantic editing command. rich toggles real formatting; raw writes the equivalent markdown syntax. Returns false when the engine can't apply it (e.g. no selection where one is needed).

isActiverequired(command: EditorCommand) => boolean—

Whether a toggle command is active at the selection (bold, list…).

replaceRangerequired(range: Range, node: Node) => void—

Replace a DOM range inside the editor with node (text, an embed element, or a fragment) and put the caret after it. Works in both engines — the rich engine converts the node into document content.

markPendingrequired(range: Range) => PendingRange | null—

Mark a DOM range as pending an async replacement (e.g. a link being looked up). Shows a pending style; resolve swaps the range for a node (an embed element or text), cancel drops the pending state and keeps the original text. Tracked through later edits.

Source: components/core/BlobContentEditor/plugins/types.ts:69

Important points:

  • Commit after a plugin edit. The editing helpers (insertText, insertNodeAtCaret, wrapSelection, prefixLines) change the DOM and update the empty state, but they do not emit onChange. Call ctx.commit() after an edit that comes from the plugin’s UI. If you do not, the host does not become dirty until the editor loses focus.
  • Keep the editor’s selection. The helpers first restore the last selection in the editor, so they work after a click on a toolbar. But buttons in plugin UI must call preventDefault() on mousedown. If they do not, the click moves focus out of the editor, which causes a blur and a commit, and the selection is lost.
  • overlay is an absolutely positioned layer over the editor with pointer-events: none. Its direct children get pointer-events: auto. To position UI at the caret, use ctx.toOverlayCoords(ctx.getSelectionRect()).
  • topSlot is a container in the normal flow, above the text. It has no height until something is put in it. Use it for UI that pushes the text down, such as a static toolbar.
  • When readOnly is true, do not change the content.
  • Write extensions for both engines. ctx.engine is raw or rich. In the rich engine ProseMirror owns the DOM, so direct DOM changes to ctx.editor are overwritten. Use the engine-neutral calls: ctx.run(command) / ctx.isActive(command) for formatting (bold, heading, bulletList, link, color, undo, … — see EditorCommand), ctx.replaceRange(range, node) / ctx.replaceTextRange(...) / ctx.insertNodeAtCaret(node) to insert content (an element with data-embed-kind + data-embed-payload becomes an embed atom), and ctx.markPending(range) for async replacements: it highlights the range, follows it through later edits, and gives resolve(node), cancel() and isAlive(). Reading the DOM (the selection, text nodes) works in both engines. wrapSelection and prefixLines still work in the rich engine for the usual markers (**, _, ~~, `, # , - , 1. , > ).

A rectangle in viewport coordinates (same shape as DOMRect's fields).

NameTypeDefaultDescription
toprequirednumber——
leftrequirednumber——
bottomrequirednumber——
rightrequirednumber——
widthrequirednumber——
heightrequirednumber——

Source: components/core/BlobContentEditor/plugins/types.ts:60

A range awaiting async replacement; see EditorPluginContext.markPending.

NameTypeDefaultDescription
resolverequired(node: Node) => void—

Replace the range with node (embed atom element or text node).

cancelrequired() => void—

Leave the original text in place.

isAliverequired() => boolean—

False once the range was deleted by the user.

Source: components/core/BlobContentEditor/plugins/types.ts:182

Host services (EditorHost)

ctx.host is always an object, but every member is optional. A plugin must continue to work when a service is not there: the editor can be standalone, read-only, or on a draft with no blob id.

What the host app (BlobView and its hosts) exposes to plugins. Every member is optional: plugins must degrade gracefully when the editor is mounted somewhere that can't provide it (e.g. a standalone editor with no blob yet — a draft).

NameTypeDefaultDescription
sdkBlobsInstance—

SDK instance for API calls (search, metadata, uploads).

networkstring—

Network to route SDK calls through.

blobBlob—

The blob being edited. id is undefined for unsaved drafts.

filesreadonly Media[]—

The blob's current attachments (kept fresh by the host).

foldersreadonly MediaFolder[]—

The blob's attachment folders.

currentUserIdstring—

Signed-in user id, if any.

uploadFile(file: File) => Promise<Media>—

Upload a new file into the current blob and resolve with the stored media row. Undefined when uploads aren't possible (drafts, read-only).

openFile(media: Media) => void—

Open a file of this blob in the host's file viewer (media modal).

navigate(href: string) => void—

Navigate in-app (SPA routing). Falls back to location.assign.

notify(message: string, kind?: 'info' | 'success' | 'warning' | 'error') => void—

Show a short message to the user (toast).

Source: components/core/BlobContentEditor/plugins/types.ts:33

Writing an extension

1 — Text replacement on input (emoji shortcodes). This changes the text as the user types. It does not commit, because the normal commit on blur is sufficient:

import type { EditorPlugin } from '@blobs/sdk-solid';
const EMOJI: Record<string, string> = { ':tada:': '🎉', ':ok:': '👌' };
export const emojiShortcodes: EditorPlugin = {
id: 'acme.emoji',
triggers: ['input'],
onTrigger(_trigger, ctx) {
if (ctx.readOnly) return;
const word = ctx.getPreviousWord(); // run of non-space chars ending at the caret
const emoji = word && EMOJI[word.text];
if (!word || !emoji) return;
const node = document.createTextNode(emoji);
ctx.replaceTextRange(word.node, word.start, word.end, node);
window.getSelection()?.collapse(node, node.length);
ctx.notifyChanged();
},
};

2 — Persistent UI with per-editor state (word count in the top slot). One plugin object can serve many editors, so keep state for each editor in mount(), or in a WeakMap keyed by ctx.editor:

export function createWordCountPlugin(): EditorPlugin {
const bars = new WeakMap<HTMLElement, HTMLElement>();
const update = (ctx: EditorPluginContext) => {
const words = ctx.editor.innerText.trim().split(/\s+/).filter(Boolean).length;
const bar = bars.get(ctx.editor);
if (bar) bar.textContent = `${words} words`;
};
return {
id: 'acme.word-count',
triggers: ['input'],
mount(ctx) {
const bar = document.createElement('div');
bar.className = 'acme-word-count';
ctx.topSlot.append(bar);
bars.set(ctx.editor, bar);
update(ctx);
return () => { bar.remove(); bars.delete(ctx.editor); };
},
onTrigger: (_t, ctx) => update(ctx),
};
}

3 — A floating button that edits and commits. This uses the overlay, keeps focus in the editor, and makes the host dirty immediately:

export const boldBubble: EditorPlugin = {
id: 'acme.bold-bubble',
triggers: ['selectionchange', 'blur'],
mount(ctx) {
const btn = document.createElement('button');
btn.className = 'acme-bubble';
btn.textContent = 'B';
btn.hidden = true;
btn.addEventListener('mousedown', (e) => e.preventDefault()); // keep editor focus
btn.addEventListener('click', () => {
ctx.wrapSelection('**', '**', 'bold text');
ctx.commit(); // host sees it now
});
ctx.overlay.append(btn);
return () => btn.remove();
},
onTrigger(trigger, ctx) {
const btn = ctx.overlay.querySelector<HTMLButtonElement>('.acme-bubble');
const rect = ctx.getSelectionRect();
if (!btn) return;
if (trigger === 'blur' || !rect || !ctx.getSelectedText()) { btn.hidden = true; return; }
const at = ctx.toOverlayCoords(rect);
Object.assign(btn.style, { position: 'absolute', top: `${at.top - 36}px`, left: `${at.left}px` });
btn.hidden = false;
},
};

Style plugin UI only with the theme variables (--surface-popover, --color-border, --color-text-*, --color-primary, --radius-*, --shadow-dropdown, --font-size-*, --animation-speed), so that every theme restyles it.

Inserting an embed

To insert a rich item that is saved, register a parser for its kind (see Embed tokens). Then build the node with the parser and stamp it, so the save pass can change it back to a token:

import { encodeEmbedToken, resolveEmbedParser } from '@blobs/sdk-solid';
function embedNode(kind: string, data: unknown): HTMLElement {
const token = encodeEmbedToken(kind, data); // %%embed:kbd:PAYLOAD%%
const payload = token.slice(`%%embed:${kind}:`.length, -2);
const el = resolveEmbedParser(kind)!.render(data);
el.setAttribute('data-embed-kind', kind);
el.setAttribute('data-embed-payload', payload);
el.setAttribute('contenteditable', 'false');
return el;
}
// in a plugin action:
ctx.insertNodeAtCaret(embedNode('kbd', { keys: ['Ctrl', 'S'] }));
ctx.commit();

For the built-in kinds, helpers build stamped nodes for you: createMediaEmbedNode(mediaEmbedDataFromMedia(media)) and buildMentionNode(payload).

Embed parser contract

Embed parser contract.

An embed parser owns a single kind (link, tweet, youtube, etc.) and knows how to render a payload of that kind into a DOM node. The editor's mount-time + viewer-side decoration walks the blob content for embed tokens, looks each parser up by kind, and substitutes the rendered node in place of the token.

Plugins are the typical registration site: the built-in createLinkPreviewPlugin registers a link parser, but anything — Spotify embeds, Mermaid diagrams, custom internal previews — can register here.

NameTypeDefaultDescription
kindrequiredstring—

Stable identifier used in the %%embed:KIND:...%% token.

renderrequired(data: T) => HTMLElement—

Render the payload as a DOM element. Must produce a complete, self-contained node — the editor will treat it as contenteditable=false (set by the decoration layer) so the caret skips over it.

Render is synchronous: parsers shouldn't perform fetches here. Anything that needs network goes through the plugin layer during authoring (so the data is captured into the embed payload before the embed token is persisted).

Source: components/core/BlobContentEditor/embeds/types.ts:16

Registry functions, all exported from @blobs/sdk-solid: registerEmbedParser(parser) (adds a parser, or replaces the parser for that kind), resolveEmbedParser(kind), listEmbedParsers(), unregisterEmbedParser(kind) (for tests). Codec and decoration: encodeEmbedToken, decodeEmbedPayload, findEmbedTokens, EMBED_TOKEN_RE, decorateEmbedsInElement, extractEmbedsFromElement, renderEmbedsInHtml.

Built-in extensions

There are three built-in extensions. They are all opt-in: add each one to the plugins list. One plugin object can serve many editors, because each plugin keeps its per-editor UI and state in mount(). Create the plugins once (at module level or in a memo), not on every render.

import {
createToolbarPlugin,
createMentionsPlugin,
createLinkLookupPlugin,
} from '@blobs/sdk-solid';
const plugins = [
createMentionsPlugin({ enabled: { blobs: true, users: true, files: true } }),
createToolbarPlugin({ display: ['floating', 'contextmenu'] }),
createLinkLookupPlugin(),
];
<BlobView blob={blob} sdk={sdk} editorPlugins={plugins} editorHost={{ navigate }} />

Put Mentions before the Toolbar. Both claim Escape in onKeyDown, and the plugins run in list order, so with this order an open mention dropdown gets the key first.

How the web client sets them up. apps/web-client/src/lib/editorPlugins.ts builds the list from Settings → General:

SettingValuesPlugin
Link previewson / offcreateLinkLookupPlugin({ enabled, requestOptions: () => ({ lang: navigator.language }) })
Editor toolbarFloating (on text selection) · Always visible · Right-click menu only · OffcreateToolbarPlugin({ display: [mode, 'contextmenu'] }) — right-click opens the toolbar in every mode but Off — or no toolbar
MentionsBlobs # · Users @ · Files & (each is a checkbox)createMentionsPlugin({ enabled: { … } }), or no plugin when all are off

useEditorPlugins() returns the plugin list, and useEditorHost() returns the extra host services (navigate for SPA routing, and currentUserId). Pages give these to BlobView / BlobSurface as editorPlugins and editorHost.

Toolbar

createToolbarPlugin(options) (id: 'core.toolbar') adds a formatting toolbar. All display modes show the same items. Each action writes markdown syntax with the editor helpers and then calls ctx.commit(), so the host becomes dirty immediately.

const toolbar = createToolbarPlugin({
display: ['static', 'contextmenu'],
items: ['heading', '|', 'bold', 'italic', 'code', '|', 'link', 'media'],
colors: [{ label: 'Accent', value: 'var(--color-primary)' }],
});

Options for createToolbarPlugin.

NameTypeDefaultDescription
displayToolbarDisplay | readonly ToolbarDisplay[]['floating', 'contextmenu']

Display mode(s). Several may be combined, e.g. ['static', 'contextmenu']. Unknown values are ignored; an empty result falls back to the default. With contextmenu, right-click in the editor opens the toolbar as a menu at the pointer instead of the browser's menu (Shift+right-click still gives the browser's menu).

itemsreadonly ToolbarItem[]DEFAULT_TOOLBAR_ITEMS — heading | bold italic strikethrough code color | link media | bulletList numberedList taskList quote codeBlock divider | clearFormatting | undo redo

Buttons, in order: built-in ids, '|' separators and ToolbarCustomItems. Unknown ids are dropped with a console warning; duplicate ids keep the first; leading, trailing and doubled separators are collapsed.

contextMenuItemsreadonly ToolbarItem[]—

Items for the right-click menu only (same format as items), for a menu with its own operations — e.g. custom items plus a few built-ins. Defaults to items.

colorsreadonly ToolbarColor[]DEFAULT_TOOLBAR_COLORS — Accent, Success, Warning, Error, Muted (theme variables)

Palette for the color item.

shortcutsbooleantrue

Keyboard shortcuts inside the editor: Ctrl/⌘+B bold, +I italic, +Shift+X strikethrough, +E inline code, +K link. They work whether or not the matching item is in items.

markdownHintbooleantrue

Blob bodies are stored as plain text, so formatting is markdown syntax that only RENDERS when the blob is viewed as markdown. When true, the first formatting action on a blob with no type_hint sets host.blob.type_hint = 'markdown' (mutating the host's blob object; hosts persist it on save). An explicit hint (text, code, …) is never overwritten. Set false to leave the hint alone.

enabled() => boolean() => true

Reactive on/off switch, read on every trigger, so a setting toggle takes effect without remounting the editor. While false the toolbar is hidden, shortcuts pass through and the native context menu shows.

mediaUrl(media: Media, ctx: EditorPluginContext) => string | nullthe media embed resolver (setMediaEmbedUrlResolver), else host.sdk.media.getMediaUrl(id, { inline: true, includeToken: true, network: host.network })

Thumbnail URL for a file in the media picker (null → file chip).

ariaLabelstring'Formatting'

Accessible name of the toolbar element.

Source: components/core/BlobContentEditor/plugins/toolbar/options.ts:156

Display modes

One of: 'static' · 'floating' · 'contextmenu'

ModeBehaviour
staticAlways visible, in ctx.topSlot above the text, and sticky to the top of the editor.
floating (default)Shows above the selection while text is selected. It moves below the selection when there is no space above. It hides when the selection collapses, when the editor loses focus, or on Escape.
contextmenu (default)Replaces the browser’s right-click menu in the editor with a vertical menu at the pointer. Shift+right-click still opens the browser’s menu (spellcheck, copy / paste). Set contextMenuItems to give this menu its own operations (custom items and built-ins); it defaults to items.

You can combine modes, for example ['static', 'contextmenu']. Unknown values are ignored, and an empty list or no display uses ['floating', 'contextmenu'].

Menu placement. Dropdowns (text size, colour, link, insert file) open below the toolbar. When one does not fit below and the toolbar is in the lower half of the visible area, it opens above; otherwise the page scrolls so it shows. The right-click menu opens down from the pointer and flips up near the bottom. “Visible area” excludes clipping scroll containers and sticky / fixed headers, so a menu never opens under the page or blob header. A menu taller than the space scrolls inside itself.

Items

Ids of the built-in toolbar items.

idwrites
bold**text**
italic_text_
strikethrough~~text~~
code`text`
headingdropdown: Normal / # / ## / ### / <small>text</small>
colorpalette: <span style="color: …">text</span>
linkURL popover: [text](url)
bulletList- line prefix
numberedList1. 2. … line prefixes
taskList- [ ] line prefix
quote> line prefix
codeBlock``` fence around the selection
dividera --- line
mediafile picker / upload: a media embed atom
clearFormattingstrips markdown markers from the selection
undo / redobrowser undo stack

One of: 'bold' · 'italic' · 'strikethrough' · 'code' · 'heading' · 'color' · 'link' · 'bulletList' · 'numberedList' · 'taskList' · 'quote' · 'codeBlock' · 'divider' · 'media' · 'clearFormatting' · 'undo' · 'redo'

The default items list is:

heading | bold italic strikethrough code color | link media | bulletList numberedList taskList quote codeBlock divider | clearFormatting | undo redo

'|' is a separator. Unknown ids are removed with a console warning. When an id occurs twice, only the first is kept. Separators at the start or end, and two separators together, are collapsed.

Details of some items:

  • heading opens a dropdown: Normal, # , ## , ### , or Small (<small>…</small>). It replaces a heading marker that is already on the line.
  • color opens a palette with the colors option and a reset swatch. The colour is written as <span style="color: VALUE">. The default swatches are theme variables (Accent, Success, Warning, Error, Muted), so coloured text follows the theme.
  • link opens a URL field. If the selection is already [text](url), the link gets the new target. The URL is normalized: http(s), mailto, tel and relative links (/x, ./x, #x) are accepted. A bare domain gets https://. Other schemes (for example javascript:) are refused. Spaces and parentheses are percent-encoded, so the URL cannot break out of [text](url).
  • media opens a file picker for host.files. It has a filter field (case-insensitive name match, up to 50 files shown) and thumbnails for images. When host.uploadFile is available, it also has Upload file…, which uploads into the blob and then inserts the new file. The selected file is inserted as a media embed. If the upload fails, the error shows in the picker and through host.notify. The item is disabled, with the reason as its tooltip, when the editor is read-only, or when the blob has no files and uploads are not available. A draft shows “Save the blob first — files can only be added to a saved blob”.
  • clearFormatting expands the selection over the markers next to it and removes the inline markdown markers.
  • undo / redo use the browser’s undo stack.

Keyboard

KeysAction
Ctrl/⌘+B · I · Shift+X · E · KBold · italic · strikethrough · inline code · link. These work even when the item is not in items. Set shortcuts: false to turn them off.
← / → (↑ / ↓ in the context menu), Home / EndMove between toolbar buttons (roving focus).
EscapeCloses an open popover and the floating toolbar or context menu, and gives the selection back to the editor. The plugin claims this key, so the host gets Escape only when no toolbar UI is open.

Markdown output and the type_hint rule

Blob bodies are plain text, so formatting is markdown that renders only when the blob is viewed as markdown. (In the rich engine the blob is already markdown, and the items apply formatting through ctx.run instead of writing syntax.) With markdownHint: true (the default), the first formatting action on a blob without a type_hint sets host.blob.type_hint = 'markdown'. This changes the host’s blob object, and the host persists the value when it saves. An explicit hint (text, code, …) is never overwritten. The media and clearFormatting items, the undo / redo items, and custom items with formats: false do not set the hint. Embed tokens render in every viewer, so they do not need it.

Writing a custom toolbar item

Put a ToolbarCustomItem in items, between the built-in ids:

const CALENDAR_SVG =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>';
const toolbar = createToolbarPlugin({
items: [
'bold', 'italic', '|',
{
id: 'insert-date',
label: 'Insert date',
icon: CALENDAR_SVG,
run: (ctx) => ctx.insertText(new Date().toLocaleDateString()),
formats: false, // plain text: do not switch the blob to markdown
},
{
id: 'highlight',
label: 'Highlight',
run: (ctx) => ctx.wrapSelection('<mark>', '</mark>', 'text'),
disabled: (ctx) => (ctx.getSelectedText() ? false : 'Select some text first'),
},
],
});
  • run gets the plugin context. Use the editing helpers. The toolbar calls ctx.commit() after run finishes (also when run returns a promise).
  • isActive (pressed state) and disabled are checked again on each selection change. If disabled returns a string, the string is the tooltip.
  • icon is trusted SVG markup. Never give user input. When you do not set it, the first letter of label shows.
  • shortcut is only a hint in the tooltip. To bind the key, write a small plugin with onKeyDown.

A consumer-defined toolbar button.

NameTypeDefaultDescription
idrequiredstring—

Unique id (must not clash with a built-in id). Stamped as data-item.

labelrequiredstring—

Accessible name; also the tooltip and the label in the context menu.

iconstring—

Icon as an SVG markup string (24×24 viewBox, stroke="currentColor" recommended). Trusted markup — never pass user input. When omitted the first letter of label is shown.

shortcutstring—

Shortcut hint shown in the tooltip (display only — bind it yourself via another plugin).

runrequired(ctx: EditorPluginContext) => void | Promise<void>—

Action. Use the context's editing helpers (wrapSelection, prefixLines, insertText, insertNodeAtCaret); the toolbar calls ctx.commit() after it resolves.

isActive(ctx: EditorPluginContext) => boolean—

Pressed state (aria-pressed), re-checked on every selection change.

disabled(ctx: EditorPluginContext) => boolean | string—

Disabled state. Return a string to disable the item and use the string as the tooltip explaining why; true disables with no explanation.

formatsbooleantrue

Whether this item writes markdown formatting — when true the toolbar sets the blob's type_hint to markdown first (see ToolbarPluginOptions.markdownHint).

Source: components/core/BlobContentEditor/plugins/toolbar/options.ts:70

A colour swatch for the color item.

NameTypeDefaultDescription
labelrequiredstring—

Accessible name ("Accent").

valuerequiredstring—

Any CSS colour. Prefer theme variables (var(--color-primary)) so the colour follows the active theme; it is written verbatim into <span style="color: VALUE">.

Source: components/core/BlobContentEditor/plugins/toolbar/options.ts:109

Mentions

createMentionsPlugin(options) (id: 'core.mentions') adds inline references to people, blobs and files. When you type a control character at the start of a word, a search dropdown opens under the caret. When you pick a result, the plugin replaces CHAR + query with a mention embed and a space, then commits.

import { createMentionsPlugin, handleMentionClick } from '@blobs/sdk-solid';
const mentions = createMentionsPlugin({
enabled: {
blobs: true, // '#', scope 'mine'
users: { scope: 'all' }, // '@'
files: { char: '&', open: 'new-tab' },
},
});

Every category is off unless you list it in enabled. Use true for the defaults of the category, or give an options object. The plugin validates the config when you create it. It throws an error when a control character is not exactly one non-whitespace character, when two categories use the same character, or when the initial scope is not a search scope of the category.

Options for createMentionsPlugin.

NameTypeDefaultDescription
enabled{ users?: boolean | MentionUsersOptions; blobs?: boolean | MentionBlobsOptions; files?: boolean | MentionFilesOptions; }{}

Which categories are on. Every category is OFF unless listed here. Pass true for the category's defaults, or an options object.

debounceMsnumber200

Wait this long after the last keystroke before calling the server. Local file results show immediately regardless.

active() => boolean() => true

Reactive on/off switch, read on every keystroke, so a settings toggle takes effect without remounting the editor.

Source: components/core/BlobContentEditor/plugins/mentions/types.ts:175

Categories

CategoryDefault charScopes (first = default)Click opens (default)
users@all “All users” · connections “My connections”same-tab → /profile/{id}
blobs#mine “My blobs” · public “All public blobs” · all “All blobs”same-tab → /blob/{id}
files&blob “Blob files” · find “Find files” (an action)modal → host.openFile

Options shared by every category.

NameTypeDefaultDescription
charstring'#' (blobs), '@' (users), '&' (files)

The control character that opens this category's dropdown. Must be a single character and unique across enabled categories.

scopestring'all' (users), 'mine' (blobs), 'blob' (files)

Initial scope id. Must be one of scopes.

scopesreadonly MentionScopeOption[]users: all, connections · blobs: mine, public, all · files: blob, find

Scopes the context-bar link cycles through. Replaces the defaults.

limitnumber8

Maximum results requested and shown.

searchMentionSearchFn—

Custom search for this category; bypasses the SDK and local ranking.

Source: components/core/BlobContentEditor/plugins/mentions/types.ts:114

Extends MentionCategoryOptionsBase

NameTypeDefaultDescription
openMentionLinkOpen'same-tab'

How a clicked user mention opens.

charstring'#' (blobs), '@' (users), '&' (files)

The control character that opens this category's dropdown. Must be a single character and unique across enabled categories.

From MentionCategoryOptionsBase

scopestring'all' (users), 'mine' (blobs), 'blob' (files)

Initial scope id. Must be one of scopes.

From MentionCategoryOptionsBase

scopesreadonly MentionScopeOption[]users: all, connections · blobs: mine, public, all · files: blob, find

Scopes the context-bar link cycles through. Replaces the defaults.

From MentionCategoryOptionsBase

limitnumber8

Maximum results requested and shown.

From MentionCategoryOptionsBase

searchMentionSearchFn—

Custom search for this category; bypasses the SDK and local ranking.

From MentionCategoryOptionsBase

Source: components/core/BlobContentEditor/plugins/mentions/types.ts:141

Extends MentionCategoryOptionsBase

NameTypeDefaultDescription
openMentionLinkOpen'same-tab'

How a clicked blob mention opens.

charstring'#' (blobs), '@' (users), '&' (files)

The control character that opens this category's dropdown. Must be a single character and unique across enabled categories.

From MentionCategoryOptionsBase

scopestring'all' (users), 'mine' (blobs), 'blob' (files)

Initial scope id. Must be one of scopes.

From MentionCategoryOptionsBase

scopesreadonly MentionScopeOption[]users: all, connections · blobs: mine, public, all · files: blob, find

Scopes the context-bar link cycles through. Replaces the defaults.

From MentionCategoryOptionsBase

limitnumber8

Maximum results requested and shown.

From MentionCategoryOptionsBase

searchMentionSearchFn—

Custom search for this category; bypasses the SDK and local ranking.

From MentionCategoryOptionsBase

Source: components/core/BlobContentEditor/plugins/mentions/types.ts:150

Extends MentionCategoryOptionsBase

NameTypeDefaultDescription
openMentionFileOpen'modal'

How a clicked file mention opens: the host's file viewer (modal, via host.openFile) or the raw file in a new tab.

onFindFiles(ctx: EditorPluginContext) => void—

Called by the default "Find files" scope action. Searching other blobs' files has no UI yet, so the default only shows a "coming soon" notice through ctx.host.notify.

charstring'#' (blobs), '@' (users), '&' (files)

The control character that opens this category's dropdown. Must be a single character and unique across enabled categories.

From MentionCategoryOptionsBase

scopestring'all' (users), 'mine' (blobs), 'blob' (files)

Initial scope id. Must be one of scopes.

From MentionCategoryOptionsBase

scopesreadonly MentionScopeOption[]users: all, connections · blobs: mine, public, all · files: blob, find

Scopes the context-bar link cycles through. Replaces the defaults.

From MentionCategoryOptionsBase

limitnumber8

Maximum results requested and shown.

From MentionCategoryOptionsBase

searchMentionSearchFn—

Custom search for this category; bypasses the SDK and local ranking.

From MentionCategoryOptionsBase

Source: components/core/BlobContentEditor/plugins/mentions/types.ts:159

When a mention opens

A mention opens while the caret is directly after CHAR + query, and:

  • CHAR is at the start of a word: at the start of a line or text node, after whitespace, or after opening punctuation ( [ { " ' “ ‘ <. So [email protected] and a#b do not open a mention.
  • The query has no spaces. A space closes the mention. The server matches a first name, a username, a blob title, a slug or a pasted id, so one word is sufficient.
  • The query does not start with the same character again (## and @@ do not open a mention), and it is 100 characters or less.
  • The context bar at the top of the dropdown shows the label of the active scope (for example “My blobs”). The switch link on the right shows the label of the next scope (its switchLabel, or else its label). Click it to move to that scope and search again. The active scope of each category is remembered for that editor, also after the dropdown closes and opens again.
  • A scope with onSelect is an action, not a mode. Clicking its link runs the action, and the active scope does not change. The files category’s Find files is an action of this type.
  • When the backend replies supported: false for a scope, the dropdown shows the unsupportedMessage of the scope instead of results.
  • Keys while the dropdown is open: ↑ / ↓ move the selection, Enter or Tab picks, and Escape closes the dropdown. After Escape, the dropdown stays closed for that mention until you start a new one. The plugin claims these keys only while the dropdown is open, so at other times the editor behaves normally.

Search behaviour

  • Debounce. The server is called debounceMs (default 200 ms) after the last keystroke.
  • Instant local files. For the files category in the blob scope, results from host.files (ranked on the client) show immediately while the server request runs. If the SDK has no search method or the request fails, these local results are used.
  • Stale-response handling. Only the answer to the newest request is shown. An older response that arrives late is discarded, so results never go back to an earlier query.
  • Cache. Answers are cached for each editor for 60 s, by category, scope, blob, limit and lower-case query (up to 100 queries).
  • Ranking (server). Exact id, exact slug or exact name first, then prefix, word-prefix and substring matches. Ties are ordered by recency. An empty query lists recent items.
  • Server endpoint. sdk.search.mentions({ type, q, scope, blobId, limit }) → GET /api/v1/search/mentions. Authentication is required. q is 100 characters or less, limit defaults to 8 (maximum 25), and blob_id is used for files in the blob scope. The endpoint never returns rows that the caller cannot read.

A search scope a category can switch between. The dropdown's context bar shows the active scope's label; the link on the right shows the NEXT scope's switchLabel (or label) and cycles to it on click.

NameTypeDefaultDescription
idrequiredstring—

Scope id sent to the search API as scope.

labelrequiredstring—

Label shown in the context bar while this scope is active ("All users").

switchLabelstringlabel

Label of the switch link that activates this scope.

unsupportedMessagestring"This search isn't available yet"

Message shown instead of results when the backend answers supported: false for this scope.

onSelect(ctx: EditorPluginContext) => void—

Make this scope an ACTION instead of a mode: clicking its switch link calls this and the active scope stays as it was. Used by the files category's "Find files".

Source: components/core/BlobContentEditor/plugins/mentions/types.ts:86

One search result. Mirrors the MentionItem wire shape of GET /api/v1/search/mentions (snake_case fields).

NameTypeDefaultDescription
typerequiredMentionItemType—

Singular item type.

idrequiredstring—

Stable id (user, blob or media UUID).

namerequiredstring—

Display name — what the inserted mention shows.

subtitlestring | null—

Muted secondary line in the dropdown (username, blob author, file size…).

hrefstring | null—

Link target. Users /profile/{id}, blobs /blob/{id}, files null.

avatar_urlstring | null—

Avatar image for users.

mime_typestring | null—

MIME type for files.

blob_idstring | null—

Owning blob for files.

scorenumber—

Server relevance score (higher is better). Informational.

Source: components/core/BlobContentEditor/plugins/mentions/types.ts:29

Give search to a category to replace the built-in search (SDK call plus local file ranking) completely. The function gets the lookup parameters and returns result items. Debounce, the stale-response guard and the cache still apply.

const mentions = createMentionsPlugin({
enabled: {
users: {
// Search your own directory instead of the Blobs API.
search: async ({ q, limit }) => {
const people = await directory.find(q, { limit });
return people.map((p) => ({
type: 'user',
id: p.id,
name: p.displayName,
subtitle: p.email,
href: `/people/${p.id}`,
avatar_url: p.avatar,
}));
},
},
},
});

A custom search always counts as supported: true. To make a scope that is not available yet, leave search unset and let the backend reply supported: false. You can also use a custom scopes list with an onSelect action.

Parameters passed to a search resolver.

NameTypeDefaultDescription
typerequiredMentionCategory—

Category being searched.

qrequiredstring—

Text typed after the control character (may be empty).

scoperequiredstring—

Active scope id (e.g. all, mine, blob).

blobIdstring—

Current blob id (used by the files / blob scope).

limitrequirednumber—

Maximum results wanted.

Source: components/core/BlobContentEditor/plugins/mentions/types.ts:51

Clicking a mention — handleMentionClick

The mention parser renders without a host, so click handling is separate. Call handleMentionClick(event, host) from a click listener on the editor root and on every read-only content container. It returns true (after it calls preventDefault()) when it handled the click. BlobView already does this on .blob-view__body, which contains both the editor and the read-only view:

<div onClick={(e) => handleMentionClick(e, host)}>{/* editor or rendered blob */}</div>
WherePlain clickCtrl/⌘ + clickShift / Alt + click
Inside the editorOpens according to the config, the same as in the viewer. A chip is a button: pressing it never selects its text or moves the caret (the plugin cancels mousedown on chips, and chips are user-select: none).Opens in a new tab (files: their view URL).—
Read-only viewerOpens according to the config. same-tab uses host.navigate (SPA) when it is available, or else the browser follows the link. new-tab opens a new tab. Files use modal → host.openFile with the matching host.files item, or else a new tab.Left to the browser (open in a new tab or window). File chips with no href open in a new tab.Left to the browser.

How mentions open is a module-level setting (configureMentionLinks), because parsers render without a host. Each createMentionsPlugin call writes this setting from its categories’ open options, so the plugin created last sets it for the whole app. Hrefs are restricted to same-origin paths and http(s) URLs. A file mention gets an href only from fileHref. Without fileHref, a click on a file uses host.sdk.media.getViewUrl.

Module-level click / link behaviour for rendered mentions.

NameTypeDefaultDescription
usersrequiredMentionLinkOpen'same-tab'

How user mentions open.

blobsrequiredMentionLinkOpen'same-tab'

How blob mentions open.

filesrequiredMentionFileOpen'modal'

How file mentions open.

fileHref(payload: MentionEmbedPayload) => string | undefined—

Builds a file mention's href (used for new-tab opening and as the modal fallback). Without it, file chips get no href and clicks fall back to host.sdk.media.getViewUrl.

Source: components/core/BlobContentEditor/plugins/mentions/mentionEmbed.ts:50

Payload of a persisted mention embed token.

NameTypeDefaultDescription
typerequiredMentionItemType——
idrequiredstring——
namerequiredstring——
hrefstring——
blobIdstring—

Owning blob (files).

mimestring—

MIME type (files) — picks the icon.

Source: components/core/BlobContentEditor/plugins/mentions/types.ts:224

createLinkLookupPlugin(options) (id: 'core.link-lookup') changes plain links into inline previews.

const linkLookup = createLinkLookupPlugin({
source: 'auto',
display: 'tout',
requestOptions: () => ({ lang: navigator.language }),
enabled: () => prefs.linkPreviewsEnabled,
});

createLinkPreviewPlugin is an alias, and LinkPreviewPluginOptions is a deprecated alias of LinkLookupPluginOptions. Older code that uses { resolver, enabled } continues to work.

Options for createLinkLookupPlugin.

NameTypeDefaultDescription
sourceLinkLookupSource'auto'

Where metadata comes from.

  • 'auto' — the Blobs API (ctx.host.sdk.links.metadata) when the host's SDK has it, falling back to the client resolver when that call fails or returns nothing usable.
  • 'backend' — the Blobs API only. No SDK → links stay text.
  • 'client' — the client resolver only (resolver, default microlink.io).
resolverLinkMetadataResolver | CachingLinkResolvermicrolinkResolver (https://api.microlink.io, rate-limited free tier)

Client-side resolver, used for source: 'client' and as the 'auto' fallback. A CachingLinkResolver (e.g. sharedLinkMetadataCache) is used as-is.

requestOptionsLinkMetadataRequestOptions | (() => LinkMetadataRequestOptions | undefined)undefined (server defaults)

Request variables forwarded to the backend endpoint, overriding its defaults — e.g. { lang: 'fr' } (sent on as Accept-Language). Pass a function to read a live value (current UI language) per lookup. Part of the cache key.

displayLinkPreviewDisplay'tout'

How a resolved link renders. Stored in the embed payload, so a preview keeps its look in every viewer.

  • 'tout' — compact row: image left (omitted when the page has none), 1-line title + 2-line description right, host line below.
  • 'card' — larger card: wide image on top, then the text.
  • 'link' — a styled hyperlink showing the page title.
detectBareWwwbooleantrue

Detect scheme-less www.example.com links (normalized to https://).

detectOnTypebooleantrue

Convert links as they are typed (on space / enter after the link).

detectOnPastebooleantrue

Convert links found in pasted text.

maxPerPastenumber10

Most links converted from a single paste; the rest stay text, so pasting a long list doesn't fire dozens of lookups.

cacheLinkLookupCachesharedLinkLookupCache

Metadata cache. Defaults to a module-level cache shared by every plugin instance, so a link resolved in one editor is instant in the next. Entries are keyed by source + options + URL.

enabled() => boolean() => true

Reactive on/off switch, read on every trigger — toggling a setting takes effect without remounting the editor.

Source: components/core/BlobContentEditor/plugins/linkLookup/plugin.ts:80

Detection

  • Paste: links in the pasted text, up to maxPerPaste (default 10). The remaining links stay as text.
  • Typing: on space or Enter, the word just before the caret. A space typed in the middle of a word (which splits it) does not trigger.
  • http(s)://… links are always detected. www.… links are detected too, and get https:// (set detectBareWww: false to turn this off). Punctuation at the end is not part of the link.
  • Never converted: code (fenced blocks, inline code spans, and the whole editor when the format is code), markdown links [text](url) and reference definitions, autolinks <https://…>, inline HTML tags, text inside <a>, <code> or <pre>, and existing embeds and previews. Nothing is converted when the editor is read-only or enabled() is false.
  1. The link is replaced with a small pending pill whose text is the original link. If the user saves while the request is in progress, the link text is saved, not the loading UI.
  2. Metadata is resolved from the source.
  3. Success: the pill becomes the preview, stamped as a link embed with the display variant in the payload, and the plugin calls ctx.commit(). Failure, or metadata with no title, description or image: the pill changes back to the original text.

Sources

One of: 'auto' · 'backend' · 'client'

sourceSDK has links.metadataTried, in order
auto (default)yesbackend → client
autonoclient
backendyesbackend
backendnonothing (the link stays text)
clienteitherclient
  • Backend: sdk.links.metadata(url, { lang }) → GET /api/v1/links/metadata?url=…. Authentication is required. The server reads OpenGraph and Twitter tags, <title> and the favicon. The fetch is SSRF-safe: only http and https, private, loopback, link-local, multicast and unspecified addresses are refused (also after redirects), with a maximum of 3 redirects, a 5 s timeout, a 1 MB body limit, and HTML only. The server caches results by URL for 24 h, and failures for 10 min. Relative image and favicon URLs are made absolute. A page that cannot be fetched returns a minimal { url }, which counts as a failure. A private target returns 400.
  • Client: the resolver option. The default is microlinkResolver (api.microlink.io, with a rate-limited free tier).
  • requestOptions are sent to the backend, for example { lang: 'fr' }, which is sent as Accept-Language. Give a function to read a live value for each lookup. Unknown keys are passed through, so new server parameters work without a new plugin version. The request options are part of the cache key.

Request variables forwarded to GET /api/v1/links/metadata. Unknown keys are passed through untouched, so new server-side parameters work without a plugin release.

NameTypeDefaultDescription
langstring—

Preferred language for the fetched page (forwarded by the server as Accept-Language), e.g. 'en', 'fr-CA'.

Source: services/linkMetadata.ts:260

Display variants

One of: 'tout' · 'card' · 'link'

displayRenders
tout (default)A compact row: the image on the left (omitted when the page has no image), a one-line title and a two-line description on the right, and the host line below.
cardA larger card: a wide image at the top, then the text.
linkA styled hyperlink that shows the page title.

The variant is stored in the embed payload, so a preview looks the same in every viewer, also after the option changes.

Caching

The plugin uses a module-level cache, sharedLinkLookupCache, which all plugin instances share. A link resolved in one editor is therefore instant in the next. Each attempt has its own cache key (source | resolver / network / request options | url), so a backend failure never hides a client success, and plugins with different request options never share an entry. Requests that are in progress are merged. Failed lookups are remembered (and not tried again) for 10 minutes, and the cache keeps at most 500 successful entries. To use a separate cache, give cache: createLinkLookupCache({ failureTtlMs, maxEntries }).

Link metadata resolver — fetches OpenGraph / Twitter Card / oEmbed style metadata for a URL so the editor can render a LinkPreview card inline.

Two pieces: 1. LinkMetadata shape + LinkMetadataResolver interface. Consumers can swap in their own (proxied through a backend endpoint, for example) by passing a custom resolver to the editor plugin. 2. microlinkResolver — a default resolver backed by https://api.microlink.io. No auth required for the free tier; rate-limited but fine for personal use. Production apps should swap this for a self-hosted resolver.

3. createSdkLinkResolver(sdk) — a resolver backed by the Blobs API (GET /api/v1/links/metadata, via sdk.links.metadata). Server-side fetch: no CORS limits, SSRF-guarded, cached in Redis.

Caching:

  • createLinkLookupCache() is a KEYED cache (callers choose the key — the link lookup plugin uses source|options|url) with an in-flight registry (concurrent requests collapse to one fetch) and a failure set with a TTL (failed URLs aren't retried in a tight loop, but are retried after failureTtlMs).
  • createLinkMetadataCache(resolver) is the older url-keyed wrapper, now a thin layer over the keyed cache.
NameTypeDefaultDescription
urlrequiredstring—

Canonical URL — typically <link rel="canonical"> or the redirected URL.

titlestring—

Page title (og:title or <title>).

descriptionstring—

Short description (og:description or meta description).

imagestring—

Hero image URL (og:image or twitter:image).

siteNamestring—

Site name (og:site_name).

faviconstring—

Site favicon URL.

authorstring—

Author / publisher (og:article:author, etc.).

publishedAtstring—

Published date string when available.

Source: services/linkMetadata.ts:30

Payload of a persisted link embed: the metadata plus how to show it.

Extends LinkMetadata

NameTypeDefaultDescription
displayLinkPreviewDisplay—

Display variant chosen when the link was inserted. Absent → tout.

urlrequiredstring—

Canonical URL — typically <link rel="canonical"> or the redirected URL.

From LinkMetadata

titlestring—

Page title (og:title or <title>).

From LinkMetadata

descriptionstring—

Short description (og:description or meta description).

From LinkMetadata

imagestring—

Hero image URL (og:image or twitter:image).

From LinkMetadata

siteNamestring—

Site name (og:site_name).

From LinkMetadata

faviconstring—

Site favicon URL.

From LinkMetadata

authorstring—

Author / publisher (og:article:author, etc.).

From LinkMetadata

publishedAtstring—

Published date string when available.

From LinkMetadata

Source: components/core/LinkPreview/renderLinkPreview.ts:33

Media embeds

The toolbar’s media item inserts the media kind. You can also insert it from your own plugin:

import { createMediaEmbedNode, mediaEmbedDataFromMedia } from '@blobs/sdk-solid';
ctx.insertNodeAtCaret(createMediaEmbedNode(mediaEmbedDataFromMedia(media)));
ctx.commit();

Payload of a media embed token.

NameTypeDefaultDescription
idrequiredstring—

Media row id.

namerequiredstring—

Display name (the original filename at insert time).

mimerequiredstring—

MIME type; image/* renders as an inline image.

blobIdstring—

Blob the media belongs to, when known.

Source: components/core/BlobContentEditor/embeds/media.ts:33

Options passed to a MediaEmbedUrlResolver.

NameTypeDefaultDescription
inlineboolean—

true for the inline view endpoint (default), false for download.

blobIdstring—

Blob the media belongs to, when the payload recorded it.

mimestring—

MIME type from the payload.

Source: components/core/BlobContentEditor/embeds/media.ts:45

Theming the extensions

All extension UI uses only theme variables, so every theme restyles it:

ElementVariables
Toolbar bar, popovers, context menu--surface-popover, --surface-control, --surface-control-hover, --color-border, --border-width, --radius-sm / --radius-md, --shadow-dropdown, --color-text-primary / -secondary / -tertiary, --color-primary (active and focus), --font-size-xs / -sm, --animation-speed, --animation-easing
Mention dropdownThe same popover variables.
Mention chips (.blob-mention)--blob-mention-tint, which defaults to --color-primary (users), --color-success (blobs, .blob-mention--blob) and --color-warning (files, .blob-mention--file). The chip background and text are mixed from the tint.
Link previews--surface-card, --color-border, the text colours, and --link-preview-thumb (tout image size, default 88px).
Media embeds (.blob-media-embed)The card and border variables. Images are limited to max-width: 100%.

To change one colour, override the variable on a container:

.my-editor-host .blob-mention--user { --blob-mention-tint: #db2777; }
.my-editor-host { --link-preview-thumb: 64px; }

Known limitations

  • A mention pick cannot be undone with Ctrl/⌘+Z. The plugin changes the DOM directly, and the browser’s undo stack does not record this change. To remove a mention, delete the chip.
  • A changed toolbar or mentions setting applies to the next editor that opens. The link lookup switch applies immediately.
  • The “My connections” users scope is not available yet. The backend replies supported: false, and the dropdown shows “Connections aren’t available yet”.
  • “Find files” is a placeholder. Searching the files of other blobs has no UI yet. The default action only shows “Searching other blobs’ files is coming soon” through host.notify. Give onFindFiles to replace it.
  • The mention click behaviour is global. The createMentionsPlugin call made last sets how rendered mentions open in the whole app.
  • Media embeds need a URL resolver. Without setMediaEmbedUrlResolver (which BlobView installs), image atoms render as chips with the name only.