Turnkey editor

ui-editor (imported as UiEditor from @threadlabs/looma through the HTML Next Vite plugin) is a complete rich-text editor. The application passes content and editability, answers the editor's requests for uploads, mentions, and links, and persists the document the editor emits; Looma owns the extensions, formatting commands, selection and focus, and every editor control. See Getting started for installation and a first example.

Props

PropDefaultPurpose
contentan empty documentThe document, as Tiptap JSON. Read event.detail.content from change.
editabletrueWhether the document can be edited.
contentDensitycomfortablecompact fits a read-only excerpt to its content without outer padding. Editable documents retain the standard canvas.
placeholderType “/” for commands, or start writing…Shown in an empty document.
labelDocumentThe editing surface's accessible name.
referencesnoneCached reference labels; replacing the map refreshes their display without changing document content.
compactToolbarfalseKeep frequent inline commands visible and open the full command set through Format.
selectionActions[]Application commands for a selected passage, reported through selection-action.
rangeActions[]Application commands at a matching caret range, reported through range-action.
mentionItems, mentionLimit[], 8Fixed mention choices and maximum results. Answer mention-query to search.
linkTargetLabel, linkBaseUrlItem, ""Destination label and origin used to resolve relative links.
toolbarModebubblebubble shows the toolbar beside a selection; sticky keeps it pinned above the document; contextual offers complete selection tools; popover opens it from your own button (see Opening the toolbar from your own button).
toolbarTriggerId""In popover mode, the ID of the button that toggles the toolbar and anchors it.
toolbarOpenfalseIn popover mode, whether the toolbar is open. Listen for toolbar-open and toolbar-close to update the host state.
disableHighlightfalseAuthors can't highlight text. Highlights already in the document still show; see Reserving highlight.

Requests

The editor asks the application for what only the application knows: its uploads, its people, its pages, and its own Tiptap extensions. Each of these is an HTML Next request. Answer it with a function prop, which receives the request's detail and { signal, progress } and returns the answer, null for none, or a promise of either:

<UiEditor :mention-query="({ query, limit }, { signal }) => searchPeople(query, limit, signal)" />

The same request also arrives as an event: a RequestEvent that bubbles from the editor and is answered with event.respondWith(answer). Listen with Vue's event form, on an ancestor, or on the document, where HTML Next's RequestEvent.respond() adds the listener and announces it. The nearest responder answers: the function prop first, then the event form, then ancestors, then the document. An application can set a default for every editor on document and override it for one editor.

import { RequestEvent } from "@nextwebwg/html-next/runtime";

const { respond } = RequestEvent;
const stop = respond(document, "image-upload", async ({ file }, { signal, progress }) => ({
  src: await upload(file, { signal, onProgress: progress }),
}));

signal aborts when the answer is no longer needed: the author typed another character, canceled an upload, or the editor unmounted. Insert image and link search appear while the editor can see a responder: its function prop, its event form, or one registered on an ancestor or the document with RequestEvent.respond(), a component controller's host.on, or a framework component's function prop, whether it arrived before or after the editor mounted. A plain addEventListener still answers requests, including pasted and dropped images, but the editor cannot see it, so Insert image and link search stay off for it; register with RequestEvent.respond() instead.

Each request's function prop is its name in camelCase: image-upload is imageUpload.

RequestDetailAnswer
slash-commands{ defaults }slash commands
mention-query{ query, limit }mention items
link-search{ query }link targets
link-resolve{ href }a link target
image-upload{ file }an image to insert
file-upload{ file }{ href, name, size, contentType }
code-languagesnullgrammar map; null keeps lazy defaults, {} disables highlighting
image-deliverynull{ resolveSource?, resolveAttributes? } transient image policy

extensions

The editor asks when it mounts, and again when a responder first appears. The answer's Tiptap extensions are added after Looma's own. When the answer arrives after the editor was built, for example from a lazily imported module or a responder that appears later, the editor rebuilds with them and keeps its document. A document nobody has edited yet is read again from content, so nodes and marks the new extensions define are kept.

import type { LoomaEditorExtensions } from "@threadlabs/looma/editor";

const extensions: LoomaEditorExtensions = async () => [(await import("./footnote")).Footnote];

slash-commands

The editor asks when it mounts, when a responder appears, and when image uploads become available. After changing an existing responder’s inventory, send the editor a native --refresh-services command. The detail carries the editor's default commands. Return them changed or extended; null keeps the defaults and an empty array offers no commands. See Host commands.

import type { LoomaEditorSlashCommands } from "@threadlabs/looma/editor";

const slashCommands: LoomaEditorSlashCommands = ({ defaults }) => defaults.filter(({ id }) => id !== "table");

