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:
| Component | Role |
|---|---|
BlobView | Host-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. |
BlobContentEdit | Wraps 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). |
BlobContentEditor | The editor itself. Use it directly when you need an editor outside a blob surface. |
BlobContentView | The 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
| Name | Type | Default | Description |
|---|---|---|---|
contentrequired | string | — | Initial content string. Read once on mount (the editor is uncontrolled). |
contentType | string | — | Content format: |
onChange | (content: string) => void | — | Serialized content, emitted on blur and on |
onBlur | () => void | — | Called on blur |
onFocus | () => void | — | Called on focus |
placeholder | string | — | Placeholder text when empty |
readOnly | boolean | — | Whether the editor is read-only |
serializerConfig | SerializerConfig | — | Custom serializer configuration |
class | string | — | Custom class name |
autoFocus | boolean | — | Auto-focus on mount |
plugins | readonly EditorPlugin[] | — | Optional input plugins. Each plugin opts into a set of triggers ( |
host | EditorHost | — | App services for plugins (SDK, current blob and its files, upload, open-file, navigation). Read live — plugins always see the current value. See |
mode | EditorEngine | — | Editing engine: |
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.
| Name | Type | Default | Description |
|---|---|---|---|
blobrequired | Blob | — | The blob being edited. Its |
onContentChange | (content: string) => void | — | Latest serialized content — fired when the editor emits (on blur, on a plugin |
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). |
isDirty | boolean | — | There are unsaved edits — drives the unsaved indicator and Save state. |
loading | boolean | — | Show a loading state instead of the editor. |
error | string | — | Show this error instead of the editor. |
currentUserId | string | — | Signed-in user id (reserved for author-specific affordances). |
hideCancel | boolean | — | Hide the cancel button (for always-edit mode) |
plugins | readonly EditorPlugin[] | — | Editor input plugins. Pass-through to |
editorHost | EditorHost | — | App services for editor plugins. Pass-through to |
editorMode | BlobEditorMode | — | Editing engine. |
showHeader | boolean | — | 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.
| Engine | What the author sees | Formatting | Picked when |
|---|---|---|---|
raw | The 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. |
rich | The 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 / press | Result |
|---|---|
# … ###### at the start of a line | Heading 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 + Enter | Code 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 these | Undo it — the literal characters come back |
| Enter in a list | New item (a task item starts unchecked). Enter on an empty item leaves the list. |
| Shift+Enter in a list | Break 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+Enter | Line break, also inside a list item |
| Tab / Shift+Tab in a list | Indent / outdent |
| Mod+B / I / E, Mod+Shift+X | Bold / italic / inline code / strike |
| Mod+Z, Mod+Shift+Z / Mod+Y | Undo / 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:
| Formatting | Written as |
|---|---|
| Bold / italic / strikethrough / inline code | **b**, _i_, ~~s~~, `c` |
| Heading (text size) | # , ## , ### |
| Lists / tasks / quote | - , 1. , - [ ] , > |
| Code block / rule / link | fenced ```, ---, [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%%KINDmatches/^[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:
| Surface | How |
|---|---|
| Editor | On 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 excerpts | Same decoration pass. |
The rules for parsers:
renderis synchronous and must not fetch. Put network data into the payload while the user authors the content (the link-preview plugin stores the fetchedLinkMetadata).- The decoration stamps every rendered node with
data-embed-kind,data-embed-payloadandcontenteditable="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:
| kind | Payload | Renders | Inserted by |
|---|---|---|---|
link | LinkEmbedPayload: LinkMetadata (url, title?, description?, image?, siteName?, favicon?, author?, publishedAt?) plus display? (tout · card · link) | A preview in the stored display variant. | Link Lookup |
mention | MentionEmbedPayload: { type: 'user' | 'blob' | 'file', id, name, href?, blobId?, mime? } | <a class="blob-mention blob-mention--{type}"> with a type icon and the name. | Mentions |
media | MediaEmbedData: { 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
contentis 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.onChangefires when the editor loses focus, and when a plugin callsctx.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 theinputtrigger and callsctx.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 focusif (isDirty()) await save();Full page — BlobPage (/blob/:id)
- Dirty state.
isDirtybecomes 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.editorEpochis the key of a keyed<Show>aroundBlobView, 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 setsparent_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.idisundefinedandhost.uploadFileisundefined. Extensions must allow for this.
Modal, dock and expand — BlobSurface
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).BlobSurfacedoes 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) throughapiRef.closeTopLayer(). - Dashboard dock / expand: changing to a different blob while there are unsaved edits asks “Discard unsaved changes to this blob?”.
- Modal (
- Escape inside the editor is not captured in the modal (
onEscapeis 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
| Name | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | Stable id for debugging / dedup ( |
triggers | readonly EditorPluginTrigger[] | — | Triggers this plugin reacts to via |
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 |
mount | (ctx: EditorPluginContext) => void | (() => void) | — | Called once per editor instance after it mounts. Render persistent UI into |
Source: components/core/BlobContentEditor/plugins/types.ts:228
Triggers
| Trigger | When it fires | event |
|---|---|---|
space / enter | On key-up of that key, so the character is already in the DOM. | KeyboardEvent |
paste | In a microtask after the paste, so the pasted content is already in the DOM. | ClipboardEvent |
input | On any content change (typing, deleting, end of IME composition). | InputEvent |
selectionchange | When the caret or selection moves inside this editor. | — |
focus / blur | When the editor gets or loses focus. blur runs before the commit, so a plugin can make a final change. | — |
contextmenu | On 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.
| Name | Type | Default | Description |
|---|---|---|---|
editorrequired | HTMLElement | — | The contenteditable root element. |
overlayrequired | HTMLElement | — | Absolutely-positioned layer covering the editor wrapper, for plugin UI (toolbars, dropdowns, popovers). Children are positioned with |
topSlotrequired | HTMLElement | — | 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. |
hostrequired | EditorHost | — | Host services; see |
formatrequired | string | — | Content format of this editor ( |
readOnlyrequired | boolean | — | 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 |
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 |
insertAtrequired | (anchor: Node | null, node: Node) => void | — | Insert a node just before |
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 |
prefixLinesrequired | (prefix: string, options?: { numbered?: boolean }) => void | — | Prefix every line touched by the selection with |
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 |
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. |
enginerequired | EditorEngine | — | Which engine renders this editor:
Plugins should prefer |
runrequired | (command: EditorCommand) => boolean | — | Apply a semantic editing command. |
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 |
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; |
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 emitonChange. Callctx.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()onmousedown. If they do not, the click moves focus out of the editor, which causes a blur and a commit, and the selection is lost. overlayis an absolutely positioned layer over the editor withpointer-events: none. Its direct children getpointer-events: auto. To position UI at the caret, usectx.toOverlayCoords(ctx.getSelectionRect()).topSlotis 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
readOnlyis true, do not change the content. - Write extensions for both engines.
ctx.engineisraworrich. In the rich engine ProseMirror owns the DOM, so direct DOM changes toctx.editorare overwritten. Use the engine-neutral calls:ctx.run(command)/ctx.isActive(command)for formatting (bold,heading,bulletList,link,color,undo, … — seeEditorCommand),ctx.replaceRange(range, node)/ctx.replaceTextRange(...)/ctx.insertNodeAtCaret(node)to insert content (an element withdata-embed-kind+data-embed-payloadbecomes an embed atom), andctx.markPending(range)for async replacements: it highlights the range, follows it through later edits, and givesresolve(node),cancel()andisAlive(). Reading the DOM (the selection, text nodes) works in both engines.wrapSelectionandprefixLinesstill work in the rich engine for the usual markers (**,_,~~,`,#,-,1.,>).
A rectangle in viewport coordinates (same shape as DOMRect's fields).
| Name | Type | Default | Description |
|---|---|---|---|
toprequired | number | — | — |
leftrequired | number | — | — |
bottomrequired | number | — | — |
rightrequired | number | — | — |
widthrequired | number | — | — |
heightrequired | number | — | — |
Source: components/core/BlobContentEditor/plugins/types.ts:60
A range awaiting async replacement; see EditorPluginContext.markPending.
| Name | Type | Default | Description |
|---|---|---|---|
resolverequired | (node: Node) => void | — | Replace the range with |
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).
| Name | Type | Default | Description |
|---|---|---|---|
sdk | BlobsInstance | — | SDK instance for API calls (search, metadata, uploads). |
network | string | — | Network to route SDK calls through. |
blob | Blob | — | The blob being edited. |
files | readonly Media[] | — | The blob's current attachments (kept fresh by the host). |
folders | readonly MediaFolder[] | — | The blob's attachment folders. |
currentUserId | string | — | 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 |
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.
| Name | Type | Default | Description |
|---|---|---|---|
kindrequired | string | — | Stable identifier used in the |
renderrequired | (data: T) => HTMLElement | — | Render the payload as a DOM element. Must produce a complete, self-contained node — the editor will treat it as 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:
| Setting | Values | Plugin |
|---|---|---|
| Link previews | on / off | createLinkLookupPlugin({ enabled, requestOptions: () => ({ lang: navigator.language }) }) |
| Editor toolbar | Floating (on text selection) · Always visible · Right-click menu only · Off | createToolbarPlugin({ display: [mode, 'contextmenu'] }) — right-click opens the toolbar in every mode but Off — or no toolbar |
| Mentions | Blobs # · 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.
| Name | Type | Default | Description |
|---|---|---|---|
display | ToolbarDisplay | readonly ToolbarDisplay[] | ['floating', 'contextmenu'] | Display mode(s). Several may be combined, e.g. |
items | readonly 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, |
contextMenuItems | readonly ToolbarItem[] | — | Items for the right-click menu only (same format as |
colors | readonly ToolbarColor[] | DEFAULT_TOOLBAR_COLORS — Accent, Success, Warning, Error, Muted (theme variables) | Palette for the |
shortcuts | boolean | true | 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 |
markdownHint | boolean | true | 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 |
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 | null | the media embed resolver (setMediaEmbedUrlResolver), else host.sdk.media.getMediaUrl(id, { inline: true, includeToken: true, network: host.network }) | Thumbnail URL for a file in the |
ariaLabel | string | 'Formatting' | Accessible name of the toolbar element. |
Source: components/core/BlobContentEditor/plugins/toolbar/options.ts:156
Display modes
| Mode | Behaviour |
|---|---|
static | Always 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.
| id | writes |
|---|---|
bold | **text** |
italic | _text_ |
strikethrough | ~~text~~ |
code | `text` |
heading | dropdown: Normal / # / ## / ### / <small>text</small> |
color | palette: <span style="color: …">text</span> |
link | URL popover: [text](url) |
bulletList | - line prefix |
numberedList | 1. 2. … line prefixes |
taskList | - [ ] line prefix |
quote | > line prefix |
codeBlock | ``` fence around the selection |
divider | a --- line |
media | file picker / upload: a media embed atom |
clearFormatting | strips markdown markers from the selection |
undo / redo | browser undo stack |
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:
headingopens a dropdown: Normal,#,##,###, or Small (<small>…</small>). It replaces a heading marker that is already on the line.coloropens a palette with thecolorsoption 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.linkopens a URL field. If the selection is already[text](url), the link gets the new target. The URL is normalized:http(s),mailto,teland relative links (/x,./x,#x) are accepted. A bare domain getshttps://. Other schemes (for examplejavascript:) are refused. Spaces and parentheses are percent-encoded, so the URL cannot break out of[text](url).mediaopens a file picker forhost.files. It has a filter field (case-insensitive name match, up to 50 files shown) and thumbnails for images. Whenhost.uploadFileis available, it also has Upload file…, which uploads into the blob and then inserts the new file. The selected file is inserted as amediaembed. If the upload fails, the error shows in the picker and throughhost.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”.clearFormattingexpands the selection over the markers next to it and removes the inline markdown markers.undo/redouse the browser’s undo stack.
Keyboard
| Keys | Action |
|---|---|
| Ctrl/⌘+B · I · Shift+X · E · K | Bold · 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 / End | Move between toolbar buttons (roving focus). |
| Escape | Closes 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'), }, ],});rungets the plugin context. Use the editing helpers. The toolbar callsctx.commit()afterrunfinishes (also whenrunreturns a promise).isActive(pressed state) anddisabledare checked again on each selection change. Ifdisabledreturns a string, the string is the tooltip.iconis trusted SVG markup. Never give user input. When you do not set it, the first letter oflabelshows.shortcutis only a hint in the tooltip. To bind the key, write a small plugin withonKeyDown.
A consumer-defined toolbar button.
| Name | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | Unique id (must not clash with a built-in id). Stamped as |
labelrequired | string | — | Accessible name; also the tooltip and the label in the context menu. |
icon | string | — | Icon as an SVG markup string (24×24 viewBox, |
shortcut | string | — | 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 ( |
isActive | (ctx: EditorPluginContext) => boolean | — | Pressed state ( |
disabled | (ctx: EditorPluginContext) => boolean | string | — | Disabled state. Return a string to disable the item and use the string as the tooltip explaining why; |
formats | boolean | true | Whether this item writes markdown formatting — when true the toolbar sets the blob's |
Source: components/core/BlobContentEditor/plugins/toolbar/options.ts:70
A colour swatch for the color item.
| Name | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | Accessible name ("Accent"). |
valuerequired | string | — | Any CSS colour. Prefer theme variables ( |
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.
| Name | Type | Default | Description |
|---|---|---|---|
enabled | { users?: boolean | MentionUsersOptions; blobs?: boolean | MentionBlobsOptions; files?: boolean | MentionFilesOptions; } | {} | Which categories are on. Every category is OFF unless listed here. Pass |
debounceMs | number | 200 | 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
| Category | Default char | Scopes (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.
| Name | Type | Default | Description |
|---|---|---|---|
char | string | '#' (blobs), '@' (users), '&' (files) | The control character that opens this category's dropdown. Must be a single character and unique across enabled categories. |
scope | string | 'all' (users), 'mine' (blobs), 'blob' (files) | Initial scope id. Must be one of |
scopes | readonly MentionScopeOption[] | users: all, connections · blobs: mine, public, all · files: blob, find | Scopes the context-bar link cycles through. Replaces the defaults. |
limit | number | 8 | Maximum results requested and shown. |
search | MentionSearchFn | — | Custom search for this category; bypasses the SDK and local ranking. |
Source: components/core/BlobContentEditor/plugins/mentions/types.ts:114
| Name | Type | Default | Description |
|---|---|---|---|
open | MentionLinkOpen | 'same-tab' | How a clicked user mention opens. |
char | string | '#' (blobs), '@' (users), '&' (files) | The control character that opens this category's dropdown. Must be a single character and unique across enabled categories. From |
scope | string | 'all' (users), 'mine' (blobs), 'blob' (files) | Initial scope id. Must be one of From |
scopes | readonly MentionScopeOption[] | users: all, connections · blobs: mine, public, all · files: blob, find | Scopes the context-bar link cycles through. Replaces the defaults. From |
limit | number | 8 | Maximum results requested and shown. From |
search | MentionSearchFn | — | Custom search for this category; bypasses the SDK and local ranking. From |
Source: components/core/BlobContentEditor/plugins/mentions/types.ts:141
| Name | Type | Default | Description |
|---|---|---|---|
open | MentionLinkOpen | 'same-tab' | How a clicked blob mention opens. |
char | string | '#' (blobs), '@' (users), '&' (files) | The control character that opens this category's dropdown. Must be a single character and unique across enabled categories. From |
scope | string | 'all' (users), 'mine' (blobs), 'blob' (files) | Initial scope id. Must be one of From |
scopes | readonly MentionScopeOption[] | users: all, connections · blobs: mine, public, all · files: blob, find | Scopes the context-bar link cycles through. Replaces the defaults. From |
limit | number | 8 | Maximum results requested and shown. From |
search | MentionSearchFn | — | Custom search for this category; bypasses the SDK and local ranking. From |
Source: components/core/BlobContentEditor/plugins/mentions/types.ts:150
| Name | Type | Default | Description |
|---|---|---|---|
open | MentionFileOpen | 'modal' | How a clicked file mention opens: the host's file viewer ( |
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 |
char | string | '#' (blobs), '@' (users), '&' (files) | The control character that opens this category's dropdown. Must be a single character and unique across enabled categories. From |
scope | string | 'all' (users), 'mine' (blobs), 'blob' (files) | Initial scope id. Must be one of From |
scopes | readonly MentionScopeOption[] | users: all, connections · blobs: mine, public, all · files: blob, find | Scopes the context-bar link cycles through. Replaces the defaults. From |
limit | number | 8 | Maximum results requested and shown. From |
search | MentionSearchFn | — | Custom search for this category; bypasses the SDK and local ranking. From |
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:
CHARis at the start of a word: at the start of a line or text node, after whitespace, or after opening punctuation( [ { " ' “ ‘ <. So[email protected]anda#bdo 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.
Dropdown, scopes and keys
- 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 itslabel). 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
onSelectis 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: falsefor a scope, the dropdown shows theunsupportedMessageof 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
blobscope, results fromhost.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.qis 100 characters or less,limitdefaults to 8 (maximum 25), andblob_idis used for files in theblobscope. 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.
| Name | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | Scope id sent to the search API as |
labelrequired | string | — | Label shown in the context bar while this scope is active ("All users"). |
switchLabel | string | label | Label of the switch link that activates this scope. |
unsupportedMessage | string | "This search isn't available yet" | Message shown instead of results when the backend answers |
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).
| Name | Type | Default | Description |
|---|---|---|---|
typerequired | MentionItemType | — | Singular item type. |
idrequired | string | — | Stable id (user, blob or media UUID). |
namerequired | string | — | Display name — what the inserted mention shows. |
subtitle | string | null | — | Muted secondary line in the dropdown (username, blob author, file size…). |
href | string | null | — | Link target. Users |
avatar_url | string | null | — | Avatar image for users. |
mime_type | string | null | — | MIME type for files. |
blob_id | string | null | — | Owning blob for files. |
score | number | — | Server relevance score (higher is better). Informational. |
Source: components/core/BlobContentEditor/plugins/mentions/types.ts:29
Writing a custom mention search
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.
| Name | Type | Default | Description |
|---|---|---|---|
typerequired | MentionCategory | — | Category being searched. |
qrequired | string | — | Text typed after the control character (may be empty). |
scoperequired | string | — | Active scope id (e.g. |
blobId | string | — | Current blob id (used by the |
limitrequired | number | — | 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>| Where | Plain click | Ctrl/⌘ + click | Shift / Alt + click |
|---|---|---|---|
| Inside the editor | Opens 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 viewer | Opens 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.
| Name | Type | Default | Description |
|---|---|---|---|
usersrequired | MentionLinkOpen | 'same-tab' | How user mentions open. |
blobsrequired | MentionLinkOpen | 'same-tab' | How blob mentions open. |
filesrequired | MentionFileOpen | 'modal' | How file mentions open. |
fileHref | (payload: MentionEmbedPayload) => string | undefined | — | Builds a file mention's |
Source: components/core/BlobContentEditor/plugins/mentions/mentionEmbed.ts:50
Payload of a persisted mention embed token.
| Name | Type | Default | Description |
|---|---|---|---|
typerequired | MentionItemType | — | — |
idrequired | string | — | — |
namerequired | string | — | — |
href | string | — | — |
blobId | string | — | Owning blob (files). |
mime | string | — | MIME type (files) — picks the icon. |
Source: components/core/BlobContentEditor/plugins/mentions/types.ts:224
Link Lookup
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.
| Name | Type | Default | Description |
|---|---|---|---|
source | LinkLookupSource | 'auto' | Where metadata comes from.
|
resolver | LinkMetadataResolver | CachingLinkResolver | microlinkResolver (https://api.microlink.io, rate-limited free tier) | Client-side resolver, used for |
requestOptions | LinkMetadataRequestOptions | (() => LinkMetadataRequestOptions | undefined) | undefined (server defaults) | Request variables forwarded to the backend endpoint, overriding its defaults — e.g. |
display | LinkPreviewDisplay | 'tout' | How a resolved link renders. Stored in the embed payload, so a preview keeps its look in every viewer.
|
detectBareWww | boolean | true | Detect scheme-less |
detectOnType | boolean | true | Convert links as they are typed (on space / enter after the link). |
detectOnPaste | boolean | true | Convert links found in pasted text. |
maxPerPaste | number | 10 | Most links converted from a single paste; the rest stay text, so pasting a long list doesn't fire dozens of lookups. |
cache | LinkLookupCache | sharedLinkLookupCache | 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 gethttps://(setdetectBareWww: falseto 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 orenabled()is false.
What happens to one link
- 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.
- Metadata is resolved from the
source. - Success: the pill becomes the preview, stamped as a
linkembed with the display variant in the payload, and the plugin callsctx.commit(). Failure, or metadata with no title, description or image: the pill changes back to the original text.
Sources
source | SDK has links.metadata | Tried, in order |
|---|---|---|
auto (default) | yes | backend → client |
auto | no | client |
backend | yes | backend |
backend | no | nothing (the link stays text) |
client | either | client |
- 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
resolveroption. The default ismicrolinkResolver(api.microlink.io, with a rate-limited free tier). requestOptionsare sent to the backend, for example{ lang: 'fr' }, which is sent asAccept-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.
| Name | Type | Default | Description |
|---|---|---|---|
lang | string | — | Preferred language for the fetched page (forwarded by the server as |
Source: services/linkMetadata.ts:260
Display variants
display | Renders |
|---|---|
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. |
card | A larger card: a wide image at the top, then the text. |
link | A 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 usessource|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 afterfailureTtlMs).createLinkMetadataCache(resolver)is the older url-keyed wrapper, now a thin layer over the keyed cache.
| Name | Type | Default | Description |
|---|---|---|---|
urlrequired | string | — | Canonical URL — typically |
title | string | — | Page title ( |
description | string | — | Short description ( |
image | string | — | Hero image URL ( |
siteName | string | — | Site name ( |
favicon | string | — | Site favicon URL. |
author | string | — | Author / publisher ( |
publishedAt | string | — | Published date string when available. |
Source: services/linkMetadata.ts:30
Payload of a persisted link embed: the metadata plus how to show it.
| Name | Type | Default | Description |
|---|---|---|---|
display | LinkPreviewDisplay | — | Display variant chosen when the link was inserted. Absent → |
urlrequired | string | — | Canonical URL — typically From |
title | string | — | Page title ( From |
description | string | — | Short description ( From |
image | string | — | Hero image URL ( From |
siteName | string | — | Site name ( From |
favicon | string | — | Site favicon URL. From |
author | string | — | Author / publisher ( From |
publishedAt | string | — | Published date string when available. From |
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.
| Name | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | Media row id. |
namerequired | string | — | Display name (the original filename at insert time). |
mimerequired | string | — | MIME type; |
blobId | string | — | Blob the media belongs to, when known. |
Source: components/core/BlobContentEditor/embeds/media.ts:33
Options passed to a MediaEmbedUrlResolver.
| Name | Type | Default | Description |
|---|---|---|---|
inline | boolean | — |
|
blobId | string | — | Blob the media belongs to, when the payload recorded it. |
mime | string | — | 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:
| Element | Variables |
|---|---|
| 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 dropdown | The 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. GiveonFindFilesto replace it. - The mention click behaviour is global. The
createMentionsPlugincall made last sets how rendered mentions open in the whole app. - Media embeds need a URL resolver. Without
setMediaEmbedUrlResolver(whichBlobViewinstalls), image atoms render as chips with the name only.