Editor
v0.0.10
mix mishka.ui.gen.headless editor
Overview of the Phoenix and LiveView Headless Editor Component
The Mishka Chelekom Headless Editor is a rich-text editor for Phoenix LiveView apps, built on TipTap 3 and ProseMirror. The editor engine owns the editing surface and your server owns the document. The content is mirrored into a hidden textarea, so the editor submits with an ordinary <.form> and phx-change fires as the user types, with no JavaScript glue of your own.
Toolbar buttons go in the toolbar slot. Tag a button with data-editor-command="bold" and the engine wires it up and sets data-active on it while that mark or block is active. The component ships no colors, spacing or typography. The generator installs the TipTap packages for you: @tiptap/core, @tiptap/pm and @tiptap/starter-kit, all MIT licensed.
Base UI Editor example - toolbar and starting content (Phoenix headless)
Base UI Editor example - toolbar and starting content (Phoenix headless)
<.editor
id="baseui-editor-hero"
format="html"
value="<p>A rich-text surface. Select a word and use the toolbar.</p>"
placeholder="Write something"
class="w-full max-w-md overflow-hidden rounded-md border border-neutral-200 bg-white text-sm text-neutral-950 dark:border-neutral-800 dark:bg-neutral-950 dark:text-white"
toolbar_class="flex items-center gap-1 border-b border-neutral-200 p-1 dark:border-neutral-800"
surface_class="min-h-24 p-3 outline-none"
>
<:toolbar>
<.button
type="button"
aria-label="Bold"
data-editor-command="bold"
class="size-7 rounded-sm text-sm font-bold text-neutral-600 hover:bg-neutral-100 data-[active]:bg-neutral-950 data-[active]:text-white dark:text-neutral-400 dark:hover:bg-neutral-800 dark:data-[active]:bg-white dark:data-[active]:text-neutral-950"
>B</.button>
<.button
type="button"
aria-label="Italic"
data-editor-command="italic"
class="size-7 rounded-sm text-sm italic text-neutral-600 hover:bg-neutral-100 data-[active]:bg-neutral-950 data-[active]:text-white dark:text-neutral-400 dark:hover:bg-neutral-800 dark:data-[active]:bg-white dark:data-[active]:text-neutral-950"
>I</.button>
<.button
type="button"
aria-label="Strikethrough"
data-editor-command="strike"
class="size-7 rounded-sm text-sm line-through text-neutral-600 hover:bg-neutral-100 data-[active]:bg-neutral-950 data-[active]:text-white dark:text-neutral-400 dark:hover:bg-neutral-800 dark:data-[active]:bg-white dark:data-[active]:text-neutral-950"
>S</.button>
</:toolbar>
</.editor>Editor example - inside a form (Phoenix headless)
Give the editor a name and the engine mirrors the document into a hidden textarea with that name, then dispatches an input event. That is what makes phx-change fire, after the debounce delay (300 ms by default), and Save submits the same value. This example needs a LiveView to hold the document, so it is shown as code.
<.form
for={to_form(%{"body" => @body}, as: :post)}
id="post-form"
phx-change="validate"
phx-submit="save"
>
<.editor
id="post-body"
name="post[body]"
value={@body}
class="rounded-lg border border-neutral-200 data-[focused]:ring-2 data-[focused]:ring-neutral-400"
toolbar_class="flex flex-wrap items-center gap-1 border-b border-neutral-200 p-1.5 [&_button]:rounded [&_button]:px-2 [&_button]:py-1 [&_button]:text-sm [&_button[data-active]]:bg-neutral-100 [&_button[data-active]]:font-semibold"
surface_class="min-h-40 p-3 text-sm leading-6 outline-none [&_h1]:mt-2 [&_h1]:mb-1 [&_h1]:text-2xl [&_h1]:font-bold [&_p]:my-1 [&_ul]:my-1 [&_ul]:list-disc [&_ul]:pl-6"
>
<:toolbar>
<button
:for={{command, label} <- [{"bold", "B"}, {"italic", "I"}, {"strike", "S"}, {"code", "Code"}]}
type="button"
data-editor-command={command}
>
{label}
</button>
</:toolbar>
</.editor>
<button type="submit">Save</button>
</.form>def mount(_params, _session, socket) do
{:ok, assign(socket, :body, nil)}
end
def handle_event("validate", %{"post" => %{"body" => body}}, socket) do
{:noreply, assign(socket, :body, body)}
end
def handle_event("save", %{"post" => %{"body" => body}}, socket) do
{:noreply, put_flash(socket, :info, "Saved #{String.length(body)} characters")}
endSetting the content from the server
LiveView ignores the editing surface (phx-update="ignore"), because TipTap owns that subtree. A new value in a re-render does not reach it after the first render. To replace the document, push the chelekom:editor event with the editor's id and the new value in the editor's format. The change is not reported back as a user edit.
def handle_event("clear", _params, socket) do
empty = ~s({"type":"doc","content":[{"type":"paragraph"}]})
{:noreply, push_event(socket, "chelekom:editor", %{id: "post-body", value: empty})}
endeditable is the exception, because it travels as a data-* attribute, which LiveView still merges on an ignored element. Pass editable={@editable} and flipping it makes the editor read-only or editable in place. The document, cursor and undo history survive.
Toolbar commands
The engine looks for data-editor-command anywhere inside the editor root. The values it accepts are bold, italic, strike, code, paragraph, h1, h2, h3, bullet_list, ordered_list, blockquote, code_block, horizontal_rule, undo and redo. All of them except horizontal_rule, undo and redo get data-active while the selection is inside that mark or block. Clicks are handled by delegation, so LiveView can re-render the toolbar without losing them, and pressing a toolbar button does not take focus from the editor.
Styling
The root keeps your live class. The engine sets data-empty on it while the document is empty and data-focused while the surface has focus, and the root also has data-disabled and data-readonly when those attributes are set. placeholder is the empty-state text: the engine copies it to data-placeholder on the ProseMirror element and you draw it with CSS.
The surface is ignored by LiveView, so its classes are frozen after the first render. Set surface_class once. Because the editor ships no typography, headings, lists and quotes render flat under Tailwind Preflight. Style them from surface_class with descendant selectors, such as [&_h1]:text-2xl and [&_ul]:list-disc.
Engines
The default engine is TipTap, which stores json or html. The markup and attributes stay the same for the other engines, and only format changes: Lexical stores json, CodeMirror 6 stores plain text and Milkdown stores markdown. Generate the one you want with mix mishka.ui.gen.headless editor --lib code_mirror, using tiptap, lexical, code_mirror or milk_down. The engine is installed as editor.js under the Editor hook, so your templates do not change.
Extra TipTap extensions and StarterKit options go in assets/vendor/editor_extensions.js. The generator creates that file once and never overwrites it.
Attributes and parts
id is required. The surface id is <id>-surface and the mirror id is <id>-value. name is the form field name of the mirror, and without it there is no mirror and nothing to submit. value is the starting document in format. format is json (the default), html, text or markdown, and must be one the installed engine produces. required and form go to the mirror.
editable (default true), disabled and readonly each stop editing; disabled also disables the mirror. debounce is the delay in milliseconds before an edit is written to the mirror. label_id and describedby_id point the surface at the elements that label and describe it. on_change, on_focus and on_blur name LiveView events, each pushed with the current document as value.
The parts are the root, the optional toolbar, the hidden value mirror and the surface, each with a data-part attribute. class, toolbar_class, value_class and surface_class style them. hook names the JavaScript hook to mount and defaults to Editor; change it only if you have vendored a second engine under another name.
Accessibility
The surface is a div with role="textbox" and aria-multiline="true". With no label_id it is named "Rich text editor", so pass a label_id to use your own visible label. The toolbar has role="toolbar", aria-label="Formatting" and aria-controls pointing at the surface. The buttons you put in it are ordinary buttons in the tab order.
Editing is standard contenteditable. Mod+B, Mod+I and Mod+Shift+X toggle bold, italic and strike, and Mod+Z and Mod+Shift+Z undo and redo. Tab leaves the editor, so it is one tab stop and not a trap.
Attributes
| Name | Type | Default | Values | Required | Description |
|---|---|---|---|---|---|
id
|
string
| — | — | Yes | Unique id; the surface derives `<id>-surface` |
name
|
string
| — | — | No | Form field name for the hidden mirror (omit = no form) |
value
|
string
| — | — | No | Initial document, in `format` |
format
|
string
|
json
|
jsonhtmltextmarkdown
| No | Storage format, and it must be one the chosen engine produces: tiptap `json`/`html`, lexical `json`, code_mirror `text`, milk_down `markdown`. `json` is the safe default — it is never rendered as markup, so a stored document cannot become an XSS vector; `html` needs server-side sanitizing |
placeholder
|
string
| — | — | No | Empty-state text (rendered by your CSS) |
editable
|
boolean
|
true
| — | No | Whether the document can be edited (live) |
disabled
|
boolean
|
false
| — | No | Disable the control (also sets data-disabled) |
readonly
|
boolean
|
false
| — | No | Render read-only (also sets data-readonly) |
required
|
boolean
|
false
| — | No | Mark the hidden mirror required |
form
|
string
| — | — | No | Form id owning the hidden mirror |
debounce
|
integer
|
300
| — | No | Milliseconds before an edit is committed to the mirror. Debounce lives in the engine — `phx-debounce="blur"` is a no-op on a hidden input, which never blurs |
label_id
|
string
| — | — | No | id of the element labelling the surface |
describedby_id
|
string
| — | — | No | id of the element describing the surface |
on_change
|
string
| — | — | No | Event pushed with %{value: doc} after each commit |
on_focus
|
string
| — | — | No | Event pushed when the surface gains focus |
on_blur
|
string
| — | — | No | Event pushed when the surface loses focus |
class
|
any
| — | — | No | Extra classes for the root (stays live) |
toolbar_class
|
any
| — | — | No | Extra classes for the toolbar |
surface_class
|
any
| — | — | No | Extra classes for the editing surface. Frozen after first render (it is ignored) |
value_class
|
any
| — | — | No | Extra classes for the hidden mirror |
hook
|
string
|
Editor
| — | No | Advanced: the JS hook to mount. Every engine registers `Editor`, so this only matters if you have hand-vendored a second engine under another name |
rest
|
global
| — | — | No | — |
Slots
| Name | Required | Description |
|---|---|---|
toolbar
| No | Toolbar controls; tag buttons with `data-editor-command` |
Options
Each option takes one or more of its values, separated by commas.
| Option | Values |
|---|---|
--type
|
editor
|
--module
| A module name of your own |
What it generates
mix mishka.ui.gen.headless editor