An app can add its own nodes to the document, for example a callout, a status chip or a link to a record. A node has two halves:
- a
Kotoba.Nodemodule in Elixir. It reads the node from the JSON, checks it, and renders it as HTML, text and Markdown. - a JavaScript module with the Lexical node class, for the editor.
To give a node commands, a toolbar button or a Markdown shortcut, make its JavaScript half an extension: see the Extensions guide.
Generate a node
mix kotoba.gen.node Callout # a decorator (the default)
mix kotoba.gen.node StatusChip --kind inline
mix kotoba.gen.node Panel --kind block
For an app :my_app, mix kotoba.gen.node Callout writes:
lib/my_app/kotoba/nodes/callout.ex:MyApp.Kotoba.Nodes.Callout, of type"my-app-callout", with alabelfield.assets/js/kotoba/nodes/callout.js: the Lexical node.test/my_app/kotoba/nodes/callout_test.exs: a test that parses, renders and reads the text of a document with the node.
The type starts with the app name, so it cannot be the type of a built-in
node. --module gives another module name, --out another directory,
--app another app name for the type, and --force replaces files that
exist.
See Mix.Tasks.Kotoba.Gen.Node.
The kinds:
decorator: a node with no editable text in it, for example a card. It goes in the root of the document.inline: a node in a line of text, for example a chip. It goes in a paragraph.block: a block in the root of the document.
The Elixir half
defmodule MyApp.Kotoba.Nodes.Callout do
use Kotoba.Node, type: "my-app-callout", kind: :decorator
alias Kotoba.Renderer
field :label, :string, required: true
field :tone_name, :string, key: "toneName", default: "info", in: ~w(info warning)
@impl Kotoba.Node
def render_html(node, _opts) do
Renderer.tag("div", [class: "my-app-callout", "data-tone": node.tone_name], node.label)
end
@impl Kotoba.Node
def render_text(node, _opts), do: node.label
@impl Kotoba.Node
def render_markdown(node, _opts), do: Renderer.escape_markdown(node.label)
endfield/3 declares one attribute of the JSON. The types are :string,
:integer, :boolean, :map and {:array, type}.
A field with more than one word needs a camelCase JSON key, as Lexical
writes it: field :tone_name, :string, key: "toneName". The JavaScript
half uses the same key in exportJSON() and importJSON(). Without
key:, the JSON key is the field name, "tone_name".
render_html/2 returns safe HTML: a ~H template, Kotoba.Renderer.tag/3,
or {:safe, iodata}. A plain string is escaped. Use
Kotoba.Renderer.escape_markdown/1 for the Markdown of text, so that the
text cannot make Markdown syntax, a heading or a list included.
Kotoba.Renderer.tag/3 escapes the attribute values and the content, but
not the tag name or the attribute names, and it does not check URLs. So:
- keep the tag and attribute names literal in your code, and never take them from the node's fields;
- pass each
hreforsrcthroughKotoba.Sanitizer.link_url/2and render no link when it returnsnil. For a link, also check the policy: render the link only whenKotoba.Renderer.policy(opts).linksis true, so that the:untrustedpolicy gives text.
To add .formatter.exs support for field without parentheses, add
:kotoba to import_deps.
The JavaScript half
// assets/js/kotoba/nodes/callout.js
export default (lexical) =>
class CalloutNode extends lexical.DecoratorNode {
static getType() { return "my-app-callout" }
static clone(node) { return new CalloutNode(node.__label, node.__key) }
static importJSON(json) { return new CalloutNode(String(json.label ?? "")) }
constructor(label = "", key) { super(key); this.__label = label }
exportJSON() { return {type: "my-app-callout", version: 1, label: this.getLatest().__label} }
createDOM() { /* the element that holds the node */ }
updateDOM() { return false }
decorate() { /* returns an HTMLElement, which Kotoba puts in the editor */ }
}The module exports a factory as its default export. The editor calls the
factory with its own copy of Lexical, so the class extends the same
DecoratorNode as the built-in nodes. A class that extends another copy
of Lexical (a module that bundles lexical itself) is refused, with an
error in the browser console. decorate() returns an HTMLElement.
The editor refuses a node whose type is a built-in type, or a module that does not load. It logs the error and mounts with the other nodes.
Build and serve the module
The editor loads the module with import(), so serve it as an ES module.
Add an esbuild profile in config/config.exs:
config :esbuild,
kotoba_nodes: [
args:
~w(js/kotoba/nodes/*.js --bundle --format=esm --target=es2022 --outdir=../priv/static/assets/kotoba/nodes),
cd: Path.expand("../assets", __DIR__)
]a watcher in the endpoint config of config/dev.exs:
watchers: [
kotoba_nodes: {Esbuild, :install_and_run, [:kotoba_nodes, ~w(--sourcemap=inline --watch)]},
# ...
]and the profile in the aliases of mix.exs:
"assets.build": [..., "esbuild kotoba_nodes"],
"assets.deploy": [..., "esbuild kotoba_nodes --minify", "phx.digest"]Do not import lexical in the module: the factory gets it.
Register the node
# config/config.exs
config :kotoba, nodes: [MyApp.Kotoba.Nodes.Callout]<.kotoba
field={@form[:body]}
nodes={[{MyApp.Kotoba.Nodes.Callout, ~p"/assets/kotoba/nodes/callout.js"}]}
/>The two places do different things:
config :kotoba, nodes:is the node registry of the server.Kotoba.Content.cast/1uses it when it parses and renders the cache, andKotoba.Document.parse/2uses it by default.- The
nodesattribute ofKotoba.Components.kotoba/1gives the editor the URL of the JavaScript half.
Put every node that stored content uses in the config. A node that is
only in the component, and not in the config, is an unknown node when the
content is cast. The document keeps it with all its attributes, but the
cached HTML has only an empty span.kotoba-unknown for it. To render it,
give the node when you render:
Kotoba.Content.rerender(content, nodes: [MyApp.Kotoba.Nodes.Callout])<.kotoba_content content={@post.body} nodes={[MyApp.Kotoba.Nodes.Callout]} />An app node cannot have a reserved type (a built-in type,
"kotoba-unknown" or "kotoba-upload", see
Kotoba.Nodes.reserved_types/0): the registry
raises ArgumentError, as the editor refuses it. Do not start a type with
kotoba-; the generator refuses an app name that would do this.
Insert a node
A node module gives the editor no toolbar button: an
extension can add one, with a command that inserts the
node, or a prompt can insert it (insert: {:node, type}, see the
Prompts guide). The server can insert one too, for example
from a button of your own:
def handle_event("add_callout", _params, socket) do
node = %MyApp.Kotoba.Nodes.Callout{label: "Read this first"}
{:noreply, Kotoba.Live.insert_node(socket, "post-body", node)}
endKotoba.Live.insert_node/4 checks the node with its validate/1 and
sends its JSON. The editor puts it at the selection.
After a change of a node module
The cache of stored content does not change when you add, remove or
change a node module. Use Kotoba.Content.rerender/2 to bring stored rows
up to date, for example in a migration task. See the
Limits guide.