Rich text

Editor

A ready-to-use writing surface with formatting, slash commands, mentions, tables, and image support. Start with the complete editor; configure its capabilities through props and callbacks.

Editor API reference

Try the editor

Keep formatting visible, or show it beside selected text.

The Fellowship office

Frodo is drafting the quarterly journey report. Sam has already booked second breakfast.

Enable JavaScript to try the live editor.

Chip color
Insert paragraph below
Duplicate block
Delete block

Scroll inside the sample to see the always-visible toolbar stay at the top, or switch to Beside selection and select text to see formatting appear nearby. Type / to insert a block or @ to mention a teammate. Edits here last only until you leave the page.

Footnotes

Type /footnote to add a reference and open its floating rich-text editor. The document numbers references automatically. Notes can contain formatting and more than one paragraph; edits update the document as you type.

Select an existing reference to open its one-line actions: Go to note and Edit note. Click outside the editor or press Escape to close it. There is no separate Done action.

Add it to a Vue app

Install Looma and the editor peers, import the theme, then use UiEditor with controlled JSON content:

<script setup lang="ts">
import { ref } from "vue";
import { UiEditor } from "@threadlabs/looma";
import "@threadlabs/looma/tokens.css";
import "@threadlabs/looma/theme-light.css";

const document = ref({ type: "doc", content: [{ type: "paragraph" }] });
</script>

<template>
  <UiEditor :content="document" @change="document = $event.detail.content" label="Article body" />
</template>

The value is Tiptap JSON. The app owns saving, loading, and collaboration; the editor emits a new document on each change. Getting started includes installation and a first integration.

Formatting and toolbar

Select text for bold, italic, underline, strike, highlight, inline code, and links. The editor also supports headings, lists, quotes, code blocks, tables, images, undo, and redo. Set toolbarMode="sticky" to keep the toolbar above the document, popover to open it from your own button, contextual for the full command set beside a selection or held-press caret, or use the default bubble mode beside a selection. Floating desktop tools stay quiet until text is selected (including Select All) or a primary pointer press is held for 500ms. Escape dismisses the current toolbar; moving the caret dismisses held-press tools. Link and table pickers keep their toolbar anchor while you edit. Small viewports retain the editing dock. Contextual controls stay within the document area and flip below the first paragraph when there is no room above it. On narrow screens the toolbar docks at the bottom and scrolls sideways when its controls do not fit.

Contextual formatting hides while slash-command or mention suggestions are active, then returns when the writer resumes editing.

Slash commands and blocks

Type / for headings, callouts, lists, a quote, code, a divider, a table, or a collapsible section. Image appears when the editor has an image uploader. Keep typing to narrow the grouped menu; use arrow keys and Enter to choose. Exact names and aliases rank ahead of partial matches; /callout puts callouts before the quote alias. An unmatched query shows No commands found and remains in the document until you edit it or choose a command. Escape dismisses suggestions. Literal code never opens slash suggestions. The Chip command inserts an inline label without splitting the paragraph; /status is an alias for the same command. Choose its text and tone in the popover, then click the chip to edit it. Markdown-style shortcuts also work: # for headings, - for a bullet list, [ ] for a checklist, and --- for a divider.

Callouts have one command per tone: Info, Note, Tip, Important, Warning and Danger. Each wraps the current block in a callout drawn by the Callout component, and choosing another tone inside a callout changes its tone. The editor saves callouts as <ui-callout>, so they look the same in any page that uses Looma. Content saved earlier as <aside data-looma-callout> still loads.

Block actions can insert a paragraph after the current block, duplicate it, or delete it.

Host commands

Pass slashCommands to answer the editor's slash-commands request. It receives fresh defaults and returns the inventory to use; an empty array offers no commands and null keeps the defaults:

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

const slashCommands: LoomaEditorSlashCommands = ({ defaults }) => [
  ...defaults,
  {
    id: "host-reference",
    group: "Host content",
    title: "Reference",
    description: "Insert a reference label",
    icon: "tag",
    keywords: ["reference", "ref"],
    command: ({ editor, range }) => {
      editor.chain().focus().deleteRange(range).insertContent("Reference: ").run();
    },
  },
];

Use a unique id for each command, especially when titles repeat. group is optional, so existing headless command objects keep working. Each command owns removal of the supplied range. Supply a pure isAvailable({ editor, range }) predicate to omit a command when a host capability or editing context is unavailable; Looma checks it again before execution. After changing an existing inventory, send the editor a native --refresh-services command. A different inventory dismisses the open menu and keeps the typed query intact; the same inventory leaves the menu open. Return new arrays rather than mutating one in place.

