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 explicit action: :flush that carried a ref (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 when suggestions is 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
end

ref 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.

KeyDefaultMeaning
:triggerRequired. The character(s) that open the popup.
:boundary:word_startWhere 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}_-]/uCharacters that continue the token. Typing anything else closes the popup.
:first_charnoneExtra constraint on the first character after the trigger.
:min_chars0Query length before the popup opens. 0 opens on the bare trigger.
:max_lengthunlimitedLongest token that still counts.
:debounce150Milliseconds before the query goes to the host.
:max_results10Rows rendered from the host's reply.
:allow_createfalseAdds a "Create …" row when nothing matches exactly.
:keep_triggertrueWhether 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.
:labelnoneHeading 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}
end

Results 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, query and seq back 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 #tag is 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 is not opaque: it shows the tag name, its attributes, a thumbnail for any image-ish attribute (src, image, poster, …) and the tag's own children rendered as formatted text — so links, emphasis and images inside <Header>…</Header> are visible while you write. Double-click a block to edit its raw source in place and Enter (or the Save button) to commit; 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: false

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.

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.

AtomEffect
:linksNo link button; <a>/[…](…) stripped from content
:imagesNo image button; <img>/![…](…) stripped from content
:videoNo video button
:visual_modeNo visual tab, in any switcher
:hybrid_modeNo hybrid tab, in any switcher
:markdown_modeNo markdown tab, in any switcher
:html_modeNo 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.version reports the version of the loaded bundle. Compare it against Leaf.js_version/0 (which equals the :leaf application version) to catch a vendored copy that stayed behind after mix deps.update leaf:

    if Leaf.js_version() != vendored_version_from_somewhere do
      Logger.warning("Vendored leaf.js is stale")
    end

    The client does the same check itself whenever the server-rendered data-leaf-js-version disagrees with the loaded bundle, and warns.

Gettext (optional)

To enable translations, configure a gettext backend:

config :leaf, :gettext_backend, MyApp.Gettext

Otherwise, 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/gettext

Then 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

js_version()

@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.

leaf_editor(assigns)

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_mode command. Denying all four modes raises.

    Defaults to [].

  • placeholder (:string) - Defaults to "Write something...".

  • readonly (:boolean) - Defaults to false.

  • height (:string) - Defaults to "480px".

  • min_height (:string) - Defaults to nil.

  • max_height (:string) - Defaults to nil.

  • debounce (:integer) - Defaults to 400.

  • flush_on_blur (:boolean) - Defaults to true.

  • emit_events (:boolean) - Defaults to false.

  • 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
    • :iconrendered 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 :glyph for 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
    • :collapsefalse pins 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 to nil.

  • spellcheck (:boolean) - Defaults to true.

  • dir (:string) - Defaults to "ltr". Must be one of "ltr", "rtl", or "auto".

  • smart_typography (:boolean) - Defaults to false.

  • export (:boolean) - Defaults to false.

  • protect_navigation (:boolean) - Defaults to false.

  • save_status (:atom) - Defaults to nil.Must be one of nil, :saved, :saving, or :unsaved.

  • suggestions (:list) - Defaults to [].

  • gettext_backend (:any) - Defaults to nil.

  • upload_handler (:any) - Defaults to nil.

  • sync_input_name (:string) - Defaults to nil.

  • class (:string) - Defaults to nil.

  • 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 false for a host whose CSP forbids inline scripts and which cannot supply a script_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 to nil.

  • Global attributes are accepted.