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 when suggestions is 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.

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

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

Otherwise, English strings are used as-is.

Summary

Functions

Renders a Leaf editor as a function component.

Functions

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) - 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) - Defaults to [].
  • toolbar_layout (:atom) - Defaults to :fixed. Must be one of :fixed, :floating, or :both.
  • preserve_tags (:list) - 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) - 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 to nil.
  • Global attributes are accepted.