The Kotoba editor is a form field. It posts the document with the other
fields of the form, and Kotoba.Content casts it in the changeset.
The field
<.form for={@form} id="post-form" phx-change="validate" phx-submit="save">
<label id="post-body-label">Body</label>
<.kotoba
field={@form[:body]}
id="post-body"
label_id="post-body-label"
placeholder="Write your post..."
/>
</.form>Kotoba.Components.kotoba/1 renders:
- a hidden input with the name of the field (
post[body]). Its value is the JSON of the document. - the editor element, with
phx-hook="Kotoba"andphx-update="ignore". The hook builds the toolbar and the editable area in it. - the LiveView file input, when you give
uploads.
Give the editor an accessible name: label_id (the id of a visible label)
is the best choice. Without it, label gives an aria-label, and the
default is the field name in words ("Body").
What the form posts
The hook writes the document to the hidden input on every change. It also
puts the current document in the form data of phx-change and
phx-submit (through the form's formdata event), so these events always
have the current document, also when a patch came between the change and
the event. A plain HTML submit posts the hidden input.
The param is a JSON string. An empty editor posts the empty document, not
"".
The changeset
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body])
|> validate_required([:title])
|> validate_body()
end
# An empty editor posts an empty document, not nil, so validate_required/2
# does not see it as blank.
defp validate_body(changeset) do
validate_change(changeset, :body, fn :body, %Kotoba.Content{doc: doc} ->
case Kotoba.Document.parse(doc) do
{:ok, parsed} -> if Kotoba.Document.empty?(parsed), do: [body: "can't be blank"], else: []
{:error, _messages} -> [body: "is invalid"]
end
end)
endKotoba.Content.cast/1 parses the JSON, checks every node, and renders
the HTML and the text of the document. Input that is not a valid document
casts to :error, so the changeset gets an "is invalid" error on the
field. See Kotoba.Content for "", nil and empty_values.
Kotoba.Content compares two values by their documents only. So a
changeset does not mark the field as changed when only the cache is new.
Each change as an event
The form's own phx-change and phx-submit events have the document in
their params, so a form needs no other event for the editor.
For a LiveView that wants each change of the document by itself, for
example to save a draft, give the editor change:
<.kotoba field={@form[:body]} id="post-body" change />The editor then pushes kotoba:change with %{"v" => 1, "id" => id, "doc" => envelope} when the document changes, at most once in 300 ms
(set another time with debounce={500}). A LiveView that sets change
must handle the event:
# Act on one editor by its id...
def handle_event("kotoba:change", %{"id" => "post-body", "doc" => doc}, socket) do
{:noreply, save_draft(socket, doc)}
end
# ...and ignore the others.
def handle_event("kotoba:change", _params, socket), do: {:noreply, socket}Without change, the editor pushes no kotoba:change. A change of
change in a later render turns the pushes on or off.
Change the document from the server
The editor reads the hidden input only once, when it mounts. After that, a new value that the server renders into the form does not change the editor: the editor writes its own document back into the input.
To replace the document, push it:
socket = Kotoba.Live.push_content(socket, "post-body", post.body)Kotoba.Live.push_content/3 sends set_content. The editor replaces its
document, clears its undo history, and does not push kotoba:change for
it. The other server events are in Kotoba.Live: insert_node/4,
set_readonly/3, focus/2 and remove_marker/3. Each carries the editor
id, so only that editor acts on it.
The hidden input after a patch
A LiveView patch of the form can put the server's old value back into the
hidden input. The hook listens for LiveView's phx:update event on the
document, and writes the current document into the input again after each
patch.
phx:update is not in the documented hook API of LiveView. phx-change,
phx-submit, form recovery and a plain submit do not depend on it: they
read the form through formdata, which always has the current document.
Only code that reads input.value directly depends on phx:update.
Read-only
<.kotoba field={@form[:body]} readonly={@locked?} />A change of readonly in a later render reaches the editor. You can also
push it with Kotoba.Live.set_readonly/3. Read-only is a state of the
editor in the browser only: check on the server that the person can save
the form.
A LiveComponent
In a LiveComponent, give phx-target={@myself}. The editor then sends its
events (kotoba:prompt, and kotoba:change when you set change) to the
component, and the component must handle them:
<.kotoba field={@form[:body]} id="note-body" phx-target={@myself} />More than one editor
Give each editor its own id. Every event from an editor has its id,
and every server push names the editor, so two editors on a page do not
act on the events of the other.
Features, code languages and the toolbar
An editor has every built-in feature by default. features gives a field
fewer, and Kotoba.Content.validate_features/3 checks them in the
changeset; code_languages chooses the languages of the code language
picker; and a toolbar slot with Kotoba.Components.kotoba_toolbar/1
gives the editor your own toolbar:
<.kotoba field={@form[:comment]} id="comment" features={~w(bold italic links lists)}>
<:toolbar>
<.kotoba_toolbar commands={~w(bold italic link bullet number undo redo)} />
</:toolbar>
</.kotoba>|> Kotoba.Content.validate_features(:comment, ~w(bold italic links lists)a)The Editing features guide has the features, their shortcuts, the code languages and the toolbar commands.
Testing a form
In Phoenix.LiveViewTest, form/3 refuses a value for a hidden input
that differs from the rendered one, and the document is a hidden input.
Give the document in the value of render_submit/2 or render_change/2,
as JSON, the way the hook sends it:
doc = JSON.encode!(Kotoba.Content.from_markdown("Hello **world**").doc)
view
|> form("#post-form", post: %{title: "Hello"})
|> render_submit(%{post: %{body: doc}})The editor's own events (kotoba:prompt, kotoba:change) go through
render_hook/3 on the editor element, which keeps its phx-target:
view
|> element("#post-body")
|> render_hook("kotoba:prompt", %{"id" => "post-body", "prompt" => "people", "query" => "ad"})
assert_push_event(view, "kotoba:prompt_results", %{items: [%{label: "Ada Lovelace"}]})Stored content without the editor
Kotoba.Content.cast/1 also takes a map (the document envelope, or a bare
Lexical root node) and a Kotoba.Content. Kotoba.Content.from_markdown/2
makes content from Markdown, on a best-effort basis, for seeds, tests and
imports. The Rendering guide shows how to show content,
and how to render it as HTML, text or Markdown elsewhere.