mention-query

The editor asks as the author types after @. Answer with at most limit people for query. null falls back to mentionItems, and a rejection shows an empty menu. Each new character aborts the previous query's signal.

import type { LoomaMentionQuery } from "@threadlabs/looma/editor";

const mentionQuery: LoomaMentionQuery = async ({ query, limit }, { signal }) =>
  (await fetch(`/api/people?q=${encodeURIComponent(query)}&limit=${limit}`, { signal })).json();

The editor asks as the author types in the link field. Answer with destinations matching query, each with id, label, href, and optional detail. With a responder the link field searches; without one it asks for a URL. A rejection shows a retryable search error.

import type { LoomaLinkSearch } from "@threadlabs/looma/editor";

const linkSearch: LoomaLinkSearch = async ({ query }, { signal }) =>
  (await fetch(`/api/pages?q=${encodeURIComponent(query)}`, { signal })).json();

The editor asks when a saved link shows its card. Answer with the current destination for its href, or null, so a renamed page shows its new name in the link card. The saved href never changes.

import type { LoomaLinkResolve } from "@threadlabs/looma/editor";

const linkResolve: LoomaLinkResolve = ({ href }) => pagesByHref.get(href) ?? null;

image-upload

The editor asks for each chosen, pasted, or dropped image. Upload file and answer with the image to insert, { src, alt?, width?, height?, responsive? }, or null to insert nothing. The editor shows the upload's progress from progress(loaded, total) with a Cancel button, which aborts signal. A rejection leaves the document unchanged, shows Retry, and emits upload-error; a canceled upload reports nothing.

import type { LoomaImageUploader } from "@threadlabs/looma/editor";

const imageUpload: LoomaImageUploader = ({ file }, { signal, progress }) => new Promise((resolve, reject) => {
  const request = new XMLHttpRequest();
  request.open("POST", "/api/images");
  request.upload.onprogress = (event) => progress(event.loaded, event.total);
  request.onload = () => resolve({ src: JSON.parse(request.responseText).url, alt: file.name });
  request.onerror = () => reject(new Error("Upload failed"));
  signal.addEventListener("abort", () => request.abort());
  request.send(file);
});

file-upload

Answer fileUpload with the retained file's { href, name, size, contentType }, or null to insert nothing. The application owns storage, access and size limits. File attachment in the insertion menu and Attach file in the toolbar open the file picker when a responder is available. Looma inserts the filename as an ordinary link, followed by its type and byte-size label. The caret is mapped through intervening edits while transport runs. Cancel or unmount prevents a late answer from inserting; rejection emits upload-error and keeps the document.

image-delivery

Answer with synchronous resolveSource and/or resolveAttributes functions. The editor resolves this policy before constructing image node views. URLs returned by the policy are browser-only: stored JSON and image events retain the original source. Register the responder before mounting; send --refresh-services after changing the policy.

Toolbar

Markdown typing conversions are separate undo steps. Undo restores the complete literal source; Redo reapplies its formatting. Escape immediately after a conversion also restores the literal source when no menu is open. Typing or moving the caret ends that Escape cancellation opportunity. Open menus retain their usual Escape dismissal.

The toolbar is always one row, in four groups. When it is wider than the space it has, the row scrolls sideways instead of wrapping, and each edge that hides controls fades into the toolbar's colour:

  • Text: Bold, Italic, Underline, Strike, Highlight, Inline code, Link.
  • Blocks: Heading 1–3, Bullet list, Numbered list, Blockquote, Code block, Block actions.
  • Insert: Table (opens the size grid), Image.
  • History: Undo, Redo.

A mark or block that applies to the selection is shown as active: a tint and an inset edge in the accent, with aria-pressed="true", never a solid fill. On a narrow screen the toolbar docks to the bottom of the viewport. Its buttons follow the shared toolbar tokens, plus --ui-editor-toolbar-button-size, --ui-editor-toolbar-button-radius, --ui-editor-toolbar-icon-size, and the active-state --ui-editor-toolbar-active-surface, --ui-editor-toolbar-active-text, and --ui-editor-toolbar-active-border.

Opening the toolbar from your own button

Set toolbar-mode="popover" when the page already has a place for tools, such as a page bar. Give the editor the ID of your button: clicking it opens the full toolbar in a popover anchored there, and clicking again, pressing Escape, or clicking elsewhere closes it. The button needs no click handler. Selecting text still shows a small bubble with the text controls (Bold through Inline code).

<UiIconButton id="formatting" label="Formatting">…</UiIconButton>
<UiEditor toolbar-mode="popover" toolbar-trigger-id="formatting" :toolbar-open="formattingOpen" @toolbar-open="formattingOpen = true" @toolbar-close="formattingOpen = false" />

