phoenix-dnd

A server-authoritative node editor for Phoenix LiveView, with focused client-side JavaScript for direct-manipulation interactions such as pan, zoom, selection, node dragging, and connection previews.

The package is in early development. Its API may change between minor versions until 1.0.

Demo application

A standalone Phoenix application lives in examples/demo. It uses this repository as a path dependency and includes a five-node workflow, rich and dynamically updating node rendering, add/remove controls, the authoritative intent reducer, and server-triggered viewport controls.

nix develop
cd examples/demo
mix setup
mix phx.server

Visit http://localhost:4000. The demo uses native ESM and intentionally has no separate npm or asset-build step.

Membrane WebRTC studio

A second application in examples/daw wires the editor to Membrane: the node graph is a live audio pipeline, processing WebRTC microphone audio and streaming the result back to the browser. Nodes are oscillators, filters, delays, and mixers; edges are signal paths; and a rejected connection is rejected because the audio graph could not perform it.

nix develop
cd examples/daw
mix setup
mix phx.server

Visit http://localhost:4001, and wear headphones. See its README for the architecture and its trade-offs.

Installation

Add phoenix_dnd to your dependencies:

def deps do
[
{:phoenix_dnd, "~> 0.1"}
]
end

To work against a checkout of this repository instead, use a path dependency:

{:phoenix_dnd, path: "../phoenix-dnd"}

Import the component's colocated hook manifest in assets/js/app.js and merge its named hooks export into the hooks passed to LiveSocket:

import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import {hooks as phoenixDndHooks} from "phoenix-colocated/phoenix_dnd"
const appHooks = {}
const liveSocket = new LiveSocket("/live", Socket, {
hooks: {...phoenixDndHooks, ...appHooks}
})

PhoenixDnd.Editor declares its namespaced hook with Phoenix.LiveView.ColocatedHook. The package keeps the substantial interaction runtime in its prebuilt, independently tested JavaScript module; the colocated manifest is a small adapter generated when the dependency compiles. Phoenix 1.8's default esbuild configuration resolves both deps and the Mix build path. If your asset pipeline is customized, make sure both are resolvable and run mix compile before bundling assets.

PhoenixDnd.Editor declares its structural styles with Phoenix.LiveView.ColocatedCSS. Import the generated manifest from assets/css/app.css:

@import "phoenix-colocated/phoenix_dnd/colocated.css";

For Tailwind, colocated CSS requires Tailwind 4.2.3 or newer and version 0.5 or newer of the :tailwind Hex package. Configure its environment so the resolver can see both dependencies and the Mix build path:

env: %{
"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]
}

Add the generated component directory as a Tailwind source so changes are picked up automatically while developing:

@source "../../_build/dev/phoenix-colocated/phoenix_dnd/*/";

With esbuild, use the same NODE_PATH configuration and import the stylesheet from JavaScript instead:

import "phoenix-colocated/phoenix_dnd/colocated.css"

This produces a CSS output alongside the JavaScript bundle, which must also be linked from the application's root layout. In either setup, compile before running the asset build so LiveView has extracted both manifests. Phoenix's standard development flow does this automatically; custom release aliases should run compile before assets.deploy.

The extracted rules are global but use .phoenix-dnd-prefixed selectors. The component's CSS custom properties can be overridden by the host application without relying on generated scope identifiers.

For an asset pipeline that cannot consume colocated CSS, import the packaged standalone stylesheet instead:

@import "../../deps/phoenix_dnd/priv/static/phoenix_dnd.css";

Use either the colocated manifest or the standalone stylesheet, not both. The standalone file is also the source used to generate the colocated stylesheet, so both integrations provide the same defaults.

For a pipeline that cannot consume colocated manifests, import the fallback hook map directly from phoenix_dnd/priv/static/phoenix_dnd.js (or its relative path under deps). It exposes the same fully qualified hook name.

Rendering an editor

Build a complete %PhoenixDnd.Scene{} on the server and pass it to the stateful PhoenixDnd.Editor. The node slot receives the opaque node and its authoritative selection state; node.data is not serialized unless the slot renders it.

alias PhoenixDnd.{Node, Port, Scene}
scene =
Scene.new!(
nodes: [
Node.new!("source",
position: %{x: 80, y: 120},
label: "Source",
ports: [Port.output("value", anchor: :right)]
),
Node.new!("sink",
position: %{x: 420, y: 120},
label: "Sink",
ports: [Port.input("value", anchor: :left)]
)
]
)
socket = assign(socket, :scene, scene)
<.live_component
module={PhoenixDnd.Editor}
id="workflow"
scene={@scene}
min_zoom={0.2}
max_zoom={4.0}
>
<:node :let={%{node: node, selected?: selected?}}>
<article class={["workflow-node", selected? && "is-selected"]}>
{node.label}
</article>
</:node>
</.live_component>

