PhiaUi.Editor.CollabChannel behaviour (phia_ui v0.1.17)

Copy Markdown View Source

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.CollabChannel

In your app.js (with Yjs):

npm install yjs @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor

Persistence

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_comment(doc_id, comment_id)

(optional)
@callback delete_comment(doc_id :: String.t(), comment_id :: String.t()) ::
  :ok | {:error, term()}

Delete a comment by ID.

delete_thread(doc_id, thread_id)

(optional)
@callback delete_thread(doc_id :: String.t(), thread_id :: String.t()) ::
  :ok | {:error, term()}

Delete a comment thread by ID.

load_document(doc_id)

@callback load_document(doc_id :: String.t()) :: {:ok, binary() | nil} | {:error, term()}

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_threads(doc_id)

(optional)
@callback load_threads(doc_id :: String.t()) :: {:ok, list()} | {:error, term()}

Load all comment threads for a document.

load_versions(doc_id)

(optional)
@callback load_versions(doc_id :: String.t()) :: {:ok, list()} | {:error, term()}

Load all version snapshots for a document.

save_comment(doc_id, comment)

(optional)
@callback save_comment(doc_id :: String.t(), comment :: map()) ::
  {:ok, map()} | {:error, term()}

Persist a new or updated comment.

save_document(doc_id, state)

@callback save_document(doc_id :: String.t(), state :: binary()) :: :ok | {:error, term()}

Persist Y.Doc state for a document.

Called after each yjs:update message. The state is the full Yjs-encoded document state (binary).

save_thread(doc_id, thread)

(optional)
@callback save_thread(doc_id :: String.t(), thread :: map()) ::
  {:ok, map()} | {:error, term()}

Persist a new or updated comment thread.

save_version(doc_id, version)

(optional)
@callback save_version(doc_id :: String.t(), version :: map()) ::
  {:ok, map()} | {:error, term()}

Persist a version snapshot.