Reserving highlight

Set disable-highlight when the application uses highlighting for its own purposes, such as marking what changed, and an author's highlight would be mistaken for it. The editor then offers no Highlight button, Mod-Shift-H does nothing, typing or pasting ==text== stays plain text, and pasted HTML <mark> keeps its text without the highlight. Highlights already stored in the document's JSON still load and render as <mark>; the mark stays in the schema because removing it would make the editor discard any document that uses it. The setting is read when the editor is created. Outside UiEditor, pass disableHighlight: true to getDefaultEditorExtensions.

Code block syntax

Code blocks keep their language in the document, whether or not a matching grammar is loaded. The searchable picker offers every bundled Highlight.js grammar. An explicitly chosen language loads only its own grammar when needed. Auto compares 20 common languages (Bash, C, C++, C#, CSS, Go, HTML, Java, JavaScript, JSON, Kotlin, Markdown, PHP, Python, Ruby, Rust, SQL, Swift, TypeScript, and YAML) when its code block has text. Documents without code do not download the grammars. HTML uses Highlight.js's XML grammar under the name html.

Answer the code-languages request before the editor is created to replace that list:

import sql from 'highlight.js/lib/languages/sql'

const codeLanguages = () => ({ sql })
<UiEditor :code-languages="codeLanguages" />

Install highlight.js in the application that imports a grammar. The example offers only SQL; return an empty object to keep code blocks plain. For a headless editor, getDefaultEditorExtensions({ codeLanguages }) takes an explicit grammar map. Untagged code blocks can be detected from the registered languages. Paste SQL into a code block to keep it as code. Plain text pasted into a paragraph stays prose. While the cursor is in an editable code block, a language selector floats above it, like the table toolbar, when languages are available. Auto shows the detected language; choosing a language saves that choice with the document. Choose Auto again to resume detection. Type three backticks at the start of an empty paragraph to create a code block and place the cursor inside it. Token colors use the theme's semantic text colors and can be overridden with --ui-editor-code-keyword, --ui-editor-code-string, --ui-editor-code-number, --ui-editor-code-name, and --ui-editor-code-comment.

Select text and choose Link to add one. Click or tap an existing link to place the caret and show nearby Open link, Edit, and Remove actions above it. Press Escape to dismiss the bar. Hold Command on macOS or Control on other computers while hovering over a link to see a pointer cursor, then click to open its destination in a new tab. Ordinary clicks keep editing. The mobile toolbar's Link button also edits the link at the caret. With no selection, the form also asks for link text. It accepts http:, https:, mailto:, tel:, and relative URLs beginning with /, #, or ?; other schemes are rejected before the document changes. The Open link action opens separately with noopener noreferrer. The Open in new tab choice updates the link's target and rel attributes directly. Valid destination and text changes apply immediately without moving focus from the field. There is no Save or Cancel footer; press Enter or Escape, or click outside, to close the popup. Incomplete or invalid URL entry leaves the last valid link intact. Open and Remove stay in the nearby link actions.

The form follows the same theme layers as the rest of Looma: its surface, border, radius and shadow read inherited --ui-overlay-* values; its input radius reads --ui-field-radius; its invalid message reads --ui-field-danger. Set those on the editor's container for a local treatment.

Block actions

Place the caret in a block and choose Block actions to insert a paragraph after it, duplicate it, or delete it. The menu acts on the top-level block even when the caret sits in a nested list or table cell. Deleting the only block leaves an empty paragraph to continue writing. The same menu works in the mobile toolbar; its trigger and items follow native button and menu keyboard behavior. Block type changes remain in the adjacent heading, list, quote, and code commands, and the slash menu provides insertion choices. This keeps block actions reachable without a hover-only gutter.

Slash menu

Typing / opens the slash menu, which inserts headings, Info, Note, and Warning callouts, bullet, numbered, and checklist lists, blockquote, a clickable inline chip, footnote, inline and block code, table, divider, and image. Markdown-style typing works too: # for headings, - for bullets, [ ] for a checklist, and --- for a divider.

Chip inserts an inline label without splitting its paragraph. Choosing /chip opens a focused popover: enter the label and choose neutral, blue, green, yellow, red, or purple. Click the chip to reopen that popover. The headless editor.commands.insertLoomaChip() command turns selected words into one chip or inserts an empty one at the caret. Chips are atomic editor nodes stored as semantic <span data-looma-chip data-label="…" data-color="…">…</span> elements, so their labels survive HTML and plain-text copy. Their borders sit between the soft fill and the stronger theme tone; hosts can override surface, border, text, and font size with --ui-editor-chip-* tokens. Plain pasted HTML spans do not turn into chips.

Footnote inserts a reference and opens a floating rich-text editor for its note (editor.commands.insertLoomaFootnote(), or Mod+Alt+F). Notes can contain formatting and multiple paragraphs. Changes update the document as you type; clicking outside or pressing Escape closes the editor.

References are numbered by their first occurrence, and deleting a note's final reference removes that note. Clicking an existing reference opens one-line actions: the Go to note icon and Edit note. The editor manages numbering and source identities automatically. Mod+Alt+N moves between a reference and its note (editor.commands.goToLoomaFootnote()). Saved HTML and Markdown retain the formatted notes and their definitions.

Pasting documents

Looma detects recognizable Markdown and HTML copied as source, including source wrapped in a clipboard code block, and turns supported headings, lists, links, tables, and code blocks into editable nodes. Pasting inside an existing code block stays literal. When a clipboard already contains recognizable formatted HTML, Looma keeps that formatting even if the plain-text copy happens to begin with a Markdown marker. Content copied from another editor page keeps its native structure.

Active-block marker

While the editor has focus, a thin line in the margin marks the top-level block being edited, so you can find your place without a border or fill around the text. It is deliberately quiet:

  • It steps aside while you type and returns once typing pauses (LOOMA_ACTIVE_BLOCK_TYPING_IDLE_MS, 800 ms).
  • Focus moving to the editor's own toolbar or menus, or leaving and coming straight back, does not remove it; it goes only once focus has stayed away for LOOMA_ACTIVE_BLOCK_BLUR_GRACE_MS (200 ms).
  • It fades in rather than snapping on, and appears instantly under reduced motion.
TokenDefaultPurpose
--ui-editor-active-block-color--ui-borderThe line's colour.
--ui-editor-active-block-width1pxThe line's width.
--ui-editor-active-block-offset--ui-space-3Its distance into the gutter from the text.

The line sits outside the content box, so a consumer that sets --ui-editor-content-padding-inline to 0 must leave it room on its own wrapper. The marker comes from the LoomaActiveBlock extension in @threadlabs/looma/editor/extensions, which adds the looma-active-block class to the block and nothing else.

Passage actions and compact formatting

compactToolbar is opt-in. On desktop it keeps Bold, Italic and Link beside Format, which exposes the existing complete formatting inventory. The narrow editing dock retains its formatting controls.

selectionActions accepts { id, label } commands owned by the application. They appear only for a nonempty text selection, before formatting in the narrow dock. Read-only selections expose the actions without exposing edit controls. selection-action emits { id, from, to, text, before, after }, captured before the host moves focus. These actions never mutate the document. The host owns permissions, durable annotation identity, panels and return navigation; positions are from the current editor snapshot, not a lasting anchor.

rangeActions accepts { id, label, from, to } actions for a collapsed text caret. Clicking inside a matching range opens the contextual toolbar, including in read-only documents; narrow viewports use the existing editing dock. Actions with the same ID share one button. range-action emits { id, ranges }, with unique matching { from, to } pairs captured before focus moves. Invalid or out-of-document ranges are ignored. The host recalculates these ephemeral positions after content changes and owns annotation persistence and paint.

Compact fields and rendering

UiCompactEditor uses the same content and change.detail.content contract for short rich text. It supports bold, italic, inline code, Markdown shortcuts, Undo and Redo without a document toolbar. Set label and optionally id for a stable textbox reference. Answer its extensions request before creation to add a schema extension.

UiCompactContent renders that JSON as escaped semantic HTML and works during SSR. Neither component imports the complete document editor.

Commands and migration

Send native CommandEvents to the editor root: --focus focuses the beginning, --focus-end focuses the end, and --refresh-services refreshes the document editor’s slash inventory and image delivery policy. Import CommandEvent from @nextwebwg/html-next/runtime. For a compact field, id identifies its textbox, so a native button can also use commandfor="message" command="--focus-end" with <UiCompactEditor id="message" />.

The source-only API uses HTML definitions. When migrating the former Vue entry points, use the following replacements: LoomaEditor, LoomaCompactEditor, and LoomaCompactContent become UiEditor, UiCompactEditor, and UiCompactContent from the package root with htmlNext({ target: "vue" }). Replace modelValue / update:modelValue with content / change.detail.content; event names use kebab case. There is no ready event, exposed editor instance, or exposed focus method. Types and headless extensions come from @threadlabs/looma/editor and /editor/compact. Applications that install Tiptap extensions can use their normal extension lifecycle for imperative integration.