Visual WYSIWYG + Obsidian-style hybrid live preview + markdown editor for Phoenix LiveView.
- Visual mode: contenteditable div with toolbar formatting (bold, italic, headings, lists, links, code blocks, tables, blockquotes, inline spoilers, etc.)
- Hybrid mode (Obsidian-style live preview): formatting renders inline (bold, italic, strike, code, spoiler, headings, horizontal rule, lists) while the source markers stay editable — typing
**word**,*word*,~~word~~,||word||,`word`,# heading,---,- item, or1. itemauto-formats on the closing delimiter, and the markers fade in/out as the cursor enters and leaves each formatted run - Markdown mode: plain textarea with toolbar support
- HTML mode: raw HTML editing for power users
- Responsive toolbar: compact, stationary controls for narrow embeds and mobile comment editors; advanced tools progressively move into menus instead of requiring horizontal scrolling
- Drag-and-drop reordering: drag any block element (headings, paragraphs, lists, images, blockquotes, code blocks) to rearrange content
- Resizable: drag the bottom-right grip to change height; double-click the grip to auto-fit to content
- Spoilers: Discord-style
||hidden||markdown that renders as a click-to-reveal censored block in published content - Content syncs between modes via Earmark and client-side HTML→Markdown conversion
- No npm dependencies — vendored JS bundle
Add leaf to your dependencies in mix.exs:
def deps do
[
{:leaf, "~> 0.2.0"}
]
endIn 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
}
})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.2.20/priv/static/assets/leaf.js";
script.onload = () => {
// Leaf is now available at window.LeafHooks
};
document.head.appendChild(script);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.
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]}
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]}
placeholder="Write something..."
readonly={false}
height="480px"
debounce={400}
/>| Assign | Type | Default | Description |
|---|---|---|---|
id |
string | required | Unique editor ID |
content |
string | "" |
Markdown content |
mode |
:hybrid | :visual | :markdown | :html |
:hybrid |
Initial editor mode |
preset |
:advanced | :simple |
:advanced |
Toolbar preset; :simple is a compact subset for comments and lightweight editing |
toolbar |
list | [] |
Extra toolbar buttons (:image, :video) |
placeholder |
string | "Write something..." |
Placeholder text shown when the editor is empty |
readonly |
boolean | false |
Read-only mode |
height |
string | "480px" |
Editor height (the body resizes from this baseline) |
debounce |
integer | 400 |
Debounce interval in ms for content-change events |
loading_preset |
atom | :random |
Pre-mount loading label preset: :random picks from :unpuzzling, :brewing, :polishing, :composing, :crafting, :tidying. :default shows plain "Loading…" |
loading_text |
string | nil |
Custom loading label; takes precedence over loading_preset when set |
upload_handler |
any | nil |
Hint 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 |
class |
string | nil |
Extra classes for the wrapper |
script_nonce |
string | "" |
CSP nonce for the inline <style> block |
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# 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)To enable translations for toolbar tooltips:
# config/config.exs
config :leaf, :gettext_backend, MyApp.GettextWithout this config, English strings are used as-is.
MIT — see LICENSE.