Text Edit
A rich text editor built from your own markup, storing markdown.
Overview
x-text-edit has two roles. On its own, it makes an element an editable area bound to a value. With a command modifier, it makes any element a control for that area. It ships no toolbar of its own — you build one from your own buttons and inputs, styled like the rest of your page.
<div x-data="{ post: '# Hello\n\nSelect a word, then press **Bold**.' }">
<button x-text-edit.strong>Bold</button>
<button x-text-edit.em>Italic</button>
<button x-text-edit.h2>Heading</button>
<button x-text-edit.ul>List</button>
<div x-text-edit="post" aria-label="Post"></div>
<pre x-text="post"></pre>
</div>
Setup
Text Edit is included in manifest.js with all core plugins, or can be selectively loaded.
<script src="https://cdn.jsdelivr.net/npm/mnfst@latest/lib/manifest.min.js"></script>
Editor styles are included in Manifest CSS or as a standalone stylesheet.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/mnfst@latest/lib/manifest.min.css">
Editable Area
Point x-text-edit at a value and the element becomes editable in place. placeholder shows while it's empty. The stored value is markdown, so x-markdown can render it anywhere else on the page — below, the second box re-renders the value as you type.
<div x-data="{ note: 'A **markdown** note.' }">
<div x-text-edit="note" placeholder="Write a note…" aria-label="Note"></div>
<div x-markdown="note"></div>
</div>
| Modifier | Effect |
|---|---|
| (none) | Stores markdown |
.html |
Stores sanitized HTML; every command available |
.plain |
Stores text; no commands |
.minimal |
Inline marks only |
.literal |
No typed-markdown shortcuts |
.autofocus |
Focus on load |
With no value to point at, the element's existing markup is the starting content and the element itself holds the result. Don't combine it with x-html — both would write to the same element.
Commands
Commands are named after the tag they produce. On a <button> the command runs on click, without moving focus out of the text. On a <select> or <input> it runs on change, and the control also reflects the state at the caret — the block dropdown below reads "Heading" while the caret sits in one. A command that takes an argument gets it from the next modifier or from an expression: .align.center, .img="url".
<div x-data="{ doc: '<p>Select a word, then pick a command.</p>' }">
<select x-text-edit.block>
<option value="p">Paragraph</option>
<option value="h2">Heading</option>
<option value="blockquote">Quote</option>
</select>
<button x-text-edit.strong>Bold</button>
<button x-text-edit.code>Code</button>
<button x-text-edit.align.center>Center</button>
<input type="color" x-text-edit.color aria-label="Colour">
<button x-text-edit.undo>Undo</button>
<div x-text-edit.html="doc" aria-label="Document"></div>
</div>
| Group | Commands |
|---|---|
| Inline | strong b em i s del code and, in .html, u mark small sub sup kbd samp var abbr cite q ins dfn time span |
| Block | p h1–h6 blockquote pre block and, in .html, address figure figcaption dl dt dd |
| Lists, inserts | ul ol checklist indent outdent hr br img a unlink |
Style (.html) |
align color background font size leading |
Table (.html) |
table row-before row-after row-remove column-before column-after column-remove merge split table-header table-remove |
| Other | clear undo redo |
A command whose output markdown can't store — underline, colours, tables — is disabled unless the area uses .html. Disabled controls get aria-disabled="true" rather than disabled, so they stay focusable; every control on this page is disabled until the caret is in an area it can act on. An inline command clicked with nothing selected applies to the next text you type. Colour inputs apply live as you pick — each tick restyles the selection, and undo removes the whole colour pass in one step.
Links
A single <input> handles links end to end. Select text and type a URL to link it; click inside a link — clicking never navigates while editing — and the field shows its href; clear the field, or press an unlink button, to remove it. A bare domain gets https:// for you, and something that isn't a URL at all is refused: the field re-syncs to the real href, showing it didn't take.
<div x-data="{ post: 'Visit [Manifest](https://manifestx.dev) today.' }">
<input type="url" x-text-edit.a placeholder="https://" aria-label="Link">
<button x-text-edit.unlink>Unlink</button>
<div x-text-edit="post" aria-label="Post"></div>
</div>
Typing Markdown
Type markdown and it becomes the real thing. Inline marks — **bold**, *italic*, `code` — convert the moment you type the closing marker. Block shortcuts — headings, lists, quotes — work at the start of a line and convert when you press Enter. .literal turns the shortcuts off.
<div x-data="{ draft: '' }">
<div x-text-edit="draft" placeholder="Type ## Title and press Enter" aria-label="Draft"></div>
</div>
| Type | Get |
|---|---|
# … ###### |
Heading |
- * + / 1. |
Bulleted / numbered list |
[ ] [x] |
Task list |
> |
Blockquote |
``` / --- |
Code block / rule |
**bold** *italic* ~~struck~~ `code` |
Inline marks |
[text](url)  |
Link, image |
Tables
Tables are .html only. Every operation is a command and is disabled while the caret is outside a table. Drag a cell border to resize.
<div x-data="{ doc: '<table><tr><td>Stem</td><td>Price</td></tr><tr><td>Rose</td><td>4</td></tr></table>' }">
<button x-text-edit.table.3x2>Insert 3×2</button>
<button x-text-edit.row-after>Row below</button>
<button x-text-edit.column-after>Column after</button>
<button x-text-edit.merge>Merge</button>
<button x-text-edit.table-header>Header row</button>
<button x-text-edit.table-remove>Remove</button>
<div x-text-edit.html="doc" aria-label="Document"></div>
</div>
merge joins the selected cells, or the caret's cell with the one to its right. Tab walks cell to cell and adds a row past the last one; arrows leave a cell only at its edge. Inserting a table while the caret is inside one lands the new table after it — tables never nest.
| Command | Effect |
|---|---|
table |
Insert; size as a modifier or expression: .table.3x2, .table="cols + 'x' + rows" (default 3×3) |
row-before, row-after |
Add a row above / below the caret's |
row-remove |
Remove the caret's row |
column-before, column-after |
Add a column before / after the caret's |
column-remove |
Remove the caret's column |
merge |
Join the selected cells, or the caret's cell with its right neighbour |
split |
Undo a merge: split the caret's spanning cell |
table-header |
Toggle the first row between header and body cells |
table-remove |
Remove the whole table |
Keyboard
| Key | Action |
|---|---|
| Enter | New paragraph. In a list: next item; from an empty item, leave the list. In a code block: new line; from an empty last line, leave the block |
| Shift + Enter | Line break |
| Tab / Shift + Tab | Next / previous cell; nest / unnest a list item; indent / outdent a block (.html) |
| Backspace at line start | Join the line above; leave a list; demote a heading |
| Escape | Leave the editor |
| Cmd/Ctrl + Z / Shift + Z | Undo / redo (the editor keeps its own history) |
| Cmd/Ctrl + B / I / U | Bold / italic / underline (.html) |
Pasted content keeps its text and marks, not the source's styling.
Scattered Controls
A control finds its area in this order:
- The nearest ancestor with
x-text-edit-for="selector". - The nearest ancestor holding exactly one area.
- The last focused area.
A shared toolbar is disabled until an area has been focused.
<div x-data="{ a: 'First **draft**', b: 'Second draft' }">
<button x-text-edit.strong>Bold (last focused)</button>
<span x-text-edit-for="#note-b">
<button x-text-edit.em>Italic in B</button>
</span>
<div x-text-edit="a" aria-label="A"></div>
<div x-text-edit="b" id="note-b" aria-label="B"></div>
</div>
Controls disable themselves while the caret sits in some other editable element, so a page-level toolbar can't write into the wrong field.
Selection Menu
Put x-text-edit-menu on an element holding commands and it becomes a selection bubble: shown over the selection of the area it belongs to (found the same way any control finds its area), hidden once the selection is gone. It's promoted to a manual popover, so the click that makes the selection can't close it.
<div x-data="{ post: 'Select some of this text.' }">
<menu x-text-edit-menu>
<button x-text-edit.strong>Bold</button>
<button x-text-edit.em>Italic</button>
</menu>
<div x-text-edit="post" aria-label="Post"></div>
</div>
For a menu of your own, the same plumbing is exposed directly: the area fires text-edit:selection with { collapsed, text, x, y, width, height, top, right, bottom, left }, or null once the selection is gone, and the same box is written as --text-edit-selection-x, -y, -width, -height and -center custom properties on the area and on :root — so anything can position itself over the selection without script.
Editing a Data Value
Bind the editor to a field of a data source row and the edit is stored in that row — the field simply holds the markup. Below, each editor is bound to a product's name; the code on the right isn't part of the pattern, it just shows the stored value updating as you type.
<template x-for="p in $x.example.products" :key="p.name">
<div x-text-edit.html.minimal="p.name" aria-label="Name"></div>
</template>
Page Styling
Add .page to font, size, leading, align, color or background and the command styles the whole area instead of the selection. Page styles live as CSS variables on the area, not as tags in the content, so the stored document stays clean. Read or assign them with $text.page; text-edit:page fires with the current set whenever they change.
<div x-data="{ doc: '<p>Page styles live outside the document.</p>', look: {} }">
<select x-text-edit.font.page>
<option value="">Default</option>
<option value="Georgia">Georgia</option>
<option value="ui-monospace">Mono</option>
</select>
<select x-text-edit.leading.page>
<option value="">Default</option>
<option value="2">Double</option>
</select>
<div x-text-edit.html="doc" aria-label="Document" @text-edit:page="look = $event.detail"></div>
<code x-text="JSON.stringify(look)"></code>
</div>
$text
$text resolves to the editable area the expression's element sits in, or failing that, the last focused area.
| Member | Description |
|---|---|
value |
Stored value; assigning resets undo history |
page |
Page styles object; assign {} to clear |
link |
href at the caret; assign to set, empty to unlink |
selection |
The last reported selection, or null |
run(cmd, arg) active(cmd) can(cmd) |
Apply, test, or check availability of a command |
markdown() html() |
Content as markdown or sanitized HTML, whatever the mode |
focus() selectAll() |
Focus the area; select everything |
<div x-data="{ doc: '<p>Hello <strong>world</strong></p>', md: '' }">
<button @click="$text.selectAll()">Select all</button>
<button :disabled="!doc" @click="doc = ''">Clear</button>
<div x-text-edit.html="doc" aria-label="Document" @input="md = $text.markdown()"></div>
<pre x-text="doc ? md : '(empty)'"></pre>
</div>
Literal * and _ come back escaped from markdown() — in an .html area they're ordinary characters, and the escape is what keeps them ordinary on the way back in.
Styles
By default the area looks like a field — it borrows the same theme tokens inputs use (--color-surface-1, --color-line, --radius), so it sits naturally in a form. For a WYSIWYG document that should look like the page itself, add unstyle: the field chrome (background, border, padding, minimum height) drops away and the text inherits its element's own type and colours, while every command, placeholder and selection behaviour keeps working. The --text-edit-* variables are per-instance overrides on top of the theme:
| Variable | Default | Sets |
|---|---|---|
--text-edit-min-height |
8rem |
Minimum height |
--text-edit-max-height |
none |
Scrolls past this |
--text-edit-padding |
0.75rem |
Content padding |
--text-edit-font -size -leading -align -color -background |
inherit | Page styles; .page controls override |
Style by attribute: [data-text-edit] (value is the mode), [data-text-edit-empty], [data-text-edit-selected], [data-text-edit-focused] (held while the area owns the editing session, even when a <select> or colour control takes native focus), [data-text-edit-control] (value is the command), [data-text-edit-active], and [aria-disabled="true"], which the reset already dims.
Related
Article does not exist
There is no documentation at this path.