howdy/ui/rich_text

A rich text editor: bold, italic, links, headings, quotes, lists and code, with mentions of people or anything else, and files sent straight to storage, posted as a document your server can trust.

html.form([attribute.method("post"), attribute.action("/posts")], [
  rich_text.new(name: "body")
  |> rich_text.value(post.body)
  |> rich_text.placeholder("Write something…")
  |> rich_text.mentions(trigger: "@", kind: "person", search: "/people/search")
  |> rich_text.attachments(start: "/posts/uploads", url: fn(file) {
    Some(files.url(file.key))
  })
  |> rich_text.required
  |> rich_text.view,
  ui.submit_button(Primary, [], [text("Post")]),
])

The form posts the document as JSON under name. Read it with field in a form validator, or document.decode, which refuses anything the editor could not have made: decoding is the safety check, and document.view draws the result through Lustre, so its text is always escaped. Store it with document.to_string.

Typing a mention’s trigger, such as @, asks your search endpoint for suggestions as the person types: GET /people/search?q=ad answers with JSON made by suggestions_to_json:

[{"id": "42", "label": "Ada Lovelace", "detail": "ada@example.com", "image": "/avatars/42.png"}]

The ids come back from the browser, so check each mention with document.mentions before acting on it, such as sending a notification. Show mentions from your records as they are now with document.view_with.

Attachments use the same endpoint as howdy/ui/uploader: the browser posts each file’s name, type and size to start, which answers with a key and where to send the file. The document keeps the key. Check each one with document.attachments before you keep it.

The editor runs on ProseMirror, loaded the first time an editor is on a page from the ui.rich_text() controller, which every app that uses it must mount; see howdy/ui/rich_text/script. It looks like the rest of the theme: the toolbar, menus and text use the same tokens and prose styles. Markdown-like shortcuts work as you type: # for a heading, > for a quote, - or 1. for a list, three backticks for code, and **bold**, _italic_, ~~strike~~ and `code`.

In a live view, live.on_value(name, message) hears the document each time the editor loses focus. Setting a different value from the server, such as clearing it after sending, replaces what is in the editor.

Types

An editor under construction.

pub opaque type Editor

Something a mention search found. label is what the mention shows after its trigger; detail, such as an email address, and image, such as an avatar, appear in the list only.

pub type Suggestion {
  Suggestion(
    id: String,
    label: String,
    detail: option.Option(String),
    image: option.Option(String),
  )
}

Constructors

A button on the toolbar.

pub type Tool {
  Bold
  Italic
  Strike
  Code
  Link
  Heading(level: Int)
  Quote
  BulletList
  NumberedList
  CodeBlock
  Divider
  Attach
  Undo
  Redo
}

Constructors

  • Bold
  • Italic
  • Strike
  • Code

    Code within a line.

  • Link
  • Heading(level: Int)

    A heading of this level, from 1 to 6. The button turns a paragraph into the heading, and back.

  • Quote
  • BulletList
  • NumberedList
  • CodeBlock
  • Divider

    A horizontal rule.

  • Attach

    Choose files to attach. Shown only with attachments.

  • Undo
  • Redo

Values

pub fn accept(editor: Editor, types: List(String)) -> Editor

Only attach files of these types: content types such as "image/png", families such as "image/*", or extensions such as ".pdf". Use with attachments.

pub fn attached_class() -> css.Class

A file in the editor: a picture, or a card while it uploads and for other files.

pub fn attachments(
  editor: Editor,
  start start: String,
  url url: fn(document.Attachment) -> option.Option(String),
) -> Editor

Let people attach files, by the toolbar, pasting or dropping them. The browser asks the endpoint at start where to send each one, as howdy/ui/uploader does. url gives the address to show an attached file by, such as a signed storage URL, or None to show only its name.

pub fn classes() -> List(css.Class)

Every class this module uses, for howdy/ui/export.

pub fn content_class() -> css.Class

The text, set as prose, whether drawn by the server or the editor.

pub fn default_tools() -> List(List(Tool))

The toolbar an editor has unless given tools.

pub fn disabled(editor: Editor) -> Editor

Show the document without letting it be edited. Nothing is posted.

pub fn editor_class() -> css.Class

The frame, drawn like a text area.

pub fn field(
  form: form.Form,
  name: String,
  rules: List(
    fn(document.Document) -> Result(
      document.Document,
      problem.Problem,
    ),
  ),
  next: fn(document.Document) -> Result(
    b,
    List(service.FieldError),
  ),
) -> Result(b, List(service.FieldError))

A required rich text field of a form, checked with rules. A value that is missing or is not a document the editor could have made is an error, so rules only see real documents.

use body <- rich_text.field(fields, "body", [rich_text.not_empty()])
pub fn heading_icon_class() -> css.Class
pub fn id(editor: Editor, id: String) -> Editor

An id for a <label for>, such as a field’s. Clicking the label focuses the editor, and the label names it. Without one, the id is made from the name.

pub fn invalid(editor: Editor) -> Editor

Mark the editor as holding a value that was refused, such as when its field has an error.

pub fn link_form_class() -> css.Class
pub fn link_input_class() -> css.Class
pub fn max_length(
  length: Int,
) -> fn(document.Document) -> Result(
  document.Document,
  problem.Problem,
)

Refuse a document whose plain text is longer than this many characters.

pub fn max_size(editor: Editor, bytes: Int) -> Editor

Refuse attachments over this many bytes before they are sent. Your start endpoint must check too. Use with attachments.

pub fn mentions(
  editor: Editor,
  trigger trigger: String,
  kind kind: String,
  search search: String,
) -> Editor

Typing trigger, such as "@" or "#", suggests things of kind to mention, from the endpoint at search. It is sent what follows the trigger as ?q= and answers with suggestions_to_json. Call this once for each trigger.

pub fn menu_class() -> css.Class

The list of suggestions, placed under the text being typed.

pub fn new(name name: String) -> Editor

An empty editor posting its document as name.

pub fn not_empty() -> fn(document.Document) -> Result(
  document.Document,
  problem.Problem,
)

Refuse a document with no text, mentions, attachments or dividers.

pub fn option_avatar_class() -> css.Class
pub fn option_detail_class() -> css.Class
pub fn option_label_class() -> css.Class
pub fn placeholder(editor: Editor, text: String) -> Editor

Shown, faintly, while the editor is empty.

pub fn required(editor: Editor) -> Editor

Do not let the form submit while the editor is empty.

pub fn separator_class() -> css.Class
pub fn suggestion(
  id id: String,
  label label: String,
) -> Suggestion

A suggestion with an id and a label alone.

pub fn suggestions_to_json(
  suggestions: List(Suggestion),
) -> json.Json

The answer to a mention search, as the editor expects it. Send at most ten or so, the best first.

pub fn tool_class() -> css.Class
pub fn toolbar_class() -> css.Class
pub fn tools(editor: Editor, groups: List(List(Tool))) -> Editor

The toolbar’s buttons, in groups with a line between each. The default is default_tools(), with Attach when there are attachments. An empty list hides the toolbar; the keyboard shortcuts still work.

pub fn unavailable_class() -> css.Class
pub fn value(editor: Editor, value: document.Document) -> Editor

The document to start with.

pub fn view(editor: Editor) -> element.Element(msg)

The editor, with its toolbar.

pub fn words(editor: Editor, words: words.Words) -> Editor

Say things in other words, or another language: the editor uses the words’ rich_text.

✨ Search Document