Use this extension point for document templates, snippets, decision outlines, and date macros. The application owns its catalog, preview or date picker, and the text it inserts. A template uses ordinary editor nodes; validate a fresh copy with editor.schema.nodeFromJSON and check() before removing the command range. Keep an asynchronous picker's insertion range mapped through edits, and cancel its pending result when that range is no longer valid.

For example, a host can add a Today command whose value is fixed when selected:

{
  id: "today",
  title: "Today",
  description: "Insert today's date",
  icon: "tag",
  keywords: ["today", "date"],
  command: ({ editor, range }) => {
    const now = new Date();
    const date = [
      now.getFullYear(),
      String(now.getMonth() + 1).padStart(2, "0"),
      String(now.getDate()).padStart(2, "0"),
    ].join("-");
    editor.chain().focus().deleteRange(range).insertContent(date).run();
  },
}

This writes the local calendar date as unambiguous YYYY-MM-DD text. A host date picker can insert its chosen date in the same format. No live date node, reminder, or scheduling state is created.

Headless consumers use createLoomaSlashCommandExtension({ commands, onStateChange }) and filterLoomaSlashCommands. Menu snapshots supply select(index) and highlight(index); replace snapshots and discard old callbacks after updates. Call editor.commands.dismissLoomaSlashMenu() to dismiss without deleting the query. Default Link and Image commands require their picker callbacks. Default structural commands are omitted when the current editor schema or insertion context cannot apply them.

Collapsible sections

Type /expand, /toggle, or /details to insert an independent section. The caret starts in its body, which accepts ordinary blocks such as lists, code, and tables. Use Section settings to edit the summary and Initially expanded, then Save; Cancel discards the draft. Press Cmd+Enter (Ctrl+Enter on other platforms) in the body to create a paragraph after the innermost section and continue writing. On phones, use Block actions → Insert paragraph below to continue after the section through the existing toolbar.

Authors start with sections expanded. Readers start with the saved default and toggle through the shared Disclosure control by keyboard or touch. Reader toggles do not change the document or its saved default. TOC links reveal enclosing sections before scrolling to and focusing a heading. Hosts can await revealLoomaExpandAt(editor, position) before their own navigation.

The loomaExpand node stores { summary, open } plus ordinary block content. JSON and HTML round-trip that content; HTML exports semantic details/summary. Headless editors can insert with insertLoomaExpand({ summary: "Details", open: false }), leave with exitLoomaExpand(), and supply expandNodeView to customize presentation. The default headless view uses native details. Plain text includes summaries and all bodies, even while collapsed.

In Markdown, a section is ::: details [Summary] … :::, with {open} after the summary when it starts expanded. Search and change-review projections must traverse the body rather than omit collapsed content.

Automatic table of contents

Type /toc (or /contents) to insert links to headings throughout the document. The list updates when headings change, move, or disappear; headings inside another TOC are excluded. There is no content wrapper or manual heading selection. Open Table of contents settings to choose plain, bulleted, or numbered formatting and the maximum heading level (H1, H2, or H3), then Save. Cancel discards those choices. The default is plain formatting through H3. With no headings, the block explains how to populate it.

Read-only editors show the same navigation without settings. Activating an entry scrolls to and focuses its heading without opening link editing.

The loomaTableOfContents node stores only { format, depth }. When a TOC is present, every heading has an ID: an ID derived from the heading's text follows the text as it changes, a custom ID stays with its heading through renames and moves, and duplicate IDs are repaired. Both the node and heading IDs round-trip through editor JSON, HTML and Markdown. editor.getHTML() includes the current entries as a named navigation landmark with real links.

For Expressive Markdown, call editorDocumentToMarkdown(editor.getJSON()) from @threadlabs/looma/editor/expressmark. It writes the TOC as its ::toc placeholder with its settings, where it stands, and readers build the entries from the headings. Authored navigation imported from a file retains its body. For a self-contained export, pass { tableOfContents: "entries" } to editorDocumentToMarkdown or editorDocumentToExpressmark: automatic TOCs receive current links using the document's heading anchors, while authored navigation and the original editor JSON remain unchanged. This also expands a placeholder when original is supplied. Portable exports write explicit heading IDs, including inside authored navigation, so repeated labels cannot redirect saved links.

Headless editors using getDefaultEditorExtensions get the schema and a navigation node view; they can set choices with insertLoomaTableOfContents({ format: "numbered", depth: 2 }) or supply tableOfContentsNodeView for their own settings composition. The HTML Next editor already supply settings built with shared FormField, Button, Menu, and Popover controls.

For search, Markdown, plain text, or change-review projections, hosts can import getTableOfContentsEntries and normalizeTableOfContentsConfig from @threadlabs/looma/editor. The entry helper accepts a ProseMirror document and maximum heading level and returns current heading labels, IDs, levels, nesting depths, and positions. Use a linked list for Markdown or heading labels for plain text. A schema-only serializer such as Tiptap's generateHTML lacks whole-document context and emits a configuration placeholder for the TOC; use editor.getHTML() or derive entries in a document-aware host renderer when exporting complete navigation.

