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_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)
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, 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.
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").
Security 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.
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)JS Setup
Add to your app.js:
import "../../../deps/leaf/priv/static/assets/leaf.js"
let Hooks = {
Leaf: window.LeafHooks.Leaf,
// ... your other hooks
}Gettext (optional)
To enable translations, configure a gettext backend:
config :leaf, :gettext_backend, MyApp.GettextOtherwise, English strings are used as-is.
Summary
Functions
Renders a Leaf editor as a function component.
Functions
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) - 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) - Defaults to[].toolbar_layout(:atom) - Defaults to:fixed. Must be one of:fixed,:floating, or:both.preserve_tags(:list) - 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) - Defaults to"".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.