id and scene are required. The ID must be unique across the whole rendered document, including nested LiveViews. notify may be set to a PID; by default the component sends intents to its owning LiveView process.

Accepting user intent

The hook sends semantic intent to the component, which validates its protocol shape and notifies the owner with:

{:phoenix_dnd, :intent, editor_id, %PhoenixDnd.Intent{}}

The application still owns authorization and domain validation. Apply an accepted intent to the current scene, then use Scene.bump/2 so every accepted authoritative change advances the scene revision. In this example, MyApp.Graph.apply_intent/2 is application code returning scene changes such as %{nodes: updated_nodes}:

alias PhoenixDnd.{Command, CommandBatch, Intent, Scene}
def handle_info(
{:phoenix_dnd, :intent, "workflow", %Intent{} = intent},
socket
) do
scene = socket.assigns.scene
case MyApp.Graph.apply_intent(scene, intent) do
{:ok, changes} ->
next_scene = Scene.bump(scene, changes)
batch =
CommandBatch.new!(
[Command.operation_resolve(intent.intent_id, :accepted)],
after_revision: next_scene.revision
)
socket =
socket
|> assign(:scene, next_scene)
|> PhoenixDnd.push_commands("workflow", batch)
{:noreply, socket}
{:error, reason} ->
batch =
CommandBatch.new!(
[Command.operation_resolve(intent.intent_id, :rejected, reason: reason)],
after_revision: scene.revision
)
{:noreply, PhoenixDnd.push_commands(socket, "workflow", batch)}
end
end

Client payloads remain untrusted even after structural parsing. Validate permissions, IDs, connection rules, coordinates, and the base revision before changing application state.

Treat base_revision as conflict context, not a universal compare-and-swap. The hook can pipeline committed gestures before a prior scene patch arrives. Applications will usually revalidate delete/connection IDs against the current scene, allow latest-wins selection and viewport changes, and reject a node move only when relevant graph state changed or the nodes are no longer movable. MyApp.Graph.apply_intent/2 is where that domain-specific policy belongs.

Send operation.resolve for every accepted or rejected optimistic node, selection, and viewport intent. Its after_revision must identify the scene snapshot that contains the accepted state (or restores the rejected state). The component's immediate received reply acknowledges transport and schema validation only; it does not mean the application accepted the change.

Each committed gesture is one intent. In particular, nodes.move carries both positions and the complete resulting node/edge selection, allowing an application to apply a drag atomically at one base revision. Click/lasso selection, connection creation, delete requests, and viewport changes use their own semantic intent types.

Intent payload maps use string keys:

intent.typeintent.payload
nodes.move%{"positions" => [%{"id" => id, "x" => x, "y" => y}], "selection" => %{"node_ids" => ids, "edge_ids" => ids}}
selection.change%{"mode" => mode, "node_ids" => ids, "edge_ids" => ids}
connection.create%{"source" => %{"node_id" => id, "port_id" => id}, "target" => endpoint}
delete.request%{"node_ids" => ids, "edge_ids" => ids}
viewport.change%{"center_x" => x, "center_y" => y, "zoom" => zoom}

Server-to-client commands

Use command batches for imperative, best-effort actions such as fitting or moving the viewport, cancelling an interaction, or resolving an optimistic operation:

batch =
PhoenixDnd.CommandBatch.new!(
[PhoenixDnd.Command.viewport_fit(padding: 64, animate_ms: 180)],
after_revision: socket.assigns.scene.revision
)
socket = PhoenixDnd.push_commands(socket, "workflow", batch)

after_revision gates the batch. The hook filters the shared command event by editor ID, queues the batch until that scene revision is present in the DOM, then applies its ordered commands together on an animation frame. Commands are not durable state: anything needed after reconnect must also be represented in the next scene snapshot.

Architecture

The API accepts full scene snapshots, while the transport remains incremental:

This keeps one durable source of truth without putting pointer-rate updates on the LiveView connection.

Development environment

Enter the pinned development shell with nix develop. Before the initial commit, use nix develop path:. because Nix ignores untracked flake files.

The shell provides Erlang/OTP 28, Elixir 1.19, Hex, Rebar3, Node.js 24 LTS, Git, and Linux filesystem watching support. Run the complete Elixir and JavaScript verification suite with mix check; format the Nix configuration with nix fmt.