Every canvas mutation should enter LivePanels as a command.
A command is a request to change or inspect runtime state. It can come from a application function call, a browser attr, item content, graph interaction, drawing gesture, shared participant, or transaction.
LivePanels uses one command path so authorization, validation, reducers, history, persistence, shared coordination, and projection stay consistent.
Use Owner Modules From Server Code
Call the module that owns the thing you are changing:
socket = LivePanels.Canvas.Item.move_to!(socket, "note-1", {120, 80})
socket = LivePanels.Canvas.Item.resize_to!(socket, "note-1", {420, 300})
socket = LivePanels.Canvas.Item.merge_props!(socket, "note-1", %{status: "review"})
socket = LivePanels.Canvas.Selection.select!(socket, "note-1")
socket = LivePanels.Canvas.History.undo!(socket)Graph and drawing commands use their own owner modules:
socket =
LivePanels.Graph.connect!(socket,
from: {"extract-1", "output"},
to: {"transform-1", "input"}
)
socket =
LivePanels.Drawing.vector_item!(socket, :rectangle, %{
space: :world,
anchor: :top_left,
x: 120,
y: 80,
w: 240,
h: 160
})These calls do not patch private assigns. They submit commands to the runtime.
Use Toolkit Attrs From Vector Itemup
For browser-triggered commands, use toolkit attrs:
<button {add_item_attrs(:note)}>New note</button>
<button {remove_item_attrs(context)}>Close</button>
<button {layout_attrs(:grid)}>Layout</button>
<button {undo_attrs()}>Undo</button>The helper emits the event name and payload fields the browser/runtime protocol expects. Your application still owns the element, label, CSS, and placement.
Use graph attrs for ports and connectors. Use drawing attrs for vector item style, erase targets, and point handles.
Commands Are Checked
A command can be rejected before it becomes state.
Examples:
- the target item does not exist;
- an add-item payload is malformed;
- an item capability is missing;
- a graph port direction or type is incompatible;
- a drawing payload is malformed;
- the actor is a viewer;
- application policy rejects the request.
Use the bare command helper when application code needs to react immediately:
case LivePanels.Canvas.Item.move_by(socket, "note-1", {24, 0}) do
{:ok, socket, _meta} ->
{:noreply, socket}
{:error, reason, socket, _meta} ->
{:noreply, put_flash(socket, :error, inspect(reason))}
endEvery command owner module returns result tuples from its mutating commands:
item commands, props, selection, layout, history, clipboard, viewport, graph,
and drawing. Use bang variants in LiveView callbacks that should receive the
next socket or raise on rejection. Browser-triggered commands still record the
latest result in LivePanels.Canvas.Read.last_command/1.
Rejected commands should be visible enough to debug. They should not leave the canvas half-mutated.
Add Application Policy
Use LivePanels.Canvas.Policy when product data should decide whether a canvas
mutation may happen.
defmodule MyApp.CanvasPolicy do
@behaviour LivePanels.Canvas.Policy
@impl true
def authorize(%LivePanels.Canvas.Policy.Request{action: :remove_item} = request) do
if MyApp.Workflows.item_locked?(request.canvas_id, request.item_ids, request.actor) do
{:error, :active_workflow_reference}
else
:ok
end
end
def authorize(_request), do: :ok
endConfigure the policy on the canvas:
use LivePanels.Canvas,
pubsub: MyApp.PubSub,
policy: MyApp.CanvasPolicyThe policy receives a request with the action, command, actor, canvas id, target
item ids, and runtime context. Return :ok to allow the command,
{:ok, metadata} to allow it with decision metadata, or {:error, reason} /
{:halt, reason} to reject it.
Use policy for product rules that can answer in-process. Use item capabilities for runtime rules such as whether an item can be removed, resized, connected, or placed.
Coordinate Application Writes
When canvas state mirrors application records, run the application write in the application callback or context module, then issue LivePanels commands explicitly after that write succeeds.
def handle_event("move_card", %{"id" => id, "dx" => dx, "dy" => dy}, socket) do
delta = {String.to_integer(dx), String.to_integer(dy)}
case MyApp.Boards.move_card(id, delta) do
{:ok, card} ->
socket =
LivePanels.Canvas.transaction!(socket, fn tx ->
tx
|> LivePanels.Canvas.Item.move_by!(id, delta)
|> LivePanels.Canvas.Item.merge_props!(id, %{
card_id: card.id,
card_version: card.lock_version
})
end)
{:noreply, socket}
{:error, reason} ->
{:noreply, assign(socket, :canvas_error, reason)}
end
endPolicy remains available for acceptance rules that must apply to every command source:
use LivePanels.Canvas,
pubsub: MyApp.PubSub,
policy: MyApp.CanvasPolicyIn shared mode policy runs inside the canonical coordinator command path, so all participants observe the same accepted or rejected result. LivePanels validates the command before invoking policy. Policy callbacks run behind a timeout boundary because they execute in the reducer and shared coordinator command path; a timeout rejects the LivePanels command and kills the policy process.
Use Transactions For Multi-Step Work
Use LivePanels.Canvas.transaction/2 when several changes should be applied as
one coordinated operation:
socket =
LivePanels.Canvas.transaction!(socket, fn tx ->
tx
|> LivePanels.Canvas.Item.add!(:note, id: "note-1")
|> LivePanels.Canvas.Item.place!("note-1", %{
space: :world,
anchor: :top_left,
x: 120,
y: 80,
w: 360,
h: 240
})
|> LivePanels.Canvas.Selection.select!("note-1")
end)Use transaction/2 when a composed operation needs one explicit success
or failure result:
case LivePanels.Canvas.transaction(socket, fn tx ->
LivePanels.Canvas.Item.add!(tx, :note, id: "note-1")
end) do
{:ok, socket, _meta} ->
{:noreply, socket}
{:error, reason, socket, _meta} ->
{:noreply, put_flash(socket, :error, inspect(reason))}
endTransactions are useful for imports, templates, graph edits, and server actions that combine item props, placement, selection, and connectors.
Keep Browser Intent Separate From State
The browser can hold transient intent while a gesture is underway.
For example, while dragging an item the browser knows the pointer position and preview frame. That is not accepted canvas state yet. The final placement becomes a command on release.
The same idea applies to graph connection drafts and drawing drafts. The user gets responsive feedback, but the server accepts the durable change.
Command Sources
Common command sources are:
- application APIs such as
LivePanels.Canvas.Item.add/3; - browser attrs such as
add_item_attrs/2; - item content helpers such as
LivePanels.Item.Content.LiveView.Commands.remove/1; - graph attrs such as
begin_connector_attrs/3andcomplete_connector_attrs/3; - drawing attrs such as
update_style_attrs/3; - shared participants through the shared coordinator;
- transactions composed by the application.
They should all mean the same thing after acceptance: a command updated canvas state and the runtime projected the new state to the connected clients.
What Not To Do
Avoid these patterns:
- replacing LivePanels assigns with your own item list;
- changing item placement only in DOM or JavaScript;
- hand-writing command payload attrs when a toolkit helper exists;
- reaching into private compositor/runtime modules from application code;
- maintaining a second graph, drawing, or layout state model next to the canvas unless it is domain data owned by your app.
Use public command helpers so local mode, shared mode, persistence, history, graph, drawing, and browser projection stay on the same contract.