# `Coelho.LiveView`
[🔗](https://github.com/nseaSeb/coelho/blob/main/lib/coelho/live_view.ex#L2)

The editor, as a function component.

    <.coelho_editor field={@form[:body]} />

## What the component actually does

It renders three things: a toolbar, an empty container, and a hidden
input carrying the document as JSON. The container is the editor's, and
it is marked `phx-update="ignore"` — ProseMirror owns that subtree and
LiveView must never patch it. Everything the server needs to know
travels through the hidden input, so the editor is an ordinary form
field: `Ecto.Changeset.cast/3` sees it, `phx-change` sees it, and no
special server side event is involved.

The schema is serialised into a `data-` attribute, so the browser builds
its ProseMirror schema from the same declaration that validates the
document server side.

## Captions

A caption is an attribute of the node carrying it, not content inside it,
so `caption` in the toolbar opens the same field on whichever node is
selected and declares the attribute — an attachment, by default. The
button is disabled the rest of the time.

## Links

When the toolbar carries `link`, the component renders a field beside it
rather than reaching for `window.prompt`, which blocks the page and
ignores the application's design. The field opens on the selection, or on
the whole link under the cursor when there is no selection; `Enter`
confirms, `Escape` closes, and emptying it removes the link without
touching the text.

An application with its own link interface listens for the cancelable
`coelho:link` event on the editor element and calls
`event.detail.apply(href)` when it has an answer.

## Styling

The toolbar carries `phx-update="ignore"` for the same reason the editor
does: the hook keeps `aria-pressed` on each button in step with what is in
force under the cursor, and LiveView would patch that away on the next
render. Which would freeze the button list — so the toolbar's own id
carries a fingerprint of everything it is drawn from, the schema, the
commands and their labels, and LiveView replaces the whole toolbar when
any of the three moves. Switching language mid-session redraws it.

A starter stylesheet ships with the package, at
`assets/css/coelho.css`. Import it and the editor is usable — toolbar,
content, lists, quotes, code, focus, the pressed state of a command, a
counter that has gone over:

    /* assets/css/app.css */
    @import "../../deps/coelho/assets/css/coelho.css";

It carries no identity of its own. Every colour, radius and space in it
comes from a custom property on `.coelho` with a neutral default, so an
application overrides the properties rather than the rules:

    .coelho {
      --coelho-accent: var(--brand);
      --coelho-radius: 2px;
      --coelho-surface: var(--paper);
    }

Copy it instead if you would rather own it — it is a hundred and some
lines and nothing else depends on it.

The hooks it is written against are the contract either way: `.coelho` on
the root, `.coelho-toolbar` and `.coelho-command` (with `aria-pressed`
and `disabled`, and `.coelho-icon` on the drawing inside it),
`.coelho-link` and `.coelho-link-input`,
`.coelho-counter` (gaining `coelho-over` past the limit),
`.coelho-content`, and on the editor's own element the class
`coelho-empty` while the document has no text, plus `data-placeholder`
carried in from the container the server rendered it on:

    .coelho-content .ProseMirror.coelho-empty::before {
      content: attr(data-placeholder);
    }

Both live *inside* the ignored subtree on purpose: `phx-update="ignore"`
stops LiveView patching an element's children, not its own attributes, so
a class or attribute JavaScript writes on the root or on the container is
undone by the next render.

A placeholder node would have to be a node, and would end up validated,
stored and rendered; this stays out of the document entirely.

## Attachments

Pass an upload config and the editor accepts dropped and pasted files,
handing them to LiveView's own upload channel. The application consumes
them, stores the bytes wherever it likes, and pushes back the node to
insert:

    <.coelho_editor field={@form[:body]} upload={@uploads.attachment} />

    def handle_progress(:attachment, entry, socket) when entry.done? do
      attachment = consume_uploaded_entry(socket, entry, &MyApp.Uploads.store/1)

      {:noreply,
       insert_node(socket, Coelho.Attachment.to_node(attachment),
         preview: MyApp.Uploads.url(attachment.key)
       )}
    end

The preview is for the editor's eyes only. What gets stored in the
document is the key; the URL is resolved again on every render. See
`Coelho.Attachments`.

## What the keyboard does

Bound by the hook, and only for the nodes and marks the schema actually
declares — a keymap for a mark that is not there would be a shortcut that
silently does nothing:

| Keys | What |
| --- | --- |
| `Mod-b`, `Mod-i`, `Mod-e` | bold, italic, inline code |
| `Mod-z`, `Shift-Mod-z`, `Mod-y` | undo, redo, redo |
| `Enter` in a list | a new item, splitting the one you are in |
| `Mod-[`, `Mod-]` | lift the item out, sink it in |
| `Shift-Enter`, `Mod-Enter` | a line break, and out of a code block |
| `Enter`, `Escape` in the link field | confirm, close |

`Mod` is Cmd on a Mac and Ctrl everywhere else. Everything else is
ProseMirror's base keymap — the arrows, backspace joining blocks,
select-all — bound underneath and left alone.

Emptying the link field and confirming removes the link and keeps the
text, which is the one gesture with no key of its own.

## Wiring the hook

The JavaScript side ships with the package. In `assets/js/app.js`:

    import { Coelho } from "../../deps/coelho/assets/js/coelho.js"

    const liveSocket = new LiveSocket("/live", Socket, {
      hooks: { Coelho, ...otherHooks }
    })

It expects `@nseaprotector/acme-script`, `prosemirror-state`,
`prosemirror-view`, `prosemirror-model`, `prosemirror-keymap`,
`prosemirror-commands` and `prosemirror-history` to be installed in the
application.

## What stays the browser's

Two things about a node cannot come from Elixir, because both are
functions: how it *looks* (`toDOM`/`parseDOM`) and how it *behaves* — the
drag handles on an image, a menu on an embed. `createCoelhoHook/1` takes
the first as `nodes`/`marks` and the second as `nodeViews`, which is
ProseMirror's own extension point, handed through untouched.

Resizing an image is an example of the division. The *size* is a schema
attribute like any other, added with `Coelho.Schema.extend/2`, validated
and stored like any other; the *handles* that set it are a node view. And
producing a smaller file — a thumbnail, a variant — is neither: the
document stores a key, and what a key resolves to is the application's,
so a resolver can answer with a variant it generated however it likes.
Coelho never touches the bytes.

A schema of your own also needs its DOM mapping on the browser side, which
`createCoelhoHook/1` takes:

    import { createCoelhoHook } from "../../deps/coelho/assets/js/coelho.js"

    const Coelho = createCoelhoHook({
      nodes: { mention: (node) => ["span", { class: "mention" }, "@" + node.attrs.user_id] }
    })

# `coelho_editor`

Renders the rich text editor.

Give it a form field, or a name and a value:

    <.coelho_editor field={@form[:body]} />
    <.coelho_editor name="page[intro_doc]" value={@draft["intro_doc"]} />

The second form is for a surface that has no changeset behind it — a
JSONB draft whose keys are historical, a field posted straight into
`phx-change` — where building a `%Phoenix.HTML.FormField{}` to satisfy
the component would be building a fiction.

## Losing the last keystrokes, and how not to

The editor writes into its hidden input and lets `phx-change` carry it,
which means a `phx-debounce` can still be holding the last edit when the
block leaves the DOM. Nothing arrives, and the writer loses what they
typed last. Cancelling a draft, switching a tab, collapsing a section:
each of those removes the editor, and each of those is where it bites.

`:flush_event` closes it. The hook pushes the document on the way out:

    <.coelho_editor
      name="page[intro_doc]"
      value={@draft["intro_doc"]}
      flush_event="flush"
      flush_token={@generation}
    />

    def handle_event("flush", %{"token" => token, "name" => name, "document" => document}, socket) do
      if token == to_string(socket.assigns.generation) do
        {:noreply, put_draft(socket, name, document)}
      else
        {:noreply, socket}
      end
    end

**The token comes back as a string.** It travels as a DOM attribute, so
whatever it was rendered from arrives as text: comparing it to an integer
generation is always false, and every flush is dropped by the clause that
was meant to catch the stale ones — the data loss `:flush_event` exists
to prevent, failing silently.

The token is yours and the comparison is yours, because only the
application knows what a generation is. It matters: cancelling a draft
re-renders the editors, and the editors being torn down flush *the
content from before the cancellation*. Without a token that the
application bumps when it cancels, the flush puts back exactly what was
just thrown away.

## What the buttons show

An icon, drawn by `Coelho.Icons`, with the command's name as its tooltip
and its accessible name — so a pointer and a screen reader are told the
same thing. The names are English until `:labels` says otherwise, and a
command the library does not draw shows its label as text instead, which
is what one an application added does until it is given an icon:

    <.coelho_editor
      field={@form[:body]}
      toolbar={~w(bold italic highlight)}
      icons={%{"highlight" => MyApp.Icons.highlight()}}
    />

An icon has to arrive already safe — a `~H` sigil, `Phoenix.HTML.raw/1`,
or a `{:safe, iodata}` — because it is rendered as markup rather than
escaped. A plain string is escaped like any other text and shows as tag
soup in the button, which is the right way round: markup is what an
application states, never what it happens to hold.

An icon is sized by the `--coelho-icon` custom property, whether it is
the library's or the application's — the stylesheet asks for an `svg` or
an `img` inside the button rather than for a class, so one supplied
without `.coelho-icon` is sized all the same.

## What the field says

The link and caption field carries three strings, and they are English
until an application says otherwise:

    <.coelho_editor
      field={@form[:body]}
      labels={%{"link" => gettext("Link")}}
      field_labels={%{
        "link_label" => gettext("Link address"),
        "link_placeholder" => gettext("https://… then Enter"),
        "link_hint" => gettext("Empty the field to remove the link."),
        "caption_label" => gettext("Caption"),
        "caption_placeholder" => gettext("Describe this attachment")
      }}
    />

`:labels` names the toolbar's buttons and `:field_labels` what the field
beside them says; they are separate because the first is a command and
the second is a sentence. The hint is shown under the field while it is
open, and there is no hint at all unless one is given — the gesture it
describes, emptying the field to remove the link, is otherwise something
a writer has to be told or discover.

Changing either redraws the toolbar, so a language switched mid-session
reaches both.

## Counting characters

`:maxlength` renders a counter beside the toolbar and keeps it in step.
The count is `Coelho.Document.text_length/1` — the text nodes
concatenated, which is what the writer typed — and the server renders the
first one, so an existing document does not read zero until the hook has
started.

The attribute does not stop anyone typing. What it does is show the
number and mark the counter with `coelho-over` past the limit; refusing
the document is the schema's job, through `limits: [max_text_length: …]`,
and doing it in two places would let the two disagree.

## Following a schema change

The container carries `phx-update="ignore"`, so LiveView never patches
what ProseMirror owns — which used to mean that **a schema changed on a
mounted editor was not picked up**: the classes, the marks and the node
types stayed the ones read at mount, and what the writer saw stopped
matching what the page would render.

The hook now watches an exported-schema fingerprint on its own element
and rebuilds the view when it moves, keeping the document. The toolbar is
redrawn with it. Ids are untouched, so `editor_id/1` and `insert_node/3`
go on working.

## Attributes

* `field` (`Phoenix.HTML.FormField`) - a form field; give this or `:name` and `:value`. Defaults to `nil`.
* `name` (`:string`) - the hidden input's name, without a form field. Defaults to `nil`.
* `value` (`:any`) - the document, without a form field. Defaults to `nil`.
* `id` (`:string`) - defaults to the field's own id, suffixed. Defaults to `nil`.
* `document_schema` (`Coelho.Schema`) - the schema to edit against, `Coelho.Schema.default/0` when omitted. Defaults to `nil`.
* `schema_id` (`:string`) - id of a `coelho_schema/1` to read the schema from, instead of carrying it. Defaults to `nil`.
* `toolbar` (`:list`) - commands to show, in order; an empty list hides the toolbar. Any mark the schema declares is a command, as are `align_left`, `align_center`, `align_right` and `align_justify` where a node declares `align`, and `heading_1` to `heading_6` where its `:level` accepts the number — `heading` on its own makes the level the schema calls default. A `{"insert", node: …, attrs: …, label: …}` entry puts a declared inline void node at the cursor, and `{"insert", text: …}` puts characters there. Every command is filtered against the schema: a button is rendered only where its command can run, and only with a value the attribute's own validator accepts. Defaults to `["bold", "italic", "strike", "code", "link", "heading", "bullet_list", "ordered_list", "blockquote", "caption"]`.
* `labels` (`:map`) - command to label, for a toolbar that has to speak the reader's language. The label is the button's tooltip and its accessible name; anything left out keeps its English. Changing them on a mounted editor redraws the toolbar. Defaults to `%{}`.
* `icons` (`:map`) - command to icon, replacing what `Coelho.Icons` draws — one of them, or
  all. Each has to be safe markup already (a `~H` sigil,
  `Phoenix.HTML.raw/1`, `{:safe, iodata}`), since it is rendered rather
  than escaped; a plain string is escaped and shows as tag soup. A
  command with no icon on either side shows its label as text

  Defaults to `%{}`.
* `field_labels` (`:map`) - what the link and caption field says, for an application with a
  translator. Keys: `"link_label"`, `"link_placeholder"`, `"link_hint"`,
  `"caption_label"`, `"caption_placeholder"`, `"caption_hint"`. Anything
  left out keeps its English

  Defaults to `%{}`.
* `maxlength` (`:integer`) - shows a character counter; does not enforce. Defaults to `nil`.
* `flush_event` (`:string`) - event pushed with the document when the editor leaves the DOM. Defaults to `nil`.
* `flush_token` (`:any`) - sent back with `:flush_event`, for the application to refuse a stale flush. It travels as a DOM attribute, so it arrives back as a string. Defaults to `nil`.
* `upload` (`:any`) - an `%Phoenix.LiveView.UploadConfig{}`; enables dropping and pasting files. Defaults to `nil`.
* `debounce` (`:integer`) - `phx-debounce` in milliseconds for the hidden input the document travels
  in. Without it every keystroke ships the whole document and the server
  decodes and validates it again — the cost that matters on a long
  document beside a live preview. LiveView reads the attribute off the
  element that emits the event and walks no ancestors, so it cannot be
  given from the outside: neither `:rest`, which lands on the root, nor
  the enclosing form reaches the input.

  Milliseconds and nothing else. LiveView's `"blur"` waits for a blur
  event on the element carrying the attribute, and a `type="hidden"` input
  never blurs — the change would be held for the life of the page.

  Pair it with `:flush_event`: a debounce still holding the last edit when
  the element goes loses it, and that is what the flush is for.

  An upload on the same form is fine. It did not use to be: anything that
  re-rendered the form while a change was still pending — an upload
  finishing was enough — patched this field with the server's older copy,
  and the editor went on holding the newer document while the field
  posted the older one. The field now follows the editor whenever it is
  handed a document the editor has already moved past

  Defaults to `nil`.
* `placeholder` (`:string`) - Defaults to `nil`.
* `class` (`:string`) - Defaults to `nil`.
* Global attributes are accepted.

# `coelho_schema`

Renders the schema once, for several editors to share.

Each editor otherwise carries the whole exported schema in a `data-`
attribute of its own — 1.3 KB for the schema that ships, which six
editors on a page turn into eight. Render this once and point the
editors at it:

    <.coelho_schema id="page-schema" document_schema={MyApp.RichText.schema()} />

    <.coelho_editor
      name="page[intro_doc]"
      value={@draft["intro_doc"]}
      document_schema={MyApp.RichText.schema()}
      schema_id="page-schema"
    />

**Give the editors the same `:document_schema`.** `:schema_id` says where
the exported JSON lives, not which schema it is: the editor still filters
its toolbar and stamps its fingerprint from its own `:document_schema`,
and an editor working from one schema while reading another is a mismatch
that would show up as buttons quietly doing nothing. The fingerprints are
compared in the browser, so the mismatch is an error and not a mystery.

The saving is on the first render. LiveView omits an unchanged dynamic
from a diff, so the repetition does not cost anything again on every
patch — but it is still eight kilobytes of the page that opens.

## Attributes

* `id` (`:string`) (required)
* `document_schema` (`Coelho.Schema`) - the schema to export, `Coelho.Schema.default/0` when omitted. Defaults to `nil`.

# `editor_id`

```elixir
@spec editor_id(Phoenix.HTML.FormField.t() | String.t()) :: String.t()
```

The DOM id of the editor rendered for a form field.

What `insert_node/3` needs to reach one editor rather than all of them.

# `insert_node`

```elixir
@spec insert_node(Phoenix.LiveView.Socket.t(), map(), keyword()) ::
  Phoenix.LiveView.Socket.t()
```

Inserts a node at the editor's selection.

The way anything the server decides on reaches the document: an
attachment it has just stored, a mention it has just resolved, an embed
it has just fetched. The node is built server side, against the same
schema that will validate it on the way back.

    socket
    |> Coelho.LiveView.insert_node(Coelho.Attachment.to_node(attachment),
         id: editor_id(@form[:body]),
         preview: MyApp.Uploads.url(attachment.key))

## Options

  * `:id` — which editor to insert into, as `editor_id/1` returns it.
    `push_event/3` reaches the whole page, so **without this every editor
    on it inserts the node**, which is only ever right when there is one.
  * `:preview` — for the editor's eyes only: an attachment's URL, which
    the document does not carry and the renderer resolves again on every
    render.

---

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