LivePanels.Canvas (livepanels v0.1.0)

Copy Markdown View Source

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.PubSub

The 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
end

Markup 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:

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
end

Applications 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
end

LivePanels 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

OptionDefaultDescription
:pubsubrequiredYour app's PubSub module
:domains[]Canvas domains to mount, such as :graph and :drawing; custom domain modules may also be supplied
:policynoneLivePanels.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:

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

actor()

@type actor() :: %{
  :client_id => String.t(),
  :viewer_id => String.t(),
  optional(:viewport) => map(),
  optional(:role) => :editor | :viewer,
  optional(:capabilities) => [atom() | String.t()],
  optional(:region_id) => String.t() | nil
}

command_meta()

@type command_meta() :: map()

command_target()

@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.

socket()

@type socket() :: Phoenix.LiveView.Socket.t()

Functions

mount_runtime(opts, params, session, socket)

@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.

transaction(socket, fun)

@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.

transaction!(socket, fun)

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.