if Code.ensure_loaded?(Phoenix.LiveView) do defmodule Easel.LiveView do @moduledoc """ 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: 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: ## 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: 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 `` element stacked via CSS. Only layers whose assigns change get re-patched — static layers like backgrounds are sent once: <:layer id="background" ops={@background.ops} /> <:layer id="sprites" ops={@sprites.ops} templates={@sprites.templates} /> <:layer id="ui" ops={@ui.ops} /> ## 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: ## 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. """ use Phoenix.Component alias Phoenix.LiveView.ColocatedHook @doc """ Renders a `` 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"`) * `mouse_move_fps` - optional max rate for mousemove push events (default: frame-synced) * `on_key_down` - enable keydown events (pushes `"\#{id}:keydown"`) Any additional attributes are passed through to the `` element. """ attr(:id, :string, required: true) attr(:width, :integer, default: nil) attr(:height, :integer, default: nil) attr(:ops, :list, default: []) attr(:templates, :map, default: %{}) attr(:class, :string, default: nil) attr(:on_click, :boolean, default: false) attr(:on_mouse_down, :boolean, default: false) attr(:on_mouse_up, :boolean, default: false) attr(:on_mouse_move, :boolean, default: false) attr(:mouse_move_fps, :integer, default: nil) attr(:on_key_down, :boolean, default: false) attr(:rest, :global) def canvas(assigns) do events = [] |> then(fn e -> if assigns.on_click, do: ["click" | e], else: e end) |> then(fn e -> if assigns.on_mouse_down, do: ["mousedown" | e], else: e end) |> then(fn e -> if assigns.on_mouse_up, do: ["mouseup" | e], else: e end) |> then(fn e -> if assigns.on_mouse_move, do: ["mousemove" | e], else: e end) |> then(fn e -> if assigns.on_key_down, do: ["keydown" | e], else: e end) assigns = assign(assigns, :events, Phoenix.json_library().encode_to_iodata!(events)) ~H""" """ end @doc """ 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 `` element. ## Example Export PNG """ attr(:for, :string, required: true) attr(:filename, :string, default: "canvas.png") attr(:class, :string, default: nil) attr(:rest, :global) slot(:inner_block, required: true) def export_button(assigns) do ~H""" {render_slot(@inner_block)} """ end @doc """ Renders a stack of layered canvases. Each layer is an independent `` element, stacked via CSS `position: absolute`. Only layers whose ops change get redrawn — LiveView's normal diffing handles this. ## Example <:layer id="background" ops={@background.ops} /> <:layer id="sprites" ops={@sprites.ops} templates={@sprites.templates} /> <:layer id="ui" ops={@ui.ops} /> ## 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 * `mouse_move_fps` — optional max rate for mousemove push events on this layer Only the topmost layer with event flags will receive pointer events. Lower layers have `pointer-events: none` by default. """ attr(:id, :string, required: true) attr(:width, :integer, required: true) attr(:height, :integer, required: true) attr(:class, :string, default: nil) attr(:rest, :global) slot :layer, required: true do attr(:id, :string, required: true) attr(:ops, :list) attr(:templates, :map) attr(:on_click, :boolean) attr(:on_mouse_down, :boolean) attr(:on_mouse_up, :boolean) attr(:on_mouse_move, :boolean) attr(:mouse_move_fps, :integer) attr(:on_key_down, :boolean) end def canvas_stack(assigns) do # Find the topmost event-enabled layer (layers are in bottom-to-top order). # Only that layer gets pointer-events: auto; all others get pointer-events: none. {layers_with_pe, _found_top} = assigns.layer |> Enum.reverse() |> Enum.map_reduce(false, fn layer, found_top -> has_events = Map.get(layer, :on_click, false) or Map.get(layer, :on_mouse_down, false) or Map.get(layer, :on_mouse_up, false) or Map.get(layer, :on_mouse_move, false) or Map.get(layer, :on_key_down, false) pe = if has_events and not found_top, do: "auto", else: "none" {Map.put(layer, :__pointer_events, pe), found_top or has_events} end) assigns = assign(assigns, :layer, Enum.reverse(layers_with_pe)) ~H""" <.canvas :for={layer <- @layer} id={layer.id} width={@width} height={@height} ops={Map.get(layer, :ops, [])} templates={Map.get(layer, :templates, %{})} style={"position: absolute; top: 0; left: 0; pointer-events: #{Map.get(layer, :__pointer_events, "none")};"} on_click={Map.get(layer, :on_click, false)} on_mouse_down={Map.get(layer, :on_mouse_down, false)} on_mouse_up={Map.get(layer, :on_mouse_up, false)} on_mouse_move={Map.get(layer, :on_mouse_move, false)} mouse_move_fps={Map.get(layer, :mouse_move_fps, nil)} on_key_down={Map.get(layer, :on_key_down, false)} /> """ end @doc """ 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`) """ def draw(socket, id, %Easel{} = canvas, opts \\ []) do canvas = Easel.render(canvas) payload = if map_size(canvas.templates) > 0 do %{ops: canvas.ops, templates: canvas.templates} else %{ops: canvas.ops} end payload = if opts[:clear], do: Map.put(payload, :clear, true), else: payload Phoenix.LiveView.push_event(socket, "easel:#{id}:draw", payload) end @doc """ Clears the entire canvas. """ def clear(socket, id) do Phoenix.LiveView.push_event(socket, "easel:#{id}:clear", %{}) end @doc """ 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") """ def animate(socket, id, state_key, tick_fn, opts \\ []) do interval = Keyword.get(opts, :interval, 16) canvas_assign = Keyword.get(opts, :canvas_assign, nil) if timer_ref = animation_timer(socket, id) do Process.cancel_timer(timer_ref) end timer_ref = if Phoenix.LiveView.connected?(socket) do Process.send_after(self(), {:easel_tick, id}, interval) end anim = %{ tick_fn: tick_fn, state_key: state_key, canvas_assign: canvas_assign, interval: interval, running: true } animations = Map.get(socket.assigns, :__easel_animations, %{}) socket |> Phoenix.Component.assign(:__easel_animations, Map.put(animations, id, anim)) |> put_animation_timer(id, timer_ref) end @doc """ 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 """ def tick(socket, id) do animations = Map.get(socket.assigns, :__easel_animations, %{}) anim = Map.get(animations, id) if anim && anim.running do state = socket.assigns[anim.state_key] {canvas, new_state} = anim.tick_fn.(state) timer_ref = Process.send_after(self(), {:easel_tick, id}, anim.interval) socket = socket |> put_animation_timer(id, timer_ref) |> Phoenix.Component.assign(anim.state_key, new_state) if anim.canvas_assign do Phoenix.Component.assign(socket, anim.canvas_assign, Easel.render(canvas)) else socket end else socket end end @doc """ Stops a running animation. """ def stop_animation(socket, id) do animations = Map.get(socket.assigns, :__easel_animations, %{}) case Map.get(animations, id) do nil -> delete_animation_timer(socket, id) anim -> if timer_ref = animation_timer(socket, id) do Process.cancel_timer(timer_ref) end updated = Map.put(animations, id, %{anim | running: false}) socket |> Phoenix.Component.assign(:__easel_animations, updated) |> delete_animation_timer(id) end end defp animation_timers(socket), do: Map.get(socket.private, :__easel_animation_timers, %{}) defp animation_timer(socket, id), do: Map.get(animation_timers(socket), id) defp put_animation_timer(socket, id, nil), do: delete_animation_timer(socket, id) defp put_animation_timer(socket, id, timer_ref) do timers = Map.put(animation_timers(socket), id, timer_ref) Phoenix.LiveView.put_private(socket, :__easel_animation_timers, timers) end defp delete_animation_timer(socket, id) do timers = Map.delete(animation_timers(socket), id) Phoenix.LiveView.put_private(socket, :__easel_animation_timers, timers) end end end