Leaf.Collab (Leaf v0.6.1)

Copy Markdown View Source

Live editing, wired into a LiveView in one call.

Everything a shared document needs — placing edits that crossed on the wire, agreeing which version everyone is on, putting a session back in step after its socket drops, showing where other people's carets are — is here rather than in the host. It is not obvious code and it is not code worth writing twice; every part of it exists because something went visibly wrong without it.

Using it

def mount(%{"id" => id}, _session, socket) do
  {:ok,
   Leaf.Collab.join(socket,
     room: MyApp.Notes.room_name(id),
     editor_id: "note-editor",
     identity: %{name: socket.assigns.current_user.name}
   )}
end

and in the template:

<.leaf_editor
  id="note-editor"
  content={@leaf_collab.content}
  collaboration={@leaf_collab.collaboration}
/>

That is the whole integration. join/2 attaches a handle_info hook, so the host writes no message handling of its own. A LiveView that switches documents without remounting calls leave/1 before joining the next room — see leave/1.

What the hook consumes

The hook handles — and stops — {:leaf_operation, …}, {:leaf_awareness, …}, {:leaf_ready, …}, {:leaf_resync, …}, {:leaf_debug_state, …}, {:leaf_changed, …}, and the room's {:leaf_conflict, …} / {:leaf_conflict_cleared, …}. A collaborating LiveView therefore does not receive {:leaf_changed, …}: read the document from @leaf_collab.content, which is kept current on every edit, local or remote. Anything the hook does not recognise passes through to the host's own handle_info clauses untouched.

One collaborative editor per LiveView: join/2 owns the @leaf_collab assign and the hook name.

What the host still owns

Starting a room per document and supervising it — Leaf has no opinion about how many nodes you run or how you name processes. And saying where documents live, which is Leaf.Collab.Store.

Turning it off

Not calling join/2 costs nothing. The editor does no collaboration work unless it is asked to: no coordinates measured, no fingerprints taken, no selection listener attached. Somebody using Leaf for a comment box pays for none of this.

Summary

Functions

Join the document this room holds.

Leave the document, undoing what join/2 did.

Everyone in the document, for a host that wants to list them.

Put the document back to its starting text, for everyone.

Functions

join(socket, opts)

Join the document this room holds.

Options:

  • :room — the room process, started and supervised by the host
  • :editor_id — the id given to leaf_editor
  • :identity%{name:, color:}, both optional. A host with signed-in users passes theirs so everyone sees a name rather than an identifier.
  • :awareness — show other people's carets and selections. Defaults true.
  • :debug — diagnostics. Defaults false; see Leaf.Collab.Log.

leave(socket)

Leave the document, undoing what join/2 did.

For a LiveView that outlives its stay in a room — one that switches documents without remounting, the way an editor with a sidebar keeps the sidebar alive. Closing the tab needs no call: the room monitors the process. But a live process that merely moves on is invisible to the monitor, so without this the person lingers as a ghost caret in the document they left, and the next join/2 raises on the hook it cannot attach twice.

Removes this session from the room so its caret disappears for everyone else, unsubscribes, detaches the hook, and clears @leaf_collab — after which join/2 works again with whatever room comes next:

def handle_params(%{"id" => id}, _uri, socket) do
  {:noreply,
   socket
   |> Leaf.Collab.leave()
   |> Leaf.Collab.join(room: room_for(id), editor_id: "note-editor")}
end

Idempotent: leaving without having joined is a no-op, so the pipeline above needs no first-mount special case.

people(socket)

Everyone in the document, for a host that wants to list them.

reset(socket)

Put the document back to its starting text, for everyone.

Handles the part a host would forget: telling the other sessions. A reset that only changed the room left every other tab holding the old text, and the next reconciliation adopted it straight back — the reset undone by the people it was for.