# `Leaf`
[🔗](https://github.com/alexdont/leaf/blob/main/lib/leaf.ex#L1)

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.

| Key | Default | Meaning |
| --- | --- | --- |
| `:trigger` | — | Required. The character(s) that open the popup. |
| `:boundary` | `:word_start` | Where 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}_-]/u` | Characters that continue the token. Typing anything else closes the popup. |
| `:first_char` | none | Extra constraint on the first character after the trigger. |
| `:min_chars` | `0` | Query length before the popup opens. `0` opens on the bare trigger. |
| `:max_length` | unlimited | Longest token that still counts. |
| `:debounce` | `150` | Milliseconds before the query goes to the host. |
| `:max_results` | `10` | Rows rendered from the host's reply. |
| `:allow_create` | `false` | Adds a "Create …" row when nothing matches exactly. |
| `:keep_trigger` | `true` | Whether 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. |
| `:label` | none | Heading 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 {: .warning}
>
> 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 reads as a *preview of the component*, not as its source.
Known attribute names map to typographic roles and are typeset in the
editor's own prose voice:

| Role | Attribute names |
| --- | --- |
| Eyebrow | `kicker`, `eyebrow`, `overline`, `badge`, `category` |
| Title | `title`, `heading`, `headline`, `name`, and `label` with no link |
| Supporting text | `subtitle`, `subheading`, `tagline`, `description`, `summary`, `caption`, `blurb`, `text`, `body`, `alt` |
| Banner | `image`, `img`, `poster`, `thumbnail`, `cover`, `background`, `avatar`, `photo`, `banner`, and an image-shaped `src` |
| Call to action | `label`/`cta`/`button` next to `href`/`url`/`link`/`to` |

Children render as formatted text, so bold and links inside
`<Header>…</Header>` are visible while you write. Anything with no role
falls through to a small, faint source line — for those there is nothing
better to say. A tag with nothing to show collapses to its nameplate.

This is a convention, not a contract: Leaf has never seen your `<Hero>`,
so getting it wrong costs nothing beyond an attribute landing on the
source line. The scale stays close to prose on purpose — a placeholder
that reads like a document, not an imitation of the published component.

**Double-click** a block to edit its raw source in place; ⌘/Ctrl+Enter
or the Save button commits, 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.

| Atom | Effect |
| --- | --- |
| `:links` | No link button; `<a>`/`[…](…)` stripped from content |
| `:images` | No image button; `<img>`/`![…](…)` stripped from content |
| `:video` | No video button |
| `:visual_mode` | No visual tab, in any switcher |
| `:hybrid_mode` | No hybrid tab, in any switcher |
| `:markdown_mode` | No markdown tab, in any switcher |
| `:html_mode` | No 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.

# `js_version`

```elixir
@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`

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
  - `:icon` — **rendered 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
  - `:collapse` — `false` 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 `[]`.
* `wiki_links` (`:any`) - Defaults to `nil`.
* `collaboration` (`:any`) - Defaults to `nil`.
* `debug_typing` (`:boolean`) - Defaults to `false`.
* `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.

# `update`

Apply someone else's edit to this editor without disturbing the local caret.

`content` is the full document after the edit and `op` is the splice that
produced it (`%{at:, remove:, insert:}`), in markdown character offsets.
Both are needed: the HTML is rendered from `content` because markdown→HTML
only happens here on the server, while `op` is what lets the client work out
where the caret should end up.

Nothing about this is collaboration-specific — a host replaying an edit log
or streaming a generated document can use it the same way. It is a sibling of
`:set_content`, differing only in that it preserves the selection instead of
resetting it, and does not reset the undo stack.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
