Collaborative Code Editor Design
Setup
Mix.install([
{:phoenix_live_view, "~> 0.20.17"},
{:jason, "~> 1.4"},
{:kino, "~> 0.11.0"},
{:burrito, "~> 1.0"}, # For deployments
{:rewire, "~> 0.9"}, # For testing/mocking
{:diff_match_patch, "~> 0.2"} # For text diffing
])
defmodule CollabEditor.Design do
@moduledoc """
Design patterns for collaborative editing features
"""
def architecture do
"""
# Collaborative Editor Architecture
## Core Components:
1. EditorPresence - Tracks active users and cursors
2. DocumentState - Manages document state and operations
3. OperationalTransform - Handles concurrent edits
4. SyncManager - Manages state synchronization
## Data Flow:
┌─────────────────┐
│ LiveView UI │
└────────┬────────┘
│
┌────────┴────────┐
│ Phoenix PubSub │
└────────┬────────┘
│
┌──────────────┴──────────────┐
│ │
┌─────────┴───────────┐ ┌──────────┴──────────┐
│ DocumentState │ │ EditorPresence │
│ (GenServer) │ │ (Presence) │
└─────────┬──────────┘ └──────────┬──────────┘
│ │
└──────────────┬────────────┘
│
┌────────┴────────┐
│ SyncManager │
└────────┬────────┘
│
┌────────┴────────┐
│ Database │
└─────────────────┘
"""
end
end
defmodule CollabEditor.DocumentState do
@moduledoc """
Manages document state and operations
"""
use GenServer
defstruct [:id, :content, :version, operations: [], users: %{}]
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: via_tuple(opts[:id]))
end
def init(opts) do
{:ok, %__MODULE__{
id: opts[:id],
content: opts[:initial_content] || "",
version: 0
}}
end
def handle_cast({:apply_operation, operation, user_id}, state) do
# Apply operational transform
{transformed_op, new_content} =
OperationalTransform.apply(operation, state.content, state.operations)
new_state = %{state |
content: new_content,
version: state.version + 1,
operations: [transformed_op | state.operations]
}
broadcast_update(new_state)
{:noreply, new_state}
end
defp via_tuple(id), do: {:via, Registry, {CollabEditor.Registry, id}}
defp broadcast_update(state) do
Phoenix.PubSub.broadcast(
CollabEditor.PubSub,
"document:#{state.id}",
{:doc_updated, state}
)
end
end
defmodule CollabEditor.OperationalTransform do
@moduledoc """
Handles concurrent edit operations
"""
def apply(operation, content, previous_ops) do
# Transform operation against previous operations
transformed = transform_operation(operation, previous_ops)
# Apply transformed operation to content
new_content = apply_operation(transformed, content)
{transformed, new_content}
end
defp transform_operation(op, []), do: op
defp transform_operation(op, [prev | rest]) do
transformed = transform_pair(op, prev)
transform_operation(transformed, rest)
end
defp transform_pair(op1, op2) do
# Implement operational transform algorithm
# This is a simplified version
case {op1, op2} do
{{:insert, pos1, text1}, {:insert, pos2, _}} when pos1 > pos2 ->
{:insert, pos1 + String.length(text1), text1}
{{:delete, pos1, len1}, {:insert, pos2, text2}} when pos1 > pos2 ->
{:delete, pos1 + String.length(text2), len1}
# Add more transformation rules
_ -> op1
end
end
end
LiveView Implementation
defmodule CollabEditorWeb.EditorLive do
use Phoenix.LiveView
def mount(%{"id" => id}, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(CollabEditor.PubSub, "document:#{id}")
CollabEditor.EditorPresence.track_user(self(), id, socket.assigns.current_user)
end
{:ok, assign(socket,
document_id: id,
content: CollabEditor.DocumentState.get_content(id),
users: CollabEditor.EditorPresence.list_users(id),
cursor_positions: %{}
)}
end
def handle_event("edit", %{"operation" => operation}, socket) do
CollabEditor.DocumentState.apply_operation(
socket.assigns.document_id,
operation,
socket.assigns.current_user.id
)
{:noreply, socket}
end
def handle_event("cursor_move", %{"position" => position}, socket) do
CollabEditor.EditorPresence.update_cursor(
socket.assigns.document_id,
socket.assigns.current_user.id,
position
)
{:noreply, socket}
end
def handle_info({:doc_updated, new_state}, socket) do
{:noreply, assign(socket, content: new_state.content)}
end
def handle_info({:presence_diff, diff}, socket) do
{:noreply, update_presence(socket, diff)}
end
def render(assigns) do
~H"""
<%= for {user_id, user} <- @users do %>
<%= user.name %>
<% end %>
<%= @content %>
<%= for {user_id, pos} <- @cursor_positions do %>
<% end %>
"""
end
end
Burrito Deployment
# config/burrito.exs
config :burrito,
app: :collab_editor,
output_dir: "rel/burrito",
steps: [
:assemble,
:compress
],
targets: [
macos: [os: :darwin, cpu: :x86_64],
linux: [os: :linux, cpu: :x86_64],
windows: [os: :windows, cpu: :x86_64]
]
# mix.exs
def project do
[
app: :collab_editor,
deps: [
{:burrito, "~> 1.0", runtime: false}
],
releases: [
collab_editor: [
steps: [:assemble, :tar],
include_executables_for: [:unix, :windows]
]
]
]
end
Testing with Rewire
defmodule CollabEditor.DocumentStateTest do
use ExUnit.Case
use Rewire
# Rewire allows us to mock dependencies
rewire CollabEditor.DocumentState, OperationalTransform: MockOperationalTransform
test "applies operations with transformation" do
operation = {:insert, 0, "Hello"}
# Mock the transformation
MockOperationalTransform
|> expect(:apply, fn ^operation, "", [] ->
{operation, "Hello"}
end)
{:ok, pid} = DocumentState.start_link(id: "test-doc")
:ok = DocumentState.apply_operation("test-doc", operation, "user-1")
assert DocumentState.get_content("test-doc") == "Hello"
end
end
Key features:
-
Real-time Collaboration:
- Operational Transform for concurrent edits
- Presence tracking for active users
- Cursor position synchronization
-
State Management:
- GenServer-based document state
- PubSub for real-time updates
- Registry for document process lookup
-
Deployment:
- Burrito for creating standalone executables
- Cross-platform support
- Easy distribution
-
Testing:
- Rewire for dependency mocking
- Comprehensive test coverage
- Isolated component testing
To use:
-
Start the server:
mix phx.server
-
Create standalone executable:
mix burrito.build
-
Run tests:
mix test
Would you like me to expand on any particular aspect?