Hosts should qualify their import/export, indexing, and review projections for this new node before enabling it in stored documents.

Code blocks

Code blocks retain their language in the document. The searchable language picker offers every bundled Highlight.js grammar, including HTML, JavaScript, TypeScript, CSS, JSON, Python, SQL, and Bash. An explicit choice loads only its grammar on demand. Auto uses 20 common grammars when code is present to keep detection light. Pass a codeLanguages responder to replace the list with your own grammars, or {} for plain code blocks. Headless editors pass grammars to getDefaultEditorExtensions. Untyped code blocks can be detected from the registered languages. While the cursor is in an editable code block, an Auto language control floats above it; choose a language to save an explicit choice with the document. Type three backticks at the start of an empty paragraph to create a code block and continue typing inside it.

Pasting documents

Pasting formatted HTML keeps its headings and inline formatting, even when the clipboard's plain text resembles Markdown. Looma also turns Markdown or HTML copied as source into editable blocks, including ::: callouts and sections and GitHub > [!NOTE] alerts. Pasting inside a code block stays literal.

Use the portable Expressmark adapter for Markdown import and export:

import { editorDocumentToMarkdown, markdownToEditorDocument } from "@threadlabs/looma/editor/expressmark";

const content = markdownToEditorDocument(markdown, { syntax: "standard" });
editor.commands.setContent(content);
// Later, write the edited document back over the Markdown it was read from.
const saved = editorDocumentToMarkdown(editor.getJSON(), { syntax: "standard", original: markdown });

The adapter defaults to compatible syntax for existing CommonMark/GFM interchange; select standard explicitly when exchanging content with Markupress. It reports content the editor schema cannot preserve instead of silently discarding it. With original, an unedited document is written back byte for byte, front matter included, and an edit leaves the source of every other block alone. Footnote and link reference definitions stay where they are written.

Mentions

Pass mentionItems for a short known list, as in the playground, or answer the mention-query request with mentionQuery for a directory search. Each item needs a stable id and visible label; detail and initials are optional display hints. mentionLimit defaults to 8. The editor stores the ID and label in the document, while the application owns directory lookup and identity.

Suggestions open only when someone types @ where a mention can go. @ text that was pasted or loaded, or that someone clicks into later, stays plain and starts no search, and so does @ in inline code or a code block.

<LoomaEditor
  v-model="document"
  :mention-items="[
    { id: 'ada', label: 'Ada Lovelace', detail: 'Design', initials: 'AL' },
    { id: 'grace', label: 'Grace Hopper', detail: 'Engineering', initials: 'GH' }
  ]"
/>

Tables

Choose Table from the toolbar or slash menu, then pick dimensions in the grid. Once a cell is selected, row and column handles and the table toolbar expose the actions that are valid for that selection: add, delete, move, merge, split, alignment, and header rows or columns. Keyboard users can reach the same controls. The editor updates the document; consumers do not need to wire a separate table menu.

Answer the file-upload request with fileUpload to enable Attach file, /file, and /attachment. It receives { file } plus signal and progress callbacks, and returns { href, name, size, contentType } or null. The host retains the file and owns permissions and its durable download URL. The editor stores an ordinary link followed by its MIME type and size. JSON, HTML, and Markdown retain its destination. editor.getText() returns its name and metadata; applications that need URLs in plain-text exports can project the saved link marks or use the Markdown export.

The editor shows upload progress and Cancel. Failure leaves authored content intact and offers Retry and Dismiss, while still emitting uploadError. Retry keeps the insertion point mapped through edits instead of following a newly moved caret. Removing that insertion point cancels the pending result. Cancel or dismiss drops the pending file. A successful insertion is one undo step, separate from intervening typing.

Answer the image-upload request with imageUpload to enable image insertion from a file, paste, or drop. It receives { file } and returns the image to insert: src, optional alt text and dimensions, or null for none. While it runs the editor shows its progress with a Cancel button. A rejected upload leaves the document unchanged and emits uploadError, so the app can show recovery UI. resolveImageAttributes can supply responsive image attributes when rendering saved content. resolveImageSource can resolve a browser-only master URL for every image, including images without responsive metadata. It receives the durable image descriptor and returns a URL or undefined. Empty results and exceptions use the stored source. Responsive attributes are resolved from this master; if a rendition fails, the editor keeps the resolved master. Neither callback changes the stored document or the durable descriptors in image events. Replacing the source resolver refreshes delivery without emitting a document update. The application owns URL validation, authorization and signing; these synchronous callbacks do not fetch or persist credentials on behalf of the application.

Pass document JSON when loading must avoid contacting the stored image host. HTML input first passes through the browser's HTML parser, which may request image URLs before the editor can apply its delivery policy. Exported HTML keeps the durable URLs.

Click or tap an editable image to select the entire node and show one Image actions bar. Choose in-line block placement, centering, or text wrapping on either side directly in the bar. Choosing wrapping resets the image to one-third of the content column. Below 768px it stacks above the text at full column width. Corner dragging sets a custom desktop size; small screens still use the full column. Drag any corner handle to resize; the intrinsic aspect ratio stays locked and the width stays within the document. Release commits one undo step. Escape cancels a drag; focused handles also accept arrow keys (Shift uses a larger increment). Image description opens the secondary alt-text field with Save and Cancel.

Delete and Backspace remove a selected image, and undo restores it. Reading mode keeps placement and pointer/keyboard viewer activation, with author controls hidden. Images are viewer tab stops only in reading mode.

Document dividers have a pointer-sized click target and a selected outline. Click one, then press Delete or Backspace to remove it. Tab inserts a literal tab in ordinary text and code; lists retain indent/outdent and tables retain cell navigation. Outside a list or table, Shift+Tab leaves the document through the browser’s normal focus navigation.

The shared preset retains image width, height, placement, and responsive attributes in both JSON and HTML. Headless hosts can supply imageNodeView and dividerNodeView for custom presentation.

Select text and choose Link, type /link, or put the caret in a link to edit or remove it. The editor accepts http:, https:, mailto:, tel:, and relative URLs beginning with /, #, or ?. Answer link-search with linkSearch({ query }) to offer a single field that searches destinations or accepts a pasted full URL. Return items with id, label, href, and optional detail; the host owns access checks and durable URLs. Answer link-resolve with linkResolve({ href }) to show a saved destination's current name and context when its link is selected. Set linkTargetLabel to name that kind of destination in the picker. A failed lookup leaves the original URL visible and editable. Pass linkBaseUrl (for example, https://docs.example.test) to make newly inserted same-origin web links site-relative, including pasted HTML and URL paste over selected text. Paths, queries, and fragments are preserved; other origins and already relative destinations are unchanged. Link is also available in the text-selection toolbar.

Configuration and events

Pass a references map to render cached display values immediately. Keys are link:<id> and mention:<id>; values contain label and an optional href. Replacing the map refreshes presentation without emitting a document update or adding an undo step. The host owns access checks, background lookup and persistence of this metadata separately from authored content.

With this prop, selecting a host destination at an empty caret inserts an automatically labelled link with its original ID. Existing selected text or text entered in the link form remains custom wording. Old ordinary links keep their saved wording; matching a title does not establish intent. The native node retains its insertion-time fallback for offline use and cold caches.

Use editorToMarkdown(editor) from @threadlabs/looma/editor/expressmark to serialize the live editor's cached names and concrete TOC entries. For saved JSON plus a separately captured map, use editorDocumentToMarkdown(document, { references }). These operations retain target IDs and do not mutate editor content. Headless editors pass { references } to getDefaultEditorExtensions and replace display metadata with setReferenceDisplay(editor, references).

PropDefaultPurpose
modelValueEmpty documentDocument JSON; use v-model
referencesNoneCached labels and destinations, stored separately from document changes
editabletrueAllow editing or show the document read only
placeholderType “/” for commands, or start writing…Empty document prompt
labelDocumentAccessible name for the editing surface
extensionsNoneAnswers extensions: additional Tiptap extensions
slashCommandsNoneAnswers slash-commands: the slash menu's commands, given the defaults
toolbarModebubblebubble, sticky, popover, or contextual toolbar placement
toolbarTriggerId, toolbarOpen"", falseThe button that opens the popover toolbar, and whether it is open (v-model:toolbar-open)
codeLanguagesBuilt-in catalogReplace the default code languages with an application-owned grammar map; {} keeps code plain
mentionItems, mentionQuery, mentionLimitEmpty, none, 8Fixed mention candidates, and the answer to mention-query
linkBaseUrlNoneMake newly inserted same-origin web links relative
linkSearch, linkResolve, linkTargetLabelNone, none, ItemAnswer link-search and link-resolve: search app destinations and describe existing identity links
imageUpload, resolveImageSource, resolveImageAttributesNoneAnswer image-upload; image delivery and rendering

update:modelValue and update report document changes; update:toolbarOpen reports the popover toolbar opening and closing; ready exposes the underlying Tiptap editor for integrations. The app decides when and where to persist the document. The editor manages its own toolbar, pickers, and editing popovers; integrate through LoomaEditor rather than composing its internal controls.