Dual-mode content editor LiveComponent with visual (WYSIWYG) and markdown modes.
Visual mode uses a contenteditable div with vanilla JS (no npm dependencies). Markdown mode uses a plain textarea with toolbar support. Content syncs between modes using MDEx (markdown→HTML) and client-side HTML→markdown conversion.
Usage
import Leaf, only: [leaf_editor: 1]
<.leaf_editor
id="my-editor"
content={@content}
mode={:visual}
preset={:advanced}
toolbar={[:image, :video]}
placeholder="Write something..."
readonly={false}
height="480px"
debounce={400}
/>Presets
:advanced(default) — Full toolbar with all formatting options:simple— Compact toolbar for comments/lightweight editing: undo/redo, bold, italic, strikethrough, inline code, lists, link, emoji, clear formatting
Messages Sent to Parent
{:leaf_changed, %{editor_id, markdown, html}}— Content updated{:leaf_flushed, %{editor_id, ref, markdown, html}}— Reply to an explicitaction: :flushthat carried aref(see "Flushing" below){:leaf_insert_request, %{editor_id, type: :image | :video}}— Insert requested{:leaf_mode_changed, %{editor_id, mode: :visual | :markdown}}— Mode switched{:leaf_suggest, %{editor_id, trigger, query, seq}}— Inline suggestion requested (only whensuggestionsis configured; see below)
Flushing (save before navigate)
send_update(Leaf, id: …, action: :flush) tells the client to push its
pending keystrokes immediately. On its own that reply is an ordinary
{:leaf_changed, …} — indistinguishable from the debounce firing — so a
host that needs to await the flush (version switch, language switch,
translation enqueue) passes a correlation ref:
send_update(Leaf, id: "content-editor", action: :flush, ref: "save-42")The client echoes it back on a dedicated message, after the matching
{:leaf_changed, …}:
def handle_info({:leaf_flushed, %{ref: "save-42", markdown: md}}, socket) do
# every keystroke is in; safe to persist and navigate
endref must be JSON-encodable (a string or integer). Without a ref no
{:leaf_flushed, …} is sent at all, so hosts written against older
versions keep their exact behaviour — a handle_info/2 that does not
match the new message can never be reached by accident.
Inline Suggestions
The editor can offer a popup as the writer types a trigger character —
# for tags, @ for people, / for components, : for emoji. It knows
nothing about any of those: it detects a configured trigger, asks the host
what matches, renders the list and inserts the pick.
<.leaf_editor
id="post-content-editor"
content={@content}
suggestions={[
%{
trigger: "#",
boundary: :word_start,
token: ~r/[\p{L}\p{N}_-]/u,
first_char: ~r/\p{L}/u,
max_length: 30,
min_chars: 0,
debounce: 150,
max_results: 10,
allow_create: true,
insert_suffix: " ",
label: "Tags"
}
]}
/>Every key but :trigger is optional. Keys may be atoms or strings, and
:token / :first_char take either a Regex or a raw character-class
string.
| Key | Default | Meaning |
|---|---|---|
:trigger | — | Required. The character(s) that open the popup. |
:boundary | :word_start | Where a token may start: :word_start (start of input, whitespace or (), :line_start, :not_line_start (like :word_start, but never the first character of a line), or :any. |
:token | ~r/[\p{L}\p{N}_-]/u | Characters that continue the token. Typing anything else closes the popup. |
:first_char | none | Extra constraint on the first character after the trigger. |
:min_chars | 0 | Query length before the popup opens. 0 opens on the bare trigger. |
:max_length | unlimited | Longest token that still counts. |
:debounce | 150 | Milliseconds before the query goes to the host. |
:max_results | 10 | Rows rendered from the host's reply. |
:allow_create | false | Adds a "Create …" row when nothing matches exactly. |
:keep_trigger | true | Whether the accepted text keeps the trigger. # and @ keep it; a / command menu sets false so /im becomes <Image />, not /<Image />. |
:insert_suffix | " " | Appended after the accepted value. |
:label | none | Heading shown above the list. |
:exclude | [:code, :link] | Contexts where the popup must not open. |
The round trip
A request arrives as a message to the host LiveView, and the reply goes
back through send_update/2 — the same shape as every other Leaf command:
def handle_info({:leaf_suggest, %{editor_id: id, trigger: "#", query: q, seq: seq}}, socket) do
results =
Enum.map(Hashtags.suggest(socket.assigns.group, q, limit: 10), fn tag ->
%{value: tag.name, label: "#" <> tag.name, sublabel: "#{tag.count} posts", icon: "hero-hashtag"}
end)
send_update(Leaf, id: id, action: :suggestions, trigger: "#", query: q, seq: seq, results: results)
{:noreply, socket}
endResults may also be plain strings (["elixir", "phoenix"]). :icon is a
CSS class name (the heroicons convention) and is rendered only when given,
so an app without that plugin never shows an empty gutter.
Two rules outrank the shape of any of this:
- Stale replies are dropped. Keystrokes routinely outrun a round trip,
so echo
trigger,queryandseqback unchanged — the client matches on all three and ignores anything superseded. - Typing is never blocked. A host that never answers gets a short
spinner and then the popup closes on its own. A hand-typed
#tagis already valid; this is an enhancement over something that works without it.
What the popup will not do
With exclude: [:code, :link] (the default) the popup stays shut inside
fenced and inline code, inside a markdown link/image destination
([jump](#section)), and — via the :word_start boundary — after a
non-space character, which covers URL fragments like /page#section.
The checks are client-side approximations: in the visual and hybrid modes
they ask the DOM for <code> / <pre> / <a> ancestors, in the markdown
and HTML modes they count delimiters. A stray popup is cosmetic — nothing
is ever written to the document except by accepting a row.
One case worth knowing about # specifically. In markdown a heading is
# followed by a space — # Notes is a heading, #notes is a
paragraph containing a tag, and the editor's hybrid preview agrees with
MDEx on this. But a lone # on an otherwise empty line is a valid (empty)
heading, so with min_chars: 0 that single keystroke both renders as a
heading and opens the tag popup. It resolves itself on the next character:
a letter makes it a tag, a space makes it a heading. Set min_chars: 1 if
you would rather the popup never appear in that ambiguous moment, or
boundary: :not_line_start to keep the popup off the first column
entirely — then # opens a heading and #tag mid-line opens the popup,
with no keystroke where both are live.
Hashtag styling
Configuring a # trigger also tells Leaf that # means "tag" in this
editor, so hashtags render as tinted, slightly-italic tokens in the
visual and hybrid surfaces instead of reading as ordinary prose. It is
purely a decoration — the markdown stays #tag and serialization is
unchanged. An editor with no # trigger gets no hashtag styling, so a
document that uses # for issue numbers is left alone.
Interaction
↑/↓ move (wrapping), Enter and Tab accept, Escape dismisses. While the
popup is open Enter neither inserts a newline, nor continues a list, nor
submits the surrounding form. Clicking a row does not steal focus from the
editor. The popup is portaled to <body>, anchored to the caret, flips
above it when there is no room below, and never opens mid-IME-composition.
Testing a trigger
The popup lives entirely on the client, so a LiveView test drives the server half directly:
render_hook(view, "suggest", %{"trigger" => "#", "query" => "eli", "seq" => 1})
assert_receive {:leaf_suggest, %{trigger: "#", query: "eli", seq: 1}}Popup DOM carries stable hooks for browser-level tests: the popup is
##{editor_id}-suggest, rows are [data-leaf-suggest-index] and carry
data-leaf-suggest-value and data-leaf-suggest-kind
("result" / "create").
Custom component tags (preserve_tags)
Read this before putting component tags through the editor
Markdown holding custom tags — <Hero />, <Showcase>…</Showcase> —
must declare them in preserve_tags. Without it the visual and
hybrid surfaces flatten each one into loose paragraphs on the first
keystroke, and autosave writes that back over the original. Leaf logs
a warning (see below) the first time it sees an undeclared PascalCase
tag, but the declaration is what actually protects the content.
<.leaf_editor
id="post-content-editor"
content={@content}
preserve_tags={["Hero", "Showcase", "Note", "Audio", "EntityForm"]}
/>A declared tag is pulled out before the markdown parser runs, rendered as an atomic block — non-editable, so nothing inside it can be corrupted in place — and restored verbatim on the way back out, so the source round-trips byte for byte.
The block reads as a preview of the component, not as its source. Known attribute names map to typographic roles and are typeset in the editor's own prose voice:
| Role | Attribute names |
|---|---|
| Eyebrow | kicker, eyebrow, overline, badge, category |
| Title | title, heading, headline, name, and label with no link |
| Supporting text | subtitle, subheading, tagline, description, summary, caption, blurb, text, body, alt |
| Banner | image, img, poster, thumbnail, cover, background, avatar, photo, banner, and an image-shaped src |
| Call to action | label/cta/button next to href/url/link/to |
Children render as formatted text, so bold and links inside
<Header>…</Header> are visible while you write. Anything with no role
falls through to a small, faint source line — for those there is nothing
better to say. A tag with nothing to show collapses to its nameplate.
This is a convention, not a contract: Leaf has never seen your <Hero>,
so getting it wrong costs nothing beyond an attribute landing on the
source line. The scale stays close to prose on purpose — a placeholder
that reads like a document, not an imitation of the published component.
Double-click a block to edit its raw source in place; ⌘/Ctrl+Enter or the Save button commits, Escape cancels.
When preserve_tags is missing a tag that the content uses, Leaf emits
a one-off Logger.warning naming it. Disable with:
config :leaf, warn_unpreserved_tags: falseSecurity Note
The deny-list regex sanitization in this component is a UX layer only. Consumers must still validate and allow-list content at the persistence boundary.
Denying features
deny removes affordances entirely — the markup is never rendered, and
the matching client paths refuse to act, so it is one rule rather than a
default a stray click can talk its way past.
| Atom | Effect |
|---|---|
:links | No link button; <a>/[…](…) stripped from content |
:images | No image button; <img>/ stripped from content |
:video | No video button |
:visual_mode | No visual tab, in any switcher |
:hybrid_mode | No hybrid tab, in any switcher |
:markdown_mode | No markdown tab, in any switcher |
:html_mode | No HTML tab, in any switcher |
A host whose documents are built from custom component tags typically wants the markdown surface only — the visual surfaces cannot edit an atomic block's source anyway:
<.leaf_editor id="content-editor" mode={:markdown}
deny={[:visual_mode, :hybrid_mode]} … />Denying the mode the host also passed as mode falls back to the first
allowed mode in :hybrid, :visual, :markdown, :html order.
Denying every mode raises — it is always a mistake. When only one mode
survives the switcher is hidden rather than rendered as a single dead tab.
Commands from Parent
Use send_update/2:
send_update(Leaf, id: "my-editor", action: :insert_image, url: "https://...", alt: "description")
send_update(Leaf, id: "my-editor", action: :set_content, content: "# Hello")
send_update(Leaf, id: "my-editor", action: :set_mode, mode: :visual)
send_update(Leaf, id: "my-editor", action: :flush, ref: "save-42")
send_update(Leaf, id: "my-editor", action: :mark_saved):set_content re-baselines the dirty snapshot by default — replacing the
content programmatically is not a user edit, so an untouched editor still
reads clean and protect_navigation does not prompt. Pass
mark_saved: false to keep the old baseline (i.e. treat the new content
as unsaved work).
JS Setup
Add to your app.js:
import "../../../deps/leaf/priv/static/assets/leaf.js"
let Hooks = {
Leaf: window.LeafHooks.Leaf,
// ... your other hooks
}Checking the bundle is present and current
Leaf does not bundle its own JS into the host — an editor whose hook never attaches renders, looks ordinary and does nothing at all. Two things guard against losing an afternoon to that:
If the hook has not attached shortly after paint, the editor logs a console error naming the likely causes. It stays on its loading shimmer rather than pretending to be an editor.
window.LeafHooks.versionreports the version of the loaded bundle. Compare it againstLeaf.js_version/0(which equals the:leafapplication version) to catch a vendored copy that stayed behind aftermix deps.update leaf:if Leaf.js_version() != vendored_version_from_somewhere do Logger.warning("Vendored leaf.js is stale") endThe client does the same check itself whenever the server-rendered
data-leaf-js-versiondisagrees with the loaded bundle, and warns.
Gettext (optional)
To enable translations, configure a gettext backend:
config :leaf, :gettext_backend, MyApp.GettextOtherwise, English strings are used as-is.
Leaf ships its own catalog template at priv/gettext/leaf.pot — a
host's mix gettext.extract cannot see msgids living in a dependency's
source, so copy it in and merge instead of extracting:
cp deps/leaf/priv/gettext/leaf.pot priv/gettext/leaf.pot
mix gettext.merge priv/gettextThen translate priv/gettext/<locale>/LC_MESSAGES/leaf.po. Lookups try
the "leaf" domain first and fall back to the "default" domain, so
hosts that would rather keep every string in default.po can append the
msgids there and skip the extra domain.
Summary
Functions
The version of the JS bundle this library ships, as a string.
Renders a Leaf editor as a function component.
Functions
@spec js_version() :: String.t()
The version of the JS bundle this library ships, as a string.
Equals the :leaf application version. Hosts that vendor
priv/static/assets/leaf.js into their own asset pipeline (rather than
importing it from deps/) can compare this against
window.LeafHooks.version to catch a copy that stayed behind after
mix deps.update leaf — the editor renders identically either way, so
a stale bundle is otherwise silent.
Renders a Leaf editor as a function component.
This is a convenience wrapper around the Leaf LiveComponent.
Import it in your view helpers:
import Leaf, only: [leaf_editor: 1]Then use it in your templates:
<.leaf_editor id="my-editor" content={@content} />All attributes are passed through to the underlying LiveComponent.
Attributes
id(:string) (required)content(:string) - Defaults to"".mode(:atom) - Defaults to:hybrid. Must be one of:visual,:hybrid,:markdown, or:html.preset(:atom) - Defaults to:advanced. Must be one of:advanced, or:simple.toolbar(:list) - Defaults to[].deny(:list) - Features to remove entirely::links,:images,:video,:visual_mode,:hybrid_mode,:markdown_mode,:html_mode. Denied modes lose their tab in every switcher and refuse a:set_modecommand. Denying all four modes raises.Defaults to
[].placeholder(:string) - Defaults to"Write something...".readonly(:boolean) - Defaults tofalse.height(:string) - Defaults to"480px".min_height(:string) - Defaults tonil.max_height(:string) - Defaults tonil.debounce(:integer) - Defaults to400.flush_on_blur(:boolean) - Defaults totrue.emit_events(:boolean) - Defaults tofalse.toolbar_extra(:list) - Host-defined toolbar buttons. Each entry is a map (atom or string keys)::id— required; echoed back as{:leaf_toolbar_action, %{id: id}}:label— text shown on the button:title— tooltip / aria-label:icon— rendered as raw markup so an inline<svg>works. That makes it trusted HTML: never build it from user-influenced input, or you have an XSS. Use:glyphfor a built-in icon name instead when you don't need custom artwork.:glyph— name of a bundled icon, used in the overflow menus:class— extra classes on the button:collapse—falsepins the button to the main toolbar row instead of letting it fold into the "More" menu when the toolbar gets narrow. Use it for the actions your documents are actually built from; leave it unset for secondary tools.
Defaults to
[].toolbar_layout(:atom) - Defaults to:fixed. Must be one of:fixed,:floating, or:both.preserve_tags(:list) - Custom component tag names (["Hero", "Showcase"]) to protect from the HTML round-trip. Required for any content using such tags — see the "Custom component tags" section; without it the visual and hybrid surfaces flatten them into loose paragraphs.Defaults to
[].maxlength(:integer) - Defaults tonil.spellcheck(:boolean) - Defaults totrue.dir(:string) - Defaults to"ltr". Must be one of"ltr","rtl", or"auto".smart_typography(:boolean) - Defaults tofalse.export(:boolean) - Defaults tofalse.protect_navigation(:boolean) - Defaults tofalse.save_status(:atom) - Defaults tonil.Must be one ofnil,:saved,:saving, or:unsaved.suggestions(:list) - Defaults to[].gettext_backend(:any) - Defaults tonil.upload_handler(:any) - Defaults tonil.sync_input_name(:string) - Defaults tonil.class(:string) - Defaults tonil.script_nonce(:string) - CSP nonce applied to the inline<style>block and to the bundle-presence<script>(see:bundle_check).Defaults to
"".bundle_check(:boolean) - Emit the tiny inline<script>that logs a console error when the Leaf JS hook never attaches — the difference between "the editor silently does nothing" and a one-line diagnosis.Set
falsefor a host whose CSP forbids inline scripts and which cannot supply ascript_nonce; the check is a diagnostic, nothing depends on it. Everything else about the editor is unaffected.Defaults to
true.loading_preset(:atom) - Defaults to:random. Must be one of:default,:random,:unpuzzling,:brewing,:polishing,:composing,:crafting, or:tidying.loading_text(:string) - Defaults tonil.Global attributes are accepted.