LiveView application entrypoint for the LivePanels canvas domain.
Wires the server-owned canvas model into a LiveView through a
Phoenix.LiveView.on_mount/1 hook. In Phoenix apps, start with your app's
normal LiveView macro, then add LivePanels:
use MyAppWeb, :live_view
use LivePanels.Canvas,
pubsub: MyApp.PubSubThe application remains responsible for layouts, verified routes, HTML imports,
and any framework conventions installed by MyAppWeb. Applications that want
explicit lifecycle ownership can call mount_runtime/4 from their own
mount/3 instead. The abstract canvas model lives under LivePanels.Canvas.*;
graph behavior lives under LivePanels.Graph.*; drawing behavior lives under
LivePanels.Drawing.*; runtime projection and shared coordination are wired
internally by the compositor runtime; item content descriptors and render
drivers live under LivePanels.Item.Content.*. Applications own the full render
tree and use the imported runtime component and helper modules for the
browser protocol boundary.
Setup
defmodule MyAppWeb.CanvasLive do
use MyAppWeb, :live_view
use LivePanels.Canvas,
pubsub: MyApp.PubSub
def render(assigns) do
~H"""
<.canvas_runtime {canvas_runtime_attrs(assigns)}>
<:item id="starter-card" at={{80, 80}} size={{640, 420}} resize>
<section class="starter-card">
<header {drag_handle_attrs()}>Starter card</header>
<p>Put ordinary HEEx content directly inside the item declaration.</p>
</section>
</:item>
</.canvas_runtime>
"""
end
endMarkup item declarations are the ordinary item path: put <:item> entries
directly inside canvas_runtime/1, declare their frame with attrs such as
id, at, and size, then place HEEx inside the slot. Runtime-created items
are created from explicit item payloads and rendered through the id-less
<:item :let={context}> slot. LivePanels does not ship visual chrome.
Applications render their own shell inside canvas_runtime/1 slots, use
ordinary Phoenix live/ and components/ folders for host code, and import
application controls progressively through toolkit modules.
use LivePanels.Canvas
When you use LivePanels.Canvas, the module attaches
Phoenix.LiveView.on_mount({LivePanels.Canvas, opts}), installs compile-time
validation that the caller has already used Phoenix LiveView, and defines
__livepanels_canvas_config__/0 returning the configured options.
Canvas application mutations and queries live on deliberate owner modules:
LivePanels.Canvas.Itemfor item lifecycle, placement, attachment, focus, visibility, props, grouping, and item metadataLivePanels.Canvas.Selectionfor actor selectionLivePanels.Canvas.Arrangefor alignment, distribution, stacking, registered layout strategies, and reserved layout areasLivePanels.Canvas.Historyfor undo and redoLivePanels.Canvas.Clipboardfor copy, cut, and pasteLivePanels.Canvas.Viewportfor anchors, stepping, and fittingLivePanels.Canvas.SharedandLivePanels.Canvas.Regionfor shared participant sessions and region boundariesLivePanels.Canvas.Renderfor render stream refreshesLivePanels.Canvas.Readfor read-side socket accessors
Graph and drawing commands stay module-qualified on LivePanels.Graph and
LivePanels.Drawing.
Runtime Component Contract
canvas_runtime/1 owns the root, viewport, stage, stream, frame, and runtime
hook attrs required by the bundled JS. Applications provide slots and ordinary
styling. Use LivePanels.Toolkit.Canvas for canvas_runtime/1,
canvas_runtime_attrs/2, resize_handles/1, drag_handle_attrs/1,
resize_handle_attrs/2, focus_exempt_attrs/1,
keyboard_scope_attrs/1, item controls, selection controls, layout controls,
history controls, clipboard controls, and canvas-wide viewport JS helpers.
The exact data-livepanels-* DOM protocol is an internal contract between
the toolkit components and bundled JS hook. It is documented for maintainers
in maintainer docs, not as setup material for ordinary
applications.
Runtime browser features are explicit. canvas_runtime/1 emits feature tokens
from the configured extensions, so graph and drawing controllers mount only on
canvases that enabled those extensions. Custom render loops can pass
runtime_features: to canvas_runtime/1.
Runtime Protocol
The bundled hook and toolkit helpers own the browser/server protocol. The maintainer-facing DOM and payload details live in the source modules and protocol contract tests. Applications usually create their own buttons, menus, keyboard shortcuts, and toolbar controls with toolkit attrs or module-qualified action calls.
Application lifecycle
LivePanels attaches its runtime with on_mount {LivePanels.Canvas, opts}.
Applications own their ordinary mount/3, handle_event/3, and handle_info/2
callbacks. LivePanels intercepts its own protocol through LiveView lifecycle
hooks before application callbacks run, so no super call is needed.
Applications that do not want a use LivePanels.Canvas entrypoint can compose the
runtime directly:
use MyAppWeb, :live_view
@impl true
def mount(params, session, socket) do
with {:ok, socket} <-
LivePanels.Canvas.mount_runtime(
[pubsub: MyApp.PubSub],
params,
session,
socket
) do
{:ok, socket}
end
endApplications can participate in command acceptance by configuring a canvas policy module:
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
end
on_mount {LivePanels.Canvas, pubsub: MyApp.PubSub, policy: MyApp.CanvasPolicy}The policy receives a normalized LivePanels.Canvas.Policy.Request. Returning
{:error, reason} rejects the command before reducers apply it; browser
command acknowledgements report the rejected status, inspected error, stable
reason string, and diagnostic message. Application command helpers return
command results by default; use the ! variant when a LiveView callback should
receive the updated socket or raise on rejection.
Returning :ok accepts the command. Returning {:ok, metadata} accepts the
command and stores metadata under :policy in command metadata. Returning
{:error, reason} or {:halt, reason} rejects the canvas command before
LivePanels state changes. Policy validates command acceptance; it does not
append commands, rewrite commands, or coordinate application database
writes. Run application writes from application callbacks or context modules,
then issue LivePanels commands through the public command helpers:
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
endLivePanels validates the command before invoking policy. Policy runs behind a timeout boundary because it executes in the reducer and shared-coordinator command path. A policy timeout rejects the LivePanels command and kills the policy process.
Options
| Option | Default | Description |
|---|---|---|
:pubsub | required | Your app's PubSub module |
:domains | [] | Canvas domains to mount, such as :graph and :drawing; custom domain modules may also be supplied |
:policy | none | LivePanels.Canvas.Policy module or {module, opts} for application command acceptance; timeout_ms: controls callback timeout |
:session | :local | :local, :shared, or a keyword/map with :mode, :id, :backend, :layout_store, and :coordinator |
:initial | %{connectors: []} | Initial canvas model data; :connectors seed the graph domain |
:viewport | %{profiles: []} | Viewport profile rules evaluated from measured screen, usable viewport, and zoom metrics |
:persistence | %{debounce_ms: 25, load_timeout_ms: 5_000} | Layout persistence timing |
:interaction | %{lease_timeout_ms: 15_000, broadcast_sync_ms: 16} | Interaction lease and shared fanout timing |
Items are authored in HEEx. A source-owned item has an id; the id-less item
slot renders runtime-owned items:
<.canvas_runtime {canvas_runtime_attrs(assigns)}>
<:item id="chart" type="chart" at={{80, 80}} size={{720, 520}}>
<MyApp.Chart.render />
</:item>
<:item :let={context}>
<MyApp.RuntimeItem.render context={context} />
</:item>
</.canvas_runtime>Viewport profiles name measured viewport breakpoints. Viewport items can use
those names in viewport_frames to project a different rendered frame
for the current socket without mutating canonical item placement:
viewport: [profiles: [compact: [screen_width_lte: 720]]],
<:item
id="toolbar"
type="toolbar"
space={:viewport}
at={{16, 16}}
size={{360, 72}}
anchor={:bottom_right}
viewport_frames={
%{
default: %{anchor: :bottom_right, x: 16, y: 16, w: 360, h: 72},
compact: %{anchor: :top_left, x: 0, y: 0, w: :fill, h: 96}
}
}
>
<MyApp.Toolbar.render />
</:item>Profile conditions may target screen_width, screen_height,
usable_width, usable_height, and zoom with _lte or _gte suffixes.
viewport_frames supports :space, :anchor, :x, :y, :w, and :h;
:space must resolve to :viewport when present. Dimensions may be positive
numbers, :fill, or {:fill, inset: n, min: n, max: n}.
Graph startup topology belongs to the canvas initial model:
initial: [
connectors: [
[from: {"source", "out"}, to: {"target", "in"}]
]
],
domains: [:graph, :drawing]Initial connectors are applied through the graph connection reducer, so port capability checks and typed-port snapping match user-driven graph commands.
Shared canvases use LivePanels.Canvas.Shared to select coordinator lookup,
coordinator ownership, participant tracking ownership, and the layout store
as one backend value. Put the backend and optional layout store under
session: so sharing concerns stay together:
use LivePanels.Canvas,
pubsub: MyApp.PubSub,
session: [
mode: :shared,
id: {:param, "board_id"},
backend: LivePanels.Canvas.Shared.Local,
layout_store: MyApp.CanvasLayouts
]LivePanels.Canvas.Geometry.interaction_sync_ms/1 is the browser-to-server
half of the interaction sync story. Shared canvases use a lower default
network cadence and layer interaction: [broadcast_sync_ms: ...] on top for
server-to-participant fanout, so remote observers can be coalesced more or
less aggressively without changing the originating client's local drag
cadence.
Runtime canvas identity
session: [id: ...] is resolved before the application mount/3 callback because the
runtime must know which local or shared state to mount first. Omit the option
to use the built-in lookup order: params["canvas_id"], session["canvas_id"],
and, in shared mode, params["shared_canvas_id"],
session["shared_canvas_id"], or session["livepanels_shared_canvas_id"]. If
none are present, LivePanels generates an id for that mount.
Shared boards with app-specific route or session keys should configure an explicit runtime source:
use MyAppWeb, :live_view
use LivePanels.Canvas,
pubsub: MyApp.PubSub,
session: [mode: :shared, id: {:param, "board_id"}]{:session, key} reads from the LiveView session. {:resolver, module, function} calls module.function(params, session) and requires a non-empty
string return value.
Application render loop
Applications render the surrounding page and visible chrome. canvas_runtime/1
covers the canonical runtime root/viewport/stage structure plus the default
item render loop. canvas_runtime_attrs/2 pulls @livepanels, the current
runtime streams, and @livepanels.cursor_mode from LiveView assigns. Applications
can pass cursor_mode: as an explicit component override when cursor policy
is app-owned. Runtime projections stay under @livepanels, including
canvas_id, client_id, mode, cursor_mode, selection, history,
clipboard, connector_draft, connector_drafts, render_items, and
connectors; root assigns with those names remain
app-owned. Toolkit modules expose these helpers as explicit imports.
Assigned item values are %LivePanels.Item{} structs; application chrome can
read and pattern match on fields such as item.id, item.type,
item.x, item.y, item.w, and item.h directly.
The :item slot declares source-owned items when it has an id, and acts as
the renderer for runtime-owned items when it has no id. Both forms can
receive a LivePanels.Item.RenderContext with :let; the canonical item is
context.item, render decisions live under context.view, rendered content
lives under context.content, and ports live under context.ports.
LivePanels.Item.t/0 is the public canonical item state record.
Application render code should use the LivePanels.Item projection
yielded by canvas_runtime/1 or returned by items_for_render/2; application code
that needs canonical data, dimensions, or type filtering can inspect the
item struct directly. Mutations still go through owner modules and commands.
Toolkit preset imports:
LivePanels.Toolkit.Canvasexposes canvas runtime helpers and canvas control attrs.LivePanels.Toolkit.Graphuses the canvas preset and exposes graph DOM attrs plus marker helpers.LivePanels.Toolkit.Drawinguses the canvas preset and exposes vector item controls.
Applications that want a custom mix of helpers can import the individual toolkit
modules directly. LivePanels.Toolkit.Integration.ProtocolAttrs and
LivePanels.Toolkit.Integration.Render are integration escape hatches for command
envelopes and render loops, not first-path imports.
Item slots can declare direct content adapters with attrs such as live_view,
remote, and canvas. Static content belongs in the item slot body.
LivePanels.Canvas.Render.refresh/2 refreshes rendered stream items when an
item renderer depends on application assigns outside canonical canvas state.
Integration frame helpers such as canvas_item_frame/1,
canvas_item_frame_attrs/4, items_for_render/2, canvas_item_z_index/1,
and canvas_item_style/2 live in LivePanels.Toolkit.Integration.Render. Integration
command-envelope attrs live in the
:protocol_attrs group as command_attrs/2 and item_command_attrs/3.
Use select_item_drag_handle_attrs/2 when an app-rendered item body should
both select and drag the item without adopting titlebar chrome. Nested
interactive controls should keep using focus-exempt command attrs.
Canvas, graph, and drawing state changes remain explicit module calls:
LivePanels.Canvas.Item.add/3, LivePanels.Canvas.Item.remove/2,
LivePanels.Graph.connect/2, and LivePanels.Drawing.vector_item/4.
Summary
Types
First argument accepted by canvas command helpers.
Functions
Mounts the canvas runtime into an existing LiveView socket.
Applies one canvas transaction through the ordinary socket command path.
Applies one canvas transaction and returns the updated socket.
Types
@type command_meta() :: map()
@type command_target() :: socket() | LivePanels.Canvas.Transaction.t()
First argument accepted by canvas command helpers.
Use a LiveView socket for an immediate command or the transaction context
received inside transaction/2.
@type socket() :: Phoenix.LiveView.Socket.t()
Functions
@spec mount_runtime(keyword() | map(), map() | :not_mounted_params, map(), socket()) :: {:ok, socket()}
Mounts the canvas runtime into an existing LiveView socket.
This is the explicit equivalent of the on_mount {LivePanels.Canvas, opts}
path installed by use LivePanels.Canvas. Call it from an application mount/3 when
the LiveView should own the lifecycle setup directly, then import the toolkit
helper modules or preset modules the template actually uses.
@spec transaction(socket(), (LivePanels.Canvas.Transaction.t() -> LivePanels.Canvas.Transaction.t())) :: {:ok, socket(), command_meta()} | {:error, term(), socket(), command_meta()}
Applies one canvas transaction through the ordinary socket command path.
The callback receives a transaction context. Pass that context to the same owner modules used for direct socket mutations, then return it:
socket =
LivePanels.Canvas.transaction!(socket, fn tx ->
tx
|> LivePanels.Canvas.Item.move_by!("item-a", {24, 0})
|> LivePanels.Canvas.Item.merge_props!("item-a", %{accent: "red"})
|> LivePanels.Graph.connect!(from: {"item-a", "out"}, to: {"item-b", "in"})
end)The commands authorize, reduce, stream, persist, broadcast, and record history
as one logical canvas operation. If any staged command is rejected, the result
is {:error, reason, socket, meta} and the model is not changed. Use
transaction!/2 when rejected commands should raise.
@spec transaction!(socket(), (LivePanels.Canvas.Transaction.t() -> LivePanels.Canvas.Transaction.t())) :: socket()
Applies one canvas transaction and returns the updated socket.
Raises when any staged command is rejected. Use transaction/2 when
application code needs the command result tuple.