Easel.LiveView (easel v0.2.2)

Copy Markdown View Source

A Phoenix LiveView component for rendering and drawing on an HTML canvas.

Setup

No JavaScript build step is required. The hook is colocated and injected at runtime automatically.

If you use colocated hooks elsewhere in your app, ensure your LiveSocket merges them:

import {hooks as colocatedHooks} from "phoenix-colocated/my_app"

const liveSocket = new LiveSocket("/live", Socket, {
  hooks: {...colocatedHooks},
  // ...
})

Usage

Render a canvas element in your LiveView template:

<Easel.LiveView.canvas id="my-canvas" width={300} height={300} />

Then draw to it from any event handler:

def handle_event("draw", _, socket) do
  canvas =
    Easel.new(300, 300)
    |> Easel.set_fill_style("blue")
    |> Easel.fill_rect(0, 0, 100, 100)
    |> Easel.render()

  {:noreply, Easel.LiveView.draw(socket, "my-canvas", canvas)}
end

You can also pass initial ops to draw on mount:

<Easel.LiveView.canvas id="my-canvas" width={300} height={300} ops={@canvas.ops} />

Clearing

To clear the canvas before drawing:

{:noreply, Easel.LiveView.clear(socket, "my-canvas")}

Or clear and draw in one step:

{:noreply, Easel.LiveView.draw(socket, "my-canvas", canvas, clear: true)}

Events

Enable mouse and keyboard events by setting the corresponding attributes. Events are sent as LiveView events with the canvas id as a prefix:

<Easel.LiveView.canvas
  id="my-canvas"
  width={300}
  height={300}
  on_click
  on_mouse_move
  on_key_down
/>

Then handle them in your LiveView:

def handle_event("my-canvas:click", %{"x" => x, "y" => y}, socket) do
  # ...
  {:noreply, socket}
end

def handle_event("my-canvas:mousemove", %{"x" => x, "y" => y}, socket) do
  # ...
  {:noreply, socket}
end

def handle_event("my-canvas:keydown", %{"key" => key}, socket) do
  # ...
  {:noreply, socket}
end

Available event attributes: on_click, on_mouse_down, on_mouse_up, on_mouse_move, on_key_down.

Layers

Use canvas_stack/1 to layer multiple canvases. Each layer is an independent <canvas> element stacked via CSS. Only layers whose assigns change get re-patched — static layers like backgrounds are sent once:

<Easel.LiveView.canvas_stack id="game" width={800} height={600}>
  <:layer id="background" ops={@background.ops} />
  <:layer id="sprites" ops={@sprites.ops} templates={@sprites.templates} />
  <:layer id="ui" ops={@ui.ops} />
</Easel.LiveView.canvas_stack>

Templates and Instances

For scenes with many similar shapes, define a template once and stamp out instances. The template ops are cached client-side; only the per-instance data (position, rotation, color) is sent each frame:

canvas =
  Easel.new(800, 600)
  |> Easel.template(:boid, fn c ->
    c
    |> Easel.begin_path()
    |> Easel.move_to(12, 0)
    |> Easel.line_to(-4, -5)
    |> Easel.line_to(-4, 5)
    |> Easel.close_path()
    |> Easel.fill()
  end)
  |> Easel.instances(:boid, instances)
  |> Easel.render()

Pass templates alongside ops in your template:

<Easel.LiveView.canvas id="c" ops={@canvas.ops} templates={@canvas.templates} ... />

Animation

Start a server-side animation loop:

def mount(_params, _session, socket) do
  socket =
    socket
    |> assign(:state, initial_state())
    |> assign(:canvas, Easel.new(600, 400) |> Easel.render())
    |> Easel.LiveView.animate("my-canvas", :state, fn state ->
      new_state = tick(state)
      canvas = render(new_state)
      {canvas, new_state}
    end, interval: 16, canvas_assign: :canvas)

  {:ok, socket}
end

def handle_info({:easel_tick, id}, socket) do
  {:noreply, Easel.LiveView.tick(socket, id)}
end

The canvas is redrawn each tick via LiveView's normal rendering cycle. The hook uses requestAnimationFrame to sync draws with the browser's refresh rate — multiple server updates between frames are coalesced.

Summary

Functions

Starts a server-side animation loop that redraws a canvas at a fixed interval.

Renders a <canvas> element wired to a colocated LiveView hook.

Renders a stack of layered canvases. Each layer is an independent <canvas> element, stacked via CSS position: absolute. Only layers whose ops change get redrawn — LiveView's normal diffing handles this.

Clears the entire canvas.

Pushes draw operations to a canvas element on the client.

Renders an export button that downloads the canvas as a PNG image.

Stops a running animation.

Processes an animation tick. Call this from your handle_info

Functions

animate(socket, id, state_key, tick_fn, opts \\ [])

Starts a server-side animation loop that redraws a canvas at a fixed interval.

The tick_fn receives the current state and must return {%Easel{}, new_state}. Each frame, the rendered ops are stored in a canvas assign so the template re-renders and the hook redraws automatically.

Returns the socket with the animation state stored in assigns.

