Coelho.LiveView (coelho v0.2.0)

Copy Markdown View Source

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.

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. The consequence is that the button list is fixed once rendered — changing :toolbar or the schema on a mounted editor will not redraw it.

The component ships no styles. It gives CSS what it needs on the editor's own element, which ProseMirror creates inside the ignored container: coelho-empty while the document has no text, and data-placeholder, carried in from the container the server rendered it on. An empty editor shows its placeholder with

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

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] }
})

Summary

Functions

Renders the rich text editor for a form field.

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

Inserts a node at the editor's selection.

Functions

coelho_editor(assigns)

Renders the rich text editor for a form field.

Attributes

  • field (Phoenix.HTML.FormField) (required)
  • 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.
  • toolbar (:list) - commands to show, in order; an empty list hides the toolbar. Defaults to ["bold", "italic", "strike", "code", "link", "heading", "bullet_list", "ordered_list", "blockquote", "caption"].
  • upload (:any) - an %Phoenix.LiveView.UploadConfig{}; enables dropping and pasting files. Defaults to nil.
  • placeholder (:string) - Defaults to nil.
  • class (:string) - Defaults to nil.
  • Global attributes are accepted.

editor_id(arg)

@spec editor_id(Phoenix.HTML.FormField.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(socket, node, opts \\ [])

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.