Leaf

Visual WYSIWYG + Obsidian-style hybrid live preview + markdown editor for Phoenix LiveView.

Live Demo

Leaf Editor

Installation

Add leaf to your dependencies in mix.exs:

def deps do
[
{:leaf, "~> 0.4.0"}
]
end

JavaScript Setup

In your app.js, import the JS and register the hook:

import "../../../deps/leaf/priv/static/assets/leaf.js"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: {
Leaf: window.LeafHooks.Leaf,
// ... your other hooks
}
})

CDN Alternative

If you prefer not to use the deps/ import path (e.g., non-standard project structure), you can load the JS from CDN instead:

// Load Leaf from CDN
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/gh/alexdont/leaf@v0.4.0/priv/static/assets/leaf.js";
script.onload = () => {
// Leaf is now available at window.LeafHooks
};
document.head.appendChild(script);

Peer Requirements

Leaf's toolbar uses Tailwind CSS + daisyUI classes (btn, btn-xs, divider, textarea, etc.) and Heroicons CSS classes (hero-*). Make sure these are available in your project.

Usage

First, import the component in your view helpers (e.g., in my_app_web.ex):

import Leaf, only: [leaf_editor: 1]

Then use it in your templates:

<.leaf_editor
id="my-editor"
content={@content}
mode={:visual}
toolbar={[:image, :video]}
deny={[:links, :images, :markdown_mode]}
placeholder="Write something..."
readonly={false}
height="480px"
debounce={400}
/>
Alternative: direct LiveComponent syntax
<.live_component
module={Leaf}
id="my-editor"
content={@content}
mode={:visual}
toolbar={[:image, :video]}
deny={[:links, :images, :markdown_mode]}
placeholder="Write something..."
readonly={false}
height="480px"
debounce={400}
/>

Assigns

AssignTypeDefaultDescription
idstringrequiredUnique editor ID
contentstring""Markdown content
mode:hybrid | :visual | :markdown | :html:hybridInitial editor mode
preset:advanced | :simple:advancedToolbar preset; :simple is a compact subset for comments and lightweight editing
toolbarlist[]Extra toolbar buttons (:image, :video)
denylist[]Disallowed features (:links, :images, :video, :markdown_mode, :html_mode); denied controls are hidden from the UI
placeholderstring"Write something..."Placeholder text shown when the editor is empty
readonlybooleanfalseRead-only mode
heightstring"480px"Editor height (the body resizes from this baseline)
debounceinteger400Debounce interval in ms for content-change events
loading_presetatom:randomPre-mount loading label preset: :random picks from :unpuzzling, :brewing, :polishing, :composing, :crafting, :tidying. :default shows plain "Loading…"
loading_textstringnilCustom loading label; takes precedence over loading_preset when set
upload_handleranynilHint that the consumer supports uploads. When set, the main image button asks the parent for an upload via :leaf_insert_request; when nil, it opens the by-URL dialog directly
suggestionslist[]Inline-suggestion trigger configs — see Inline suggestions
classstringnilExtra classes for the wrapper
script_noncestring""CSP nonce for the inline <style> block

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. Works in all four modes.

<.leaf_editor
id="post-editor"
content={@content}
suggestions={[
%{
trigger: "#",
boundary: :word_start,
token: ~r/[\p{L}\p{N}_-]/u,
first_char: ~r/\p{L}/u,
max_length: 30,
allow_create: true,
insert_suffix: " ",
label: "Tags"
}
]}
/>
def handle_info({:leaf_suggest, %{editor_id: id, trigger: "#", query: q, seq: seq}}, socket) do
results =
Enum.map(my_tag_source(q), 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

Every config key but :trigger is optional; keys may be atoms or strings. :boundary (:word_start / :line_start / :any), :token, :first_char, :min_chars, :max_length, :debounce, :max_results, :allow_create, :keep_trigger, :insert_suffix, :label and :exclude are documented in full in the Leaf moduledoc.

Two rules matter more than the shape: echo trigger, query and seq back unchanged so the client can drop replies a later keystroke superseded, and know that typing is never blocked — a host that never answers gets a short spinner and then the popup closes on its own.

By default the popup stays shut inside fenced/inline code, inside a markdown link destination ([jump](#section)) and after a non-space character (URL fragments like /page#section). ↑/↓ move, Enter and Tab accept, Escape dismisses; while it is open Enter neither inserts a newline, nor continues a list, nor submits the surrounding form.

A runnable two-trigger example (# tags and / components) lives in the demo app's HomeLive.

Messages to Parent

Handle these in your LiveView's handle_info/2:

def handle_info({:leaf_changed, %{editor_id: id, markdown: md, html: html}}, socket) do
# Content was updated
{:noreply, assign(socket, :content, md)}
end
def handle_info({:leaf_insert_request, %{editor_id: id, type: :image}}, socket) do
# User clicked the image toolbar button — show your image picker
{:noreply, socket}
end
def handle_info({:leaf_mode_changed, %{editor_id: id, mode: mode}}, socket) do
# Mode switched between :visual and :markdown
{:noreply, socket}
end
def handle_info({:leaf_suggest, %{editor_id: id, trigger: t, query: q, seq: seq}}, socket) do
# Only sent when `suggestions` is configured — see "Inline suggestions"
{:noreply, socket}
end

Commands from Parent

# Insert an image at the cursor position
send_update(Leaf, id: "my-editor", action: :insert_image, url: "https://...", alt: "description")
# Replace all content
send_update(Leaf, id: "my-editor", action: :set_content, content: "# New content")
# Switch mode programmatically
send_update(Leaf, id: "my-editor", action: :set_mode, mode: :markdown)
# Answer a {:leaf_suggest, …} request (echo trigger/query/seq back unchanged)
send_update(Leaf,
id: "my-editor",
action: :suggestions,
trigger: "#",
query: "eli",
seq: 7,
results: [%{value: "elixir", label: "#elixir", sublabel: "12 posts", icon: "hero-hashtag"}]
)

Gettext (optional)

To enable translations for toolbar tooltips:

# config/config.exs
config :leaf, :gettext_backend, MyApp.Gettext

Without this config, English strings are used as-is.

License

MIT — see LICENSE.