Current section
Files
Jump to
Current section
Files
README.md
# Tiptapex
**[Tiptap](https://tiptap.dev) v3 rich-text editing for Phoenix LiveView (Phoenix ≥ 1.8, LiveView ≥ 1.1).**
Tiptapex packages a production-grade editor integration as a single Hex package:
- **`<.tiptapex_editor>` / `<.tiptapex_viewer>`** — HEEx function components backed by JS hooks shipped inside this package.
- **A full-featured editor** — toolbar (marks, headings, alignment, lists, fonts, colors), resizable images and videos (file + YouTube embeds), tables with a floating context menu, task lists, drag-handle block reordering, placeholder, character count, invisible characters.
- **`Tiptapex.Renderer`** — converts the Tiptap/ProseMirror JSON document to **safe, escaped HTML on the server**. No `raw/1`, no trusting client-generated HTML, no stored-XSS surface.
- **Uploads** — a `Tiptapex.Upload` behaviour + controller macro with real server-side validation (size limits and magic-byte content-type sniffing).
- **Realtime collaboration (optional)** — Yjs over Phoenix Channels via `use Tiptapex.Collab.Channel` and a bundled provider; multiple users edit the same doc with live carets.
The canonical storage format is the **JSON document** (an Ecto `:map` / Postgres `jsonb` column). HTML is derived from it on the server whenever you need to display it.
## Installation
### 1. Hex package
```elixir
# mix.exs
def deps do
[
{:tiptapex, "~> 0.1"}
]
end
```
### 2. npm peer dependencies
The JS ships as raw ESM inside the Hex package; your app bundles it with its own copy of Tiptap (this guarantees a single `@tiptap/core` instance):
```bash
cd assets
npm install \
@tiptap/core @tiptap/pm @tiptap/starter-kit \
@tiptap/extension-bubble-menu @tiptap/extension-character-count \
@tiptap/extension-color @tiptap/extension-drag-handle \
@tiptap/extension-dropcursor @tiptap/extension-file-handler \
@tiptap/extension-floating-menu @tiptap/extension-focus \
@tiptap/extension-font-family @tiptap/extension-gapcursor \
@tiptap/extension-highlight @tiptap/extension-image \
@tiptap/extension-invisible-characters @tiptap/extension-link \
@tiptap/extension-list @tiptap/extension-list-keymap \
@tiptap/extension-placeholder @tiptap/extension-table \
@tiptap/extension-table-of-contents @tiptap/extension-text-align \
@tiptap/extension-text-style @tiptap/extension-typography \
@tiptap/extension-unique-id
```
For collaboration, also:
```bash
npm install @tiptap/extension-collaboration @tiptap/extension-collaboration-caret \
yjs y-prosemirror y-protocols lib0
```
### 3. esbuild `NODE_PATH`
Tell esbuild to resolve `"tiptapex"` from `deps/` and the Tiptap packages from `assets/node_modules` (imports inside a dep don't reach your `node_modules` on their own):
```elixir
# config/config.exs
config :esbuild,
my_app: [
args: ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js),
cd: Path.expand("../assets", __DIR__),
env: %{
"NODE_PATH" =>
Enum.join(
[
Path.expand("../deps", __DIR__),
Path.expand("../assets/node_modules", __DIR__),
Mix.Project.build_path()
],
":"
)
}
]
```
### 4. Register the hooks
```js
// assets/js/app.js
import { hooks as tiptapex } from "tiptapex"
const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: { ...colocatedHooks, ...tiptapex },
})
```
### 5. Stylesheet
```css
/* assets/css/app.css (Tailwind v4 CSS-first or plain CSS) */
@import "../../deps/tiptapex/priv/static/tiptapex.css";
```
The stylesheet is theme-aware: every color flows through `--ttx-*` custom properties that fall back to daisyUI tokens when present (and to neutral defaults otherwise). Override them to re-skin:
```css
:root { --ttx-primary: #7c3aed; }
```
## Usage
### The editor
```heex
<.tiptapex_editor
id="article-body"
value={@body}
placeholder="Start writing…"
upload_url={~p"/admin/tiptapex/uploads"}
upload_scope={@article.id}
on_change="editor_update"
/>
```
```elixir
def handle_event("editor_update", %{"json" => json, "characters" => chars}, socket) do
{:noreply, socket |> assign(:body, json) |> assign(:characters, chars) |> assign(:dirty?, true)}
end
def handle_event("save", _params, socket) do
Articles.update_article(socket.assigns.article, %{body: socket.assigns.body})
# ...
end
```
Store `body` as `:map` (Postgres `jsonb`) — or use the bundled Ecto type:
```elixir
schema "articles" do
field :body, Tiptapex.Schema.Document
end
```
#### Form-based sync (no custom events)
For plain changeset forms, bind the editor to a hidden input instead:
```heex
<.form for={@form} phx-change="validate" phx-submit="save">
<.tiptapex_editor id="body" sync={{:hidden_input, @form[:body]}} />
<button>Save</button>
</.form>
```
The hook keeps the input in sync with the document JSON and triggers your form's `phx-change`. `Tiptapex.Schema.Document` casts the JSON string automatically.
#### Options worth knowing
| Attr | What it does |
| --- | --- |
| `toolbar={[:marks, :blocks, :lists, :history]}` | ordered subset of groups; `false` hides the toolbar |
| `extensions={%{table: false, character_count_limit: 10_000}}` | per-feature switches for the JS extension matrix |
| `labels={%{bold: "Negrita", insert_link: "Insertar enlace"}}` | i18n for toolbar/table menu (English defaults) |
| `remount_key={@editor_session}` | bump to force a client remount (e.g. after restoring a version) |
| `count_template="{chars} caracteres · {words} palabras"` | footer counter format |
| `<:actions>` slot | render buttons in the editor footer |
Push content into a mounted editor from the server (multiple editors are namespaced by id):
```elixir
{:noreply, Tiptapex.Components.set_content(socket, "article-body", restored.body)}
```
### Displaying documents — the safe way
```heex
<%!-- server-rendered, escaped, allow-listed; no raw/1 anywhere --%>
<.tiptapex_viewer value={@article.body} hydrate={false} />
<%!-- or with client-side hydration (task-list checkboxes etc.) --%>
<.tiptapex_viewer id="article" value={@article.body} />
```
Or call the renderer directly:
```elixir
Tiptapex.Renderer.to_html(doc) # {:safe, iodata} — interpolate in HEEx
Tiptapex.Renderer.to_plain_text(doc) # excerpts / search indexing
Tiptapex.Renderer.headings(doc) # [%{level: 1, id: "…", text: "…"}] for a TOC
```
The renderer covers every node/mark the editor produces and enforces: URL scheme allow-lists (`javascript:`/`data:` stripped), iframes only for canonically rebuilt YouTube embed URLs, a strict CSS value grammar for inline styles, and per-node attribute allow-lists. Unknown nodes are dropped (configurable via `on_unknown:`). Custom nodes plug in via `nodes:`/`marks:` options — see `Tiptapex.Renderer`.
> #### Live preview next to the editor? Pass `ids: false` {: .warning}
>
> When you render the same document on the same page as its live editor
> (e.g. an edit + preview split view), use
> `Tiptapex.Renderer.to_html(@doc, ids: false)`. The editor's DOM carries
> the same heading ids, and LiveView's DOM patcher matches elements by id
> globally — with duplicate ids it will pull nodes out of the editor (even
> inside `phx-update="ignore"`), which ProseMirror then interprets as the
> user deleting them.
### Uploads
Implement the behaviour with your storage:
```elixir
defmodule MyApp.EditorUploads do
@behaviour Tiptapex.Upload
@impl true
def store(%Plug.Upload{} = upload, %{scope: article_id}) do
# S3, local disk, your Attachments context…
{:ok, %{url: url, content_type: type, filename: name}}
end
end
```
Mount the controller in an authenticated pipeline:
```elixir
defmodule MyAppWeb.EditorUploadController do
use MyAppWeb, :controller
use Tiptapex.Upload.Controller, handler: MyApp.EditorUploads
end
# router.ex
post "/admin/tiptapex/uploads", EditorUploadController, :create
```
Size limits and **magic-byte content-type verification** run before your handler is called. `Tiptapex.Upload.LocalDisk` is included for demos/simple apps.
### Realtime collaboration
Server — mount a channel:
```elixir
# user_socket.ex
channel "doc:*", MyAppWeb.DocChannel
defmodule MyAppWeb.DocChannel do
use Tiptapex.Collab.Channel
@impl true
def authorize("doc:" <> slug, _params, socket) do
case MyApp.Docs.get_by_slug(slug) do
nil -> {:error, %{reason: "not_found"}}
_doc -> {:ok, socket}
end
end
end
```
Client — build the hook with the collab plugin (separate import so yjs stays out of non-collab bundles):
```js
import { makeEditorHook, TiptapexViewerHook } from "tiptapex"
import { CollabPlugin } from "tiptapex/collaboration"
const hooks = {
TiptapexEditor: makeEditorHook({ collab: CollabPlugin }),
TiptapexViewer: TiptapexViewerHook,
}
```
Component:
```heex
<.tiptapex_editor
id="body"
value={@doc}
collab={%{
topic: "doc:" <> @slug,
socket_path: "/socket",
user: Tiptapex.Collab.user(%{id: @current_user.id, name: @current_user.name})
}}
/>
```
The server relays binary Yjs sync/awareness messages between peers; your save flow remains the source of truth. Optional `load_state/2` / `persist_update/3` callbacks support snapshotting — see `Tiptapex.Collab.Channel`.
### Custom Tiptap extensions
```js
import { makeEditorHook } from "tiptapex"
import { MyCallout } from "./extensions/callout"
const TiptapexEditor = makeEditorHook({
extend: (extensions) => [...extensions, MyCallout],
toolbar: { custom: [{ icon: () => myIcon(), title: "Callout", run: (e) => e.commands.toggleCallout() }] },
})
```
Teach the server renderer about the node:
```elixir
Tiptapex.Renderer.to_html(doc, nodes: %{"callout" => MyApp.CalloutRenderer})
```
## Upload wire contract
The hook POSTs `multipart/form-data` with a `file` part (plus `scope` when configured) and the `x-csrf-token` header. The endpoint must reply:
```json
{ "url": "/uploads/abc.png", "content_type": "image/png", "filename": "abc.png" }
```
`image/*` inserts an image, `video/*` a video node, anything else a download link. `Tiptapex.Upload.Controller` implements this contract for you.
## Working on tiptapex itself
```bash
mix deps.get
mix test
# demo app
cd dev/assets && npm install && cd ../..
mix dev.assets # bundle the demo JS
iex -S mix dev # http://localhost:4400 (two tabs → collaboration)
```
## License
MIT — see [LICENSE](LICENSE).