Options

  • :interval - milliseconds between frames (default 16, ~60fps)
  • :canvas_assign - assign key to store the rendered canvas (default: same as state_key). Your template should bind ops={@canvas_assign_key.ops} (or wherever you read ops from).

Example

In your LiveView mount/3:

def mount(_params, _session, socket) do
  initial = %{balls: [...], canvas: Easel.new(600, 400)}

  socket =
    socket
    |> assign(:state, initial)
    |> Easel.LiveView.animate("my-canvas", :state, fn state ->
      new_balls = tick(state.balls)
      canvas = render_balls(new_balls)
      {canvas, %{state | balls: new_balls, canvas: canvas}}
    end)

  {:ok, socket}
end

Your LiveView must include a handle_info clause to receive ticks:

def handle_info({:easel_tick, id}, socket) do
  {:noreply, Easel.LiveView.tick(socket, id)}
end

To stop the animation:

Easel.LiveView.stop_animation(socket, "my-canvas")

canvas(assigns)

Renders a <canvas> element wired to a colocated LiveView hook.

Attributes

  • id (required) - unique DOM id, also used to target draw commands
  • width - canvas width in pixels
  • height - canvas height in pixels
  • ops - list of ops to draw (default []). When this changes, the hook automatically clears and redraws.
  • class - CSS class for the canvas element
  • on_click - enable click events (pushes "#{id}:click")
  • on_mouse_down - enable mousedown events (pushes "#{id}:mousedown")
  • on_mouse_up - enable mouseup events (pushes "#{id}:mouseup")
  • on_mouse_move - enable mousemove events (pushes "#{id}:mousemove")
  • on_key_down - enable keydown events (pushes "#{id}:keydown")

Any additional attributes are passed through to the <canvas> element.

Attributes

  • id (:string) (required)
  • width (:integer) - Defaults to nil.
  • height (:integer) - Defaults to nil.
  • ops (:list) - Defaults to [].
  • templates (:map) - Defaults to %{}.
  • class (:string) - Defaults to nil.
  • on_click (:boolean) - Defaults to false.
  • on_mouse_down (:boolean) - Defaults to false.
  • on_mouse_up (:boolean) - Defaults to false.
  • on_mouse_move (:boolean) - Defaults to false.
  • on_key_down (:boolean) - Defaults to false.
  • Global attributes are accepted.

canvas_stack(assigns)

Renders a stack of layered canvases. Each layer is an independent <canvas> element, stacked via CSS position: absolute. Only layers whose ops change get redrawn — LiveView's normal diffing handles this.

Example

<Easel.LiveView.canvas_stack id="game" width={800} height={600}>
  <:layer id="background" ops={@background.ops} />
  <:layer id="sprites" ops={@sprites.ops} templates={@sprites.templates} />
  <:layer id="ui" ops={@ui.ops} />
</Easel.LiveView.canvas_stack>

Slots

Each :layer slot accepts:

  • id (required) — unique DOM id for this layer's canvas
  • ops — list of drawing operations
  • templates — map of template definitions (for instance rendering)
  • on_click, on_mouse_down, on_mouse_up, on_mouse_move, on_key_down — event flags

Only the topmost layer with event flags will receive pointer events. Lower layers have pointer-events: none by default.

Attributes

  • id (:string) (required)
  • width (:integer) (required)
  • height (:integer) (required)
  • class (:string) - Defaults to nil.
  • Global attributes are accepted.

Slots

  • layer (required) - Accepts attributes:
    • id (:string) (required)
    • ops (:list)
    • templates (:map)
    • on_click (:boolean)
    • on_mouse_down (:boolean)
    • on_mouse_up (:boolean)
    • on_mouse_move (:boolean)
    • on_key_down (:boolean)

clear(socket, id)

Clears the entire canvas.

draw(socket, id, canvas, opts \\ [])

Pushes draw operations to a canvas element on the client.

This uses push_event to send ops directly to the hook without going through the normal render cycle. Useful for one-off draws from event handlers.

Options

  • :clear - if true, clears the canvas before drawing (default false)

export_button(assigns)

Renders an export button that downloads the canvas as a PNG image.

When clicked, converts the target canvas to a PNG and triggers a browser file download.

Attributes

  • for (required) — the DOM id of the canvas to export
  • filename — download filename (default "canvas.png")
  • class — CSS class for the button

Any additional attributes are passed through to the <button> element.

Example

<Easel.LiveView.canvas id="my-canvas" width={300} height={300} ops={@ops} />
<Easel.LiveView.export_button for="my-canvas" filename="drawing.png">
  Export PNG
</Easel.LiveView.export_button>

Attributes

  • for (:string) (required)
  • filename (:string) - Defaults to "canvas.png".
  • class (:string) - Defaults to nil.
  • Global attributes are accepted.

Slots

  • inner_block (required)

stop_animation(socket, id)

Stops a running animation.

tick(socket, id)

Processes an animation tick. Call this from your handle_info:

def handle_info({:easel_tick, id}, socket) do
  {:noreply, Easel.LiveView.tick(socket, id)}
end