RichText
WxRichText is a WYSIWYG editor for content fields: formatting, lists, tables, links, images and YouTube embeds. It is built on Tiptap — MIT, no paid extensions — and the model is an HTML string, which is what a CMS column holds anyway.
HTML in the model
<h2>Release notes</h2><p>Paste an image straight into this editor, or drop a file on it — the demo "uploads" it by turning it into a data URL.</p><ul><li>Lists</li><li>Tables</li><li>Links and YouTube embeds</li></ul>
Usage
<script setup lang="ts">
import { ref } from 'vue'
const body = ref('')
</script>
<template>
<wx-form-item label="Body" name="body">
<wx-rich-text v-model="body" placeholder="Write something…" />
</wx-form-item>
</template>An empty editor writes an empty string, not <p></p> — so "has the user written anything" stays a plain truthiness check on the backend.
Images
The editor never uploads anything itself. Give it a function and it calls that:
<script setup lang="ts">
async function upload(file: File) {
const body = new FormData()
body.append('file', file)
const response = await fetch('/admin/media', { method: 'POST', body })
if (!response.ok) throw new Error('Upload failed')
const { url } = await response.json()
return { url, alt: file.name }
}
</script>
<template>
<wx-rich-text v-model="body" :upload="upload" @upload-error="notify" />
</template>With upload set, three things start working: pasting an image from the clipboard, dropping a file onto the editor, and the toolbar's image button. Each file is uploaded on its own and inserted only once the promise resolves — a failed upload emits upload-error and leaves the document untouched, with nothing half-inserted to clean up.
pickImage is the other half, and the seam a media library plugs into later:
<template>
<wx-rich-text v-model="body" :upload="upload" :pick-image="openMediaLibrary" />
</template>When pickImage is given, the toolbar button opens it instead of a file dialog; pasting and dropping still go through upload. Give neither and the image button is not rendered at all — the editor does not offer what it cannot do.
The key, not the address
Both functions may answer with a path beside the url, and when they do the editor writes it into the document as data-wx-path:
<img src="https://cdn.example.com/m/hero.jpg?v=9f2a" data-wx-path="2026/09/hero.jpg" alt="…" />The address is not the picture. It is different on every deployment of the same site — a bucket on the developer's machine, the application's own disk in production — it may be signed and about to expire, and it carries a version stamp that changes the moment somebody crops the image. The key is none of those things, so the key is what a record should keep and the address is what should be worked out again on the way out.
The editor only carries the key; working an address back out of it is the job of whatever stores the document — in a WebX panel that is wx-rich-text, which does it on every read. A picture with no path is an address and nothing more, and is never touched.
// The one thing a picker has to do differently.
const pickImage = async () => {
const file = await openLibrary()
return file && { url: file.url, path: file.key }
}Languages
localized turns the field into one editor with a language chip in its corner, exactly as WxInput and WxTextarea do — and the model becomes { en: '<p>…</p>', de: '<p>…</p>' } rather than a string:
<template>
<wx-rich-text v-model="body" localized />
</template>One editor rather than one per language, and the chip swaps the document in it. Four of them stacked would be four documents to scroll past to reach the next field, which is the difference between a document and a line: a localized input can afford to show every language at once.
The languages come from provideLocales() — in a panel they are the site's content languages, and the chips of every field on a screen move together.
What the buttons are called
Every label is English by default. This component knows nothing about the application it is opened in, so whoever opens it translates it:
<template>
<wx-rich-text v-model="body" :labels="{ bold: 'Жирный', linkAddress: 'Адрес ссылки' }" />
</template>The keys are the tool keys, the table tools (addRowAfter, deleteTable, …), and toolbar, linkAddress, youtubeAddress, apply, cancel, uploading, and the three of the source view — sourceLoss, sourceDrop, sourceKeep — and expand and collapse, the two names of the full-window button. A key left out stays English rather than blank. In a WebX panel none of this is written by hand: wx-rich-text is the same editor with the panel's dictionary already behind it.
Tables and videos
The table button inserts a 3×3 table with a header row. While the caret is inside a table, a second toolbar row appears with add and delete for rows and columns, merge and split, and delete table. Columns are resizable by dragging.
The YouTube button asks for a URL and embeds it through youtube-nocookie.com.
HTML source
The last button, </>, turns the field into the HTML behind it — highlighted, one block to a line, indented where blocks hold blocks — and back. It is for the jobs the toolbar cannot do: seeing what a paste from a word processor really left behind, fixing a link's rel by hand, pasting a fragment a copywriter prepared. (The <> button next to Bold is inline code, a different thing.)
What is typed goes to the model as it is typed, so a form saved with the source still open saves what is on screen. Leaving the source hands it to the editor, which keeps only what its schema knows — paragraphs, headings, lists, quotes, links, tables, pictures, videos. When a hand edit holds anything else — a <div>, a <span>, a style or a class — the view stays open and names it first, with a choice to remove it or keep editing. Nothing goes without a word, and a source left untouched leaves the value exactly as it was.
The source is not a way around the server: a WebX panel stores rich text through an allowlist whichever way it was typed — see below.
A read-only field still opens its source, to be read. A field that should not show it leaves source out of tools.
The toolbar
The dividers in tools mark out groups, and a toolbar narrower than its buttons wraps a group at a time: bold never ends a line with italic starting the next, and no divider is left at either end of a line. Every button stays on screen — nothing folds into a "more" menu. The placeholder button joins the last group, beside the source; the button that opens the editor over the window is a group of its own right after it. A localized field keeps its language chip at the right of the first line of buttons, and the toolbar leaves room for it on every line.
In a form the editor stops at --wx-size-field-wide (768px), a little wider than an input: at that width the whole default toolbar, language chip included, is one line. A narrower card narrows it, and the toolbar wraps.
Over the whole window
A field in a form is about as wide as the column the form's other fields are — a line long enough to read — and a long description outgrows that height-wise long before it does width-wise. The button after the last group of the toolbar opens the same editor over the whole window: the caret and the selection stay where they were, the language chip still swaps the document, and the button or Esc gives the form back. Esc closes the link bar or the placeholder list first, when one is open.
It is the same editor moved, not a copy: one history, one caret, nothing to reconcile on the way back. It moves to the end of the body, so a container that measures itself or a transformed sheet around the field does not clip it, and it sits above the page and below dialogs — the image button still opens the library over it. expand reports each change, with true or false.
An inline field is a line and never offers it, a field inside a dialog or a sheet neither (the dialog would close at the first click into the text), and :expandable="false" turns it off.
Inline
A heading often carries a touch of markup — a coloured full stop, one word in italics — and a plain input shows that markup raw, one stray keystroke from printing </span> on the site. inline turns the editor into one line for exactly that:
Deeply heard<span>.</span> Gently guided<template>
<wx-rich-text v-model="heading" inline placeholder="Heading" />
</template>The value is the line itself, with no paragraph around it — Deeply heard<span>.</span> Gently guided — so a template prints it inside its own <h1>. Enter does nothing, pasted lines become one line, and the toolbar is bold, italic and accent. The accent is a bare <span>: what a heading written by hand already holds and what the site's styles colour. Anything else pasted in — a heading, a list, a link — leaves its words and loses its tags. tools still decides the buttons; source works here too.
Placeholders
The same as Input: tokens turns on the list on [ and a button at the end of the toolbar that lists them all. The chips are a decoration over the text, not a node of the document: getHTML() answers with [phone] as plain text, so the stored HTML is the one the site reads, and a chip cannot be half-deleted into markup nobody can see. The inline field has them too.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | Record<string, string> | '' | HTML content; a map per language under localized |
placeholder | string | — | Shown while the document is empty |
inline | boolean | false | One line with a few marks instead of a document — see Inline |
tools | RichTextTool[] | all; inline: three | Which buttons appear, in order |
upload | (file: File) => Promise<{ url, alt? }> | — | Handles pasted, dropped and picked files |
pickImage | () => Promise<string | null> | — | Opens a media library; null cancels |
accept | string[] | image types | MIME types accepted for upload |
localized | boolean | false | Edits one language at a time; the model becomes a map |
labels | RichTextLabels | English | What the buttons and the link bar are called |
expandable | boolean | true | Offers to open the editor over the whole window — see above |
tokens | TokenOption[] | — | Placeholders to suggest on [, list from the button and draw as chips |
tokensTitle | string | 'Placeholders' | Heading of the list the button opens |
tokensLabel | string | 'Insert a placeholder' | Name and tooltip of the button |
minHeight | string | '220px'; inline: none | Height before the editor starts growing |
size | 'sm' | 'md' | 'lg' | 'md' | Control size |
status | 'default' | 'success' | 'warning' | 'error' | 'default' | Validation state |
disabled | boolean | false | Disables the editor and its toolbar |
readonly | boolean | false | Content stays visible but cannot be edited |
id | string | generated | Overrides the id the label points at; WxFormItem supplies one |
ariaLabel | string | — | Label when there is no visible one |
Events: update:modelValue (string), change (string), focus, blur, upload-error (error, file), expand (boolean).
RichTextImage is { url, path?, alt? } — see the key, not the address.
Exposed: editor — the Tiptap instance, for commands this component does not wrap — plus focus() and clear().
Tool keys: bold, italic, accent (a bare <span>, meant for inline), strike, code, h2, h3, h4, bulletList, orderedList, blockquote, hr, link, table, image, youtube, undo, redo, source, and divider for a separator.
<template>
<wx-rich-text v-model="excerpt" :tools="['bold', 'italic', 'divider', 'link']" />
</template>Sanitise on the server anyway
ProseMirror parses pasted content against a schema and drops everything the schema does not know, so a <script> pasted into the editor never becomes part of the document. That is a useful property, but it is not a security boundary: the same field can be POSTed to directly, without the editor ever running. Sanitise the HTML on the server on save — the editor makes the common path clean, the server makes it safe.
In a WebX panel that is done for you wherever a value is saved through a screen: wx-rich-text stores what it is given through an allowlist, in module-admin — see Screens, which also says where that does not apply. data-wx-path is on the allowlist, which is what keeps the key in the document.
Bundle size
Tiptap and ProseMirror are dependencies of @webx-ui/core and are installed with it. They are not bundled into our output, so an app that never imports WxRichText will not ship them.
WxRichText is an async component: the editor and Tiptap are a chunk of their own that the browser fetches when a screen first draws the field, not with the app — about a fifth of what the panel used to download before anything appeared. Props, events and v-model are the same; a template ref points at the editor once it has arrived and is null until then. The HTML source view loads WxCodeEditor the same way, on first opening. In a jsdom test, wait for it with await vi.dynamicImportSettled(); a stub by name (stubs: { WxRichText: … }) still replaces it.