LiveFlow includes built-in support for real-time multi-user collaboration. Multiple users can simultaneously edit the same flow diagram, see each other's cursors, and track who is online.
Prerequisites
- Phoenix PubSub -- required for broadcasting changes between users
- Phoenix Presence -- optional, for tracking online users
Both are included with Phoenix by default. Your application's PubSub module
(e.g., MyApp.PubSub) is already configured in your supervision tree.
If you want Presence tracking, create a Presence module:
defmodule MyAppWeb.Presence do
use Phoenix.Presence,
otp_app: :my_app,
pubsub_server: MyApp.PubSub
endAdd it to your application supervision tree in lib/my_app/application.ex:
children = [
# ...
MyAppWeb.Presence,
# ...
]Setting Up Collaboration
1. Join a Session in mount/3
Use LiveFlow.Collaboration.join/4 to subscribe the user to a shared topic:
defmodule MyAppWeb.CollabFlowLive do
use MyAppWeb, :live_view
alias LiveFlow.{State, Node, Edge, Handle}
alias LiveFlow.Collaboration
alias LiveFlow.Collaboration.User
@impl true
def mount(%{"room" => room_id}, _session, socket) do
# Create or identify the user
user_id = socket.assigns.current_user.id # or generate one
user = User.new(user_id, name: "Alice")
# Load the shared flow state
flow = load_flow(room_id)
socket =
socket
|> assign(flow: flow, room_id: room_id)
|> Collaboration.join("flow:#{room_id}", user,
pubsub: MyApp.PubSub,
presence: MyAppWeb.Presence # optional
)
{:ok, socket}
end
endWhat join/4 Does
Calling Collaboration.join/4 performs the following:
- Subscribes to the collaboration PubSub topic
- Subscribes to the cursor PubSub topic
- If Presence is configured, tracks the user and subscribes to presence diffs
- Sets these assigns on the socket:
:lf_user-- theUserstruct:lf_topic-- the base collaboration topic:lf_pubsub-- the PubSub module:lf_presences-- map of tracked presences:lf_presence_mod-- the Presence module (ornil)
2. Handle Incoming Messages
Delegate PubSub messages to Collaboration.handle_info/2:
@impl true
def handle_info(msg, socket) do
case Collaboration.handle_info(msg, socket) do
{:ok, socket} -> {:noreply, socket}
:ignore -> {:noreply, socket}
end
endCollaboration.handle_info/2 handles these messages and returns :ignore for
anything else:
| Message | Description |
|---|---|
{:lf_flow_change, sender_id, change} | Another user modified the flow: applied with apply_remote_change/2 and assigned to :flow |
{:lf_drag_move, sender_id, changes} | Another user is dragging nodes: pushed to the browser as lf:remote_drag |
{:lf_cursor_move, sender_id, cursor_data} | Another user moved their cursor: pushed as lf:remote_cursor |
%Phoenix.Socket.Broadcast{event: "presence_diff"} | A user joined or left: updates :lf_presences |
Changes are broadcast with Phoenix.PubSub.broadcast_from/4, so a user never
receives their own changes.
3. Broadcast Changes
When the local user modifies the flow, broadcast the change so other users see it:
alias LiveFlow.Changes.NodeChange
@impl true
def handle_event("lf:node_change", %{"changes" => changes}, socket) do
flow = NodeChange.apply_changes(socket.assigns.flow, changes)
# Every browser measures its own nodes: share moves, resizes and removals only
shared = Enum.filter(changes, &(&1["type"] in ~w(position resize remove)))
socket =
if shared == [],
do: socket,
else: Collaboration.broadcast_change(socket, {:node_changes, shared})
{:noreply, assign(socket, flow: flow)}
end
@impl true
def handle_event("lf:connect_end", params, socket) do
case LiveFlow.Validation.Connection.validate_and_create(socket.assigns.flow, params) do
{:ok, edge} ->
flow = State.add_edge(socket.assigns.flow, edge)
socket = Collaboration.broadcast_change(socket, {:edge_add, edge})
{:noreply, assign(socket, flow: flow)}
{:error, _reason} ->
{:noreply, socket}
end
end
@impl true
def handle_event("lf:delete_selected", _params, socket) do
flow = socket.assigns.flow
node_ids = MapSet.to_list(flow.selected_nodes)
edge_ids = MapSet.to_list(flow.selected_edges)
updated_flow = State.delete_selected(flow)
socket = Collaboration.broadcast_change(socket, {:delete_selected, node_ids, edge_ids})
{:noreply, assign(socket, flow: updated_flow)}
endSupported Change Types
LiveFlow.Collaboration.apply_remote_change/2 understands these changes:
| Change Tuple | Description |
|---|---|
{:node_changes, changes} | lf:node_change change maps (position, dimensions, resize, add, replace, remove). Selection changes are ignored: every user keeps their own selection |
{:node_add, %Node{}} | A node was added |
{:node_update, id, attrs} | A node was updated (attrs as in LiveFlow.State.update_node/3) |
{:node_remove, id} | A node (and its children) was removed |
{:edge_add, %Edge{}} | A new edge was created |
{:edge_update, id, attrs} | An edge was updated, e.g. [label: "next"] |
{:edge_remove, edge_id} | An edge was removed |
{:edge_reconnect, params} | An edge was reconnected (the lf:reconnect_edge params) |
{:delete_selected, node_ids, edge_ids} | Bulk deletion of selected elements |
{:flow_reset, %State{}} | The entire flow was replaced |
Unknown changes leave the flow untouched. For your own change types, match
{:lf_flow_change, _sender, {:my_change, ...}} in handle_info/2 before delegating
to Collaboration.handle_info/2.
A convenient pattern is a commit/2 helper that applies a change locally and
broadcasts the same tuple, so both sides run the same code:
defp commit(socket, change) do
socket
|> assign(:flow, Collaboration.apply_remote_change(socket.assigns.flow, change))
|> Collaboration.broadcast_change(change)
end
def handle_event("lf:edge_label_change", %{"id" => id, "label" => label}, socket) do
{:noreply, commit(socket, {:edge_update, id, [label: label]})}
end
def handle_event("lf:reconnect_edge", params, socket) do
{:noreply, commit(socket, {:edge_reconnect, params})}
endLive Dragging
lf:node_change only reports where a drag starts and ends. To show other users the
nodes moving, relay the throttled lf:drag_move events:
def handle_event("lf:drag_move", %{"changes" => changes}, socket) do
{:noreply, Collaboration.broadcast_drag_move(socket, changes)}
endReceivers move the nodes in the browser only (lf:remote_drag, interpolated for
smooth movement), without touching their state or re-rendering; the final position
arrives with the {:node_changes, ...} broadcast of the drop.
Shared State
PubSub only carries changes. Users joining later need the current flow, so keep it
somewhere shared (a GenServer, an Agent, ETS or your database), update it on every
change and read it in mount/3. The showcase page /examples/interaction/collaborative
keeps it in an Agent.
Cursor Sharing
Enable the Cursor Overlay
Pass cursors: true in the Flow component options:
<.live_component
module={LiveFlow.Components.Flow}
id="collab-flow"
flow={@flow}
opts={%{
controls: true,
background: :dots,
cursors: true
}}
/>This renders an SVG overlay showing other users' cursors as colored arrows with name labels.
Broadcasting Cursor Position
With cursors: true, the hook sends lf:cursor_move (throttled, in flow
coordinates) while the pointer moves over the flow. Relay it:
@impl true
def handle_event("lf:cursor_move", %{"x" => x, "y" => y}, socket) do
{:noreply, Collaboration.broadcast_cursor(socket, x, y)}
endOther users receive it through Collaboration.handle_info/2, which pushes
lf:remote_cursor to their browser. Each cursor displays as an SVG arrow in the
user's color, with their name, and disappears when the user leaves (with Presence).
User Identity
LiveFlow.Collaboration.User generates deterministic display names and colors
from the user ID:
# Auto-generated name and color (deterministic from ID hash)
user = User.new("user_abc123")
user.name #=> "User 42"
user.color #=> "#3b82f6"
# Explicit name and color
user = User.new("user_abc123", name: "Alice", color: "#ef4444")
user.name #=> "Alice"
user.color #=> "#ef4444"The color is chosen from a palette of 10 visually distinct colors. The same user ID always produces the same name and color across sessions.
Displaying Online Users
LiveFlow provides a presence_list function component for showing who is online:
@impl true
def render(assigns) do
~H"""
<div class="h-screen flex flex-col">
<div class="p-4 border-b">
<Collaboration.presence_list
presences={@lf_presences}
current_user={@lf_user}
/>
</div>
<div class="flex-1">
<.live_component
module={LiveFlow.Components.Flow}
id="collab-flow"
flow={@flow}
opts={%{cursors: true}}
/>
</div>
</div>
"""
endThis renders each user's name with a colored dot, the current user marked as "(you)", and a count of online users.
Leaving a Session
Call Collaboration.leave/1 when the user disconnects or navigates away:
@impl true
def terminate(_reason, socket) do
Collaboration.leave(socket)
:ok
endThis unsubscribes from PubSub topics and untracks Presence.
Complete Example
defmodule MyAppWeb.CollabFlowLive do
use MyAppWeb, :live_view
alias LiveFlow.State
alias LiveFlow.Changes.NodeChange
alias LiveFlow.Collaboration
alias LiveFlow.Collaboration.User
@impl true
def mount(%{"room" => room_id}, session, socket) do
user = User.new(session["user_id"] || "anon-#{System.unique_integer([:positive])}")
flow = get_or_create_flow(room_id)
socket =
socket
|> assign(flow: flow, room_id: room_id)
|> Collaboration.join("flow:#{room_id}", user,
pubsub: MyApp.PubSub,
presence: MyAppWeb.Presence
)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div class="h-screen flex flex-col">
<div class="p-3 bg-base-200 border-b flex items-center justify-between">
<h1 class="font-bold">Room: {@room_id}</h1>
<Collaboration.presence_list
presences={@lf_presences}
current_user={@lf_user}
/>
</div>
<div class="flex-1">
<.live_component
module={LiveFlow.Components.Flow}
id="collab-flow"
flow={@flow}
opts={%{controls: true, background: :dots, cursors: true, fit_view_on_init: true}}
/>
</div>
</div>
"""
end
@impl true
def handle_event("lf:node_change", %{"changes" => changes}, socket) do
flow = NodeChange.apply_changes(socket.assigns.flow, changes)
shared = Enum.filter(changes, &(&1["type"] in ~w(position resize remove)))
socket =
if shared == [],
do: socket,
else: Collaboration.broadcast_change(socket, {:node_changes, shared})
{:noreply, assign(socket, flow: flow)}
end
@impl true
def handle_event("lf:drag_move", %{"changes" => changes}, socket) do
{:noreply, Collaboration.broadcast_drag_move(socket, changes)}
end
@impl true
def handle_event("lf:cursor_move", %{"x" => x, "y" => y}, socket) do
{:noreply, Collaboration.broadcast_cursor(socket, x, y)}
end
@impl true
def handle_event("lf:connect_end", params, socket) do
case LiveFlow.Validation.Connection.validate_and_create(socket.assigns.flow, params) do
{:ok, edge} ->
flow = State.add_edge(socket.assigns.flow, edge)
socket = Collaboration.broadcast_change(socket, {:edge_add, edge})
{:noreply, assign(socket, flow: flow)}
{:error, _} ->
{:noreply, socket}
end
end
@impl true
def handle_event("lf:delete_selected", _params, socket) do
flow = socket.assigns.flow
node_ids = MapSet.to_list(flow.selected_nodes)
edge_ids = MapSet.to_list(flow.selected_edges)
updated = State.delete_selected(flow)
socket = Collaboration.broadcast_change(socket, {:delete_selected, node_ids, edge_ids})
{:noreply, assign(socket, flow: updated)}
end
@impl true
def handle_event("lf:viewport_change", params, socket) do
flow = State.update_viewport(socket.assigns.flow, params)
{:noreply, assign(socket, flow: flow)}
end
@impl true
def handle_event("lf:selection_change", %{"nodes" => nodes, "edges" => edges}, socket) do
flow =
socket.assigns.flow
|> State.clear_selection()
|> State.select_nodes(nodes)
flow = Enum.reduce(edges, flow, &State.select_edge(&2, &1, multi: true))
{:noreply, assign(socket, flow: flow)}
end
@impl true
def handle_event("lf:" <> _, _params, socket), do: {:noreply, socket}
@impl true
def handle_info(msg, socket) do
case Collaboration.handle_info(msg, socket) do
{:ok, socket} -> {:noreply, socket}
:ignore -> {:noreply, socket}
end
end
@impl true
def terminate(_reason, socket) do
Collaboration.leave(socket)
:ok
end
# get_or_create_flow/1 reads the shared state (see "Shared State")
endArchitecture Notes
- Collaboration is PubSub-based with no central server process. Each LiveView process subscribes to shared topics and broadcasts changes directly (keep the shared state for late joiners yourself, see "Shared State").
- Changes from the current user are applied locally first, then broadcast.
handle_info/2skips messages from the current user to avoid double-applying. - Undo/redo history is local only and not broadcast to other users.
- Cursor positions are broadcast on a separate PubSub topic (
"#{topic}:cursors") to allow independent throttling.