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