Drawing App Recipes

Copy Markdown View Source

These recipes show common drawing-app patterns. They are app-level patterns: LivePanels owns runtime drawing behavior, and your app owns product meaning.

Add A Viewport Toolbar Item

Toolbars can be ordinary viewport items:

[
  drawing_tools:
    {MyAppWeb.DrawingToolsLive,
     label: "Drawing tools",
     placement_space: :viewport,
     placement_anchor: :top,
     placement_lock: true,
     reserved_area: %{edge: :top, policy: :avoid},
     frame: :content,
     persistent: false,
     render: %{frame_overflow: :visible, stream_update: :canvas_state},
     interaction: %{presentable: false, removable: false}}
]

This keeps the toolbar inside the canvas runtime while excluding it from saved board content.

Build A Tool Button

<button
  type="button"
  class={["tool-button", @cursor_mode == :draw and @draw_tool == :arrow and "is-active"]}
  phx-click={
    set_cursor_mode_js(@livepanels.canvas_id, :draw,
      tool: :arrow,
      style: @draw_style,
      push: "set_cursor_mode"
    )
  }
>
  Arrow
</button>

Mirror mode when the toolbar needs active state. Skip push: for controls that do not need LiveView assign state.

Apply Style To Selected Vector Items

def handle_event("set_draw_style", params, socket) do
  style = normalize_draw_style(params, socket.assigns.draw_style)
  selection = LivePanels.Canvas.Read.selection(socket)

  selected_vector_items =
    socket
    |> LivePanels.Canvas.Read.items()
    |> Enum.filter(&LivePanels.Canvas.Selection.selected_item?(selection, &1.id))
    |> Enum.filter(fn item ->
      match?({:ok, _draw}, LivePanels.Drawing.vector_state(item))
    end)

  socket =
    Enum.reduce(selected_vector_items, assign(socket, :draw_style, style), fn item, acc ->
      LivePanels.Drawing.update_style!(acc, item, style)
    end)

  {:noreply, socket}
end

Use this behavior when palette changes should restyle selected vector items and also set the style for the next draft.

Create A Callout Stamp

def handle_event("stamp-callout", _params, socket) do
  box = %{x: 160, y: 120, w: 260, h: 120}
  arrow = %{x: 60, y: 160, w: 120, h: 80}

  socket =
    LivePanels.Canvas.transaction!(socket, fn tx ->
      tx
      |> LivePanels.Drawing.vector_item!(:rectangle, box,
        id: "callout-box",
        style: %{color: "#f97316", width: 3, fill_color: "#ffedd5", stroke_style: "solid", opacity: 100, roundness: 10}
      )
      |> LivePanels.Drawing.vector_item!(:arrow, arrow,
        id: "callout-arrow",
        points: [%{x: 0, y: 0}, %{x: 120, y: 80}],
        style: %{color: "#f97316", width: 3, fill_color: "none", stroke_style: "solid", opacity: 100, roundness: 0}
      )
      |> LivePanels.Canvas.Selection.select!(["callout-box", "callout-arrow"])
    end)

  {:noreply, socket}
end

Use deterministic ids only when the stamp can appear once. Generate unique ids for repeatable stamps.

Add A Vector Item Inspector

defp assign_vector_item_inspector(socket) do
  selection = LivePanels.Canvas.Read.selection(socket)

  selected_vector_items =
    socket
    |> LivePanels.Canvas.Read.items()
    |> Enum.filter(&LivePanels.Canvas.Selection.selected_item?(selection, &1.id))
    |> Enum.flat_map(fn item ->
      case LivePanels.Drawing.vector_state(item) do
        {:ok, draw} -> [%{item: item, draw: draw}]
        :error -> []
      end
    end)

  assign(socket, :selected_vector_items, selected_vector_items)
end

Render controls from the inspector:

<aside :if={@selected_vector_items != []} class="vector-item-inspector">
  <section :for={selected <- @selected_vector_items}>
    <p>{selected.draw.kind}</p>
    <button {erase_attrs(selected.item)}>Remove</button>
  </section>
</aside>

Import An Excalidraw File

def handle_event("import_excalidraw", %{"content" => content}, socket) do
  with {:ok, document} <- Jason.decode(content),
       {:ok, [_ | _]} <- LivePanels.Drawing.excalidraw_commands(document) do
    {:noreply, LivePanels.Drawing.import_excalidraw!(socket, document)}
  else
    _error ->
      {:noreply, put_flash(socket, :error, "No supported drawing elements found.")}
  end
end

In a real upload flow, read the uploaded file with LiveView uploads and keep file size limits tight.

Export The Current Drawing

def handle_event("export_excalidraw", _params, socket) do
  document =
    socket
    |> LivePanels.Canvas.Read.state()
    |> LivePanels.Drawing.to_excalidraw()

  {:noreply,
   push_event(socket, "download-file", %{
     filename: "board.excalidraw",
     mime_type: "application/vnd.excalidraw+json",
     content: Jason.encode!(document, pretty: true)
   })}
end

The browser event that actually downloads the file belongs to your application.

Combine Drawing And Graph

Use the graph toolkit when the canvas is primarily a graph. Use the drawing toolkit when it is primarily a drawing surface. For hybrid products, import the toolkit helpers you need explicitly:

use LivePanels.Canvas,
  pubsub: MyApp.PubSub

use LivePanels.Toolkit.Canvas
import LivePanels.Toolkit.Controls.Drawing
import LivePanels.Toolkit.Controls.Viewport
import LivePanels.Graph.Attrs

Then render both capabilities:

<.canvas_runtime {canvas_runtime_attrs(assigns, cursor_mode: @cursor_mode)}>
  <:item :let={context}>
    <.node_or_content_item context={context} />
  </:item>
</.canvas_runtime>

Committed vector items and graph connector items both render through the runtime world SVG item projection. Product UI stays in node markup, controls, and CSS.

Annotate A Document

Use one item for the document and vector items for annotations:

<:item
  id="page-1"
  type="document_page"
  at={{0, 0}}
  size={{960, 1240}}
  props={%{document_id: @document.id, page: 1}}
>
  <.document_page document={@document} page={1} />
</:item>

Keep annotation ids stable when they mirror database rows:

LivePanels.Drawing.vector_item!(socket, :rectangle, placement,
  id: "annotation-#{annotation.id}",
  style: highlight_style
)

The document remains app content. The vector item remains canvas annotation.

Debug A Missing Vector Item

Check these in order:

  • the command created a committed vector item, not just a browser draft;
  • the vector item kind is supported;
  • the style map includes every required field;
  • the item id is unique;
  • the placement has positive width and height;
  • point-backed vector items have valid points;
  • the standard runtime is rendering the world vector layer;
  • custom renderers call LivePanels.Drawing.vector_state/1;
  • erase or removal controls are not immediately removing the vector item;
  • layout restore has the item type and vector state expected by the current code.

Use LivePanels.Drawing.vector_item/4 when creating vector items from server code and show the returned reason on rejection. Browser-authored drawing commands also record their result on LivePanels.Canvas.Read.last_command/1.