Tools, Cursor Modes, And Drafts

Copy Markdown View Source

Drawing starts in the browser.

The server owns committed canvas state, but the browser owns the active pointer gesture. That split is what makes drawing feel immediate without turning every pointer move into a LiveView event.

In shared canvases the browser sends throttled draw intent while the gesture is active so peers can see live draft previews. The server still treats the final add_vector_item command as the durable source of truth. The browser keeps a pending draw commit locally until that command ack resolves; applications can replace or disable the default draft persistence through createLivePanelsCanvasHook runtime options.

Use The Drawing Toolkit

Drawing canvases should import the drawing toolkit:

use LivePanels.Canvas,
  pubsub: MyApp.PubSub,
  domains: [:drawing]

use LivePanels.Toolkit.Drawing

The drawing preset includes the canvas preset plus drawing controls and viewport JS helpers:

  • set_cursor_mode_js/3;
  • set_draw_style_js/3;
  • drawing element attrs;
  • committed vector item style attrs;
  • erase target attrs;
  • point handle attrs.

Cursor Modes

The browser runtime recognizes these cursor modes:

ModePurpose
:dragSelect, drag, and resize committed canvas items.
:marqueeDraw a selection marquee.
:panPan the viewport.
:drawCreate a drawing draft with the selected tool.
:eraseRemove committed vector items through erase targets.

Set a mode with set_cursor_mode_js/3:

<button phx-click={set_cursor_mode_js(@livepanels.canvas_id, :drag)}>
  Select
</button>

<button phx-click={set_cursor_mode_js(@livepanels.canvas_id, :marquee)}>
  Marquee
</button>

<button phx-click={set_cursor_mode_js(@livepanels.canvas_id, :erase)}>
  Erase
</button>

Drawing Tools

When the mode is :draw, pass a drawing tool and a complete style map:

<button
  phx-click={
    set_cursor_mode_js(@livepanels.canvas_id, :draw,
      tool: :arrow,
      style: %{
        color: "#111827",
        width: 3,
        fill_color: "none",
        stroke_style: "solid",
        opacity: 100,
        roundness: 0
      }
    )
  }
>
  Arrow
</button>

Supported drawing tools are:

  • :pen;
  • :freehand;
  • :polyline;
  • :arrow;
  • :line;
  • :rectangle;
  • :ellipse;
  • :diamond;
  • :triangle;
  • :text.

pen is the toolbar-friendly name for freehand drawing.

Draft Style

Drawing style is explicit. The style map must include all fields:

%{
  color: "#2563eb",
  width: 4,
  fill_color: "none",
  stroke_style: "solid",
  opacity: 100,
  roundness: 0
}

stroke_style must be "solid", "dashed", or "dotted". opacity is an integer from 0 through 100. roundness is a non-negative integer.

Update draft style without changing mode:

<button
  phx-click={
    set_draw_style_js(@livepanels.canvas_id, %{
      color: "#dc2626",
      width: 4,
      fill_color: "none",
      stroke_style: "dashed",
      opacity: 100,
      roundness: 0
    })
  }
>
  Red dashed
</button>

Draft style affects the current and next browser draft. It does not restyle already committed vector items unless the application also sends committed vector item updates.

Mirror Mode In LiveView Assigns

The browser can own mode and style without any LiveView assigns. Most products still mirror them so toolbar buttons can show active state.

Pass push: "event_name" to append a LiveView event after the browser dispatch:

<button
  phx-click={
    set_cursor_mode_js(@livepanels.canvas_id, :draw,
      tool: :rectangle,
      style: @draw_style,
      push: "set_cursor_mode"
    )
  }
>
  Rectangle
</button>

Handle the event in the application:

def handle_event("set_cursor_mode", params, socket) do
  params = Map.get(params, "value", params)

  cursor_mode =
    case params["mode"] do
      "draw" -> :draw
      "erase" -> :erase
      "marquee" -> :marquee
      "pan" -> :pan
      _ -> :drag
    end

  {:noreply, assign(socket, :cursor_mode, cursor_mode)}
end

Pass the mirrored mode into the runtime:

<.canvas_runtime {canvas_runtime_attrs(assigns, cursor_mode: @cursor_mode)} />

This keeps LiveView patches from snapping visible toolbar state back to an old mode.

Mirror Style In LiveView Assigns

Style controls use the same pattern:

<button
  phx-click={
    set_draw_style_js(@livepanels.canvas_id, Map.put(@draw_style, :color, "#16a34a"),
      push: "set_draw_style"
    )
  }
>
  Green
</button>

Handle the pushed style:

def handle_event("set_draw_style", style, socket) do
  {:noreply, assign(socket, :draw_style, normalize_draw_style(style))}
end

If your product applies palette changes to selected committed vector items, do that explicitly in the same event. Draft style and committed vector item style are separate state.

Marquee Selection

Drawing canvases often need both drag selection and marquee selection. Pass the marquee attrs through canvas_runtime_attrs/2:

<.canvas_runtime
  {canvas_runtime_attrs(
    assigns,
    marquee_selection_attrs(:items, :overlap, cursor_mode: @cursor_mode)
  )}
/>

Use :overlap when touching the selection rectangle should select an item. Use :contain when the item must be fully enclosed.

Draft Lifecycle

During a drawing gesture:

  1. the browser creates and updates a draft locally;
  2. the canvas renders the draft immediately;
  3. the finished gesture becomes one drawing command;
  4. the server validates the command;
  5. the accepted command creates a committed canvas item.

The active draft is not persisted, copied, undone, redone, or broadcast to other participants. The committed vector item is.

That boundary keeps collaboration and persistence quiet while the pointer is moving.