Phoenix Channel for Yjs collaborative editing via TipTap.
Implements the Yjs sync protocol over Phoenix Channels, replacing the need
for a separate Hocuspocus (Node.js) server. Each document gets its own
channel topic: "editor:collab:<doc_id>".
Protocol Messages
Client → Server
"yjs:sync-step-1"— client sends its state vector, server responds with diff"yjs:sync-step-2"— client sends its diff to server"yjs:update"— client sends incremental update, server broadcasts + persists"awareness:update"— client sends cursor/selection awareness, server broadcasts
Collaboration Messages (Client → Server)
"collab:thread:create"— create a new comment thread anchored to a selection"collab:thread:resolve"— mark a thread as resolved"collab:thread:reopen"— reopen a resolved thread"collab:comment:create"— add a comment to a thread"collab:comment:edit"— edit an existing comment"collab:comment:delete"— delete a comment"collab:comment:react"— toggle a reaction on a comment"collab:cursor:update"— broadcast cursor position to peers"collab:typing:start"— broadcast typing indicator on"collab:typing:stop"— broadcast typing indicator off"collab:version:snapshot"— trigger a version snapshot
Server → Client
"yjs:sync-step-1"— server sends its state vector (on join)"yjs:sync-step-2"— server sends diff in response to client sync-step-1"yjs:update"— server broadcasts updates from other clients"awareness:update"— server broadcasts awareness from other clients"collab:thread:created"/"collab:thread:resolved"/"collab:thread:reopened""collab:comment:created"/"collab:comment:edited"/"collab:comment:deleted"/"collab:comment:reacted""collab:cursor:updated"— peer cursor positions"collab:typing:updated"— peer typing state"collab:version:saved"— version snapshot confirmation
Setup
In your endpoint/socket:
channel "editor:collab:*", PhiaUi.Editor.CollabChannelIn your app.js (with Yjs):
npm install yjs @tiptap/extension-collaboration @tiptap/extension-collaboration-cursorPersistence
Override load_document/1 and save_document/2 callbacks in your own
channel module to persist Y.Doc state to your database:
defmodule MyApp.EditorChannel do
use PhiaUi.Editor.CollabChannel
@impl true
def load_document(doc_id) do
case MyApp.Repo.get(MyApp.Document, doc_id) do
nil -> {:ok, nil}
doc -> {:ok, doc.yjs_state}
end
end
@impl true
def save_document(doc_id, state) do
MyApp.Repo.insert_or_update!(...)
:ok
end
@impl true
def load_threads(doc_id) do
threads = MyApp.Repo.all(from t in MyApp.Thread, where: t.doc_id == ^doc_id)
{:ok, threads}
end
end
Summary
Callbacks
Delete a comment by ID.
Delete a comment thread by ID.
Load persisted Y.Doc state for a document.
Load all comment threads for a document.
Load all version snapshots for a document.
Persist a new or updated comment.
Persist Y.Doc state for a document.
Persist a new or updated comment thread.
Persist a version snapshot.
Callbacks
Delete a comment by ID.
Delete a comment thread by ID.
Load persisted Y.Doc state for a document.
Returns {:ok, binary | nil} where binary is the Yjs-encoded document state.
Return {:ok, nil} for new documents.
Load all comment threads for a document.
Load all version snapshots for a document.
Persist a new or updated comment.
Persist Y.Doc state for a document.
Called after each yjs:update message. The state is the full Yjs-encoded
document state (binary).
Persist a new or updated comment thread.
Persist a version snapshot.