Move, Resize, Select, And Focus Items

Copy Markdown View Source

LivePanels item interaction has two sides.

The browser owns immediate pointer feel: drag previews, resize previews, marquee rectangles, and local gesture state. The server owns accepted canvas state: final placement, selection, focus, z-order, history, persistence, and shared fanout.

This guide shows the application APIs for normal workspace interaction.

Make An Item Draggable

Put drag_handle_attrs/1 on the element that should start a drag:

<header {drag_handle_attrs(class: "item-titlebar")}>
  {context.item.label}
</header>

The application chooses the titlebar markup. The attrs tell the browser runtime that this element is a drag handle for the containing item frame.

During a drag, the browser previews movement locally. On release, it sends a placement command. The server validates and accepts or rejects the final frame.

Make An Item Resizable

Use the standard resize handles:

<.resize_handles :if={context.view.resizable?} />

Or render one handle yourself:

<button {resize_handle_attrs(:se)} aria-label="Resize" />

Read context.view.resizable? instead of rechecking item capabilities in the application. The render view already answers whether resize should be offered.

Move Or Resize From Server Code

Use LivePanels.Canvas.Item when an application event or app process changes item placement:

socket = LivePanels.Canvas.Item.move_by!(socket, "note-1", {24, 0})
socket = LivePanels.Canvas.Item.move_to!(socket, "note-1", {120, 80})
socket = LivePanels.Canvas.Item.resize_by!(socket, "note-1", {40, 20})
socket = LivePanels.Canvas.Item.resize_to!(socket, "note-1", {420, 300})

For exact placement, use place/3:

socket =
  LivePanels.Canvas.Item.place!(socket, "note-1", %{
    space: :world,
    anchor: :top_left,
    x: 120,
    y: 80,
    w: 420,
    h: 300
  })

Do not update item placement by assigning your own item list. Commands are what connect validation, history, persistence, shared mode, and projection.

Select Items

Use selection attrs for browser controls:

<button {select_item_attrs(item)}>Select</button>
<button {toggle_item_selection_attrs(item)}>Toggle</button>
<button {clear_selection_attrs()}>Clear</button>
<button {select_all_attrs()}>Select all</button>

Use selection commands from server code:

socket = LivePanels.Canvas.Selection.select!(socket, "note-1")
socket = LivePanels.Canvas.Selection.include!(socket, ["note-1", "note-2"])
socket = LivePanels.Canvas.Selection.toggle!(socket, "note-1")
socket = LivePanels.Canvas.Selection.clear!(socket)

Selection is actor-scoped. In a shared canvas, each participant can have a different selection unless your app chooses to coordinate selection in product state.

The render context exposes selection for the current actor:

<section class={["item-frame", context.selected? && "is-selected"]}>
  {context.content}
</section>

Use Marquee Selection

Put marquee attrs on the world or viewport region where the gesture should start:

<div {marquee_selection_attrs(:world, class: "selection-plane")}></div>

The browser renders the marquee gesture. The server accepts the resulting selection command.

Use graph-specific selection helpers when the marquee should include connectors as well as items.

Focus Items

Focus is the current actor's active item and usually promotes the item in the canvas stack. It is separate from selection.

socket = LivePanels.Canvas.Item.focus!(socket, "note-1")

The slot item exposes focus:

<article class={["item-frame", context.focused? && "is-focused"]}>
  {context.content}
</article>

Use focus_exempt_attrs/1 on controls inside an item frame that should not change item focus when clicked:

<button {focus_exempt_attrs()} phx-click="open-menu">Menu</button>

Set interaction: %{promote_on_focus: false} on item types or item creation opts when focus should preserve the current stack order. The item still becomes active, selection helpers still work, and focus-loss attachments still dismiss; only automatic z-order promotion is skipped. Use the z-order commands below when the user explicitly reorders layers.

Reorder Selected Items

Use z-order commands for selected items:

socket = LivePanels.Canvas.Selection.z_order!(socket, :front)
socket = LivePanels.Canvas.Selection.z_order!(socket, :back)
socket = LivePanels.Canvas.Selection.z_order!(socket, :forward)
socket = LivePanels.Canvas.Selection.z_order!(socket, :backward)

From markup:

<button {z_order_selection_attrs(:front)}>Bring front</button>
<button {z_order_selection_attrs(:back)}>Send back</button>

LivePanels keeps z-order in canvas state so projection, persistence, shared mode, graph connectors, and vector items can stay consistent.

Align, Distribute, And Layout

Selection layout helpers operate on visible world-space items:

<button {align_selection_attrs(:left)}>Align left</button>
<button {distribute_selection_attrs(:horizontal)}>Distribute</button>
<button {layout_attrs(:grid)}>Grid</button>
<button {layout_attrs(:skyline_pack)}>Compact</button>

Server equivalents:

socket = LivePanels.Canvas.Selection.align!(socket, :left)
socket = LivePanels.Canvas.Selection.distribute!(socket, :horizontal)
socket = LivePanels.Canvas.Arrange.layout!(socket, :grid)
socket = LivePanels.Canvas.Arrange.layout!(socket, :skyline_pack, preserve_size: true)

These are still canvas commands. They do not bypass policy, history, or shared coordination.

Canvas layout strategies are headless planning contracts. Canvas.Arrange submits commands, while Canvas.Layout exposes planning adapters for external engines:

socket = LivePanels.Canvas.Arrange.layout!(socket, :masonry, scope: :visible, gap: 12, columns: :auto)
socket = LivePanels.Canvas.Arrange.layout!(socket, :master_stack, master: :active, ratio: 0.62, stack: :right)
socket = LivePanels.Canvas.Arrange.layout!(socket, :ordered_treemap, weight: :priority)

adapter = LivePanels.Canvas.Layout.adapter(state, :visible, viewport: viewport)
commands = LivePanels.Canvas.Layout.adapter_commands(state, external_result)
strategies = LivePanels.Canvas.Layout.registered_strategies(state)

Built-in strategy keys include :horizontal, :vertical, :grid, :cascade, :masonry, :fit_rows, :fit_columns, :skyline_pack, :master_stack, :bsp_balance, :bsp_split, and :ordered_treemap. Browser controls send the registered strategy key plus params. Server code may also pass a module that implements LivePanels.Canvas.Layout.Strategy; browser payloads cannot name modules. Custom browser-visible strategy keys come from canvas extensions:

LivePanels.Canvas.Extension.new!(:workflow,
  authorize: MyApp.CanvasAuthorize,
  apply: MyApp.CanvasApply,
  commands: %{},
  layout_strategies: %{workflow_board: MyApp.WorkflowBoardLayout}
)

Keyboard Scope

Put keyboard_scope_attrs/1 on the element that should receive canvas keyboard behavior:

<main {keyboard_scope_attrs(class: "board-page")}>
  ...
</main>

Use focus exemptions for inputs, menus, and controls where ordinary browser or app keyboard behavior should win.

Input Policy

Use one runtime input policy when an app needs to change wheel, pointer, or keyboard behavior:

input = %{
  wheel: %{bare: :z, shift: :x, command: :y},
  drag: %{
    primary: :mode,
    middle: :pan,
    secondary: :host,
    space_primary: :pan,
    space_middle: :pan,
    space_secondary: :pan
  },
  keyboard: %{
    nudge: %{base: :snap_grid, large_multiplier: 4},
    overrides: %{
      "Space" => :temporary_pan,
      "+" => :zoom_in,
      "-" => :zoom_out,
      "0" => :reset_zoom
    }
  }
}

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

For markup composition, emit the same contract as flat attrs:

<.canvas_runtime
  {canvas_runtime_attrs(assigns)}
  {input_attrs(
    wheel_bare: :z,
    wheel_shift: :x,
    wheel_command: :y,
    drag_secondary: :pan,
    keyboard_nudge_large_multiplier: 4
  )}
/>

Wheel dimensions are :x, :y, :z, :none, and :host. :z zooms around the pointer, :x and :y pan the camera, :none consumes the gesture, and :host leaves it to application code. The shipped wheel mapping is bare :z, Shift :x, and Ctrl/Cmd :y.

Pointer drag actions are :mode, :pan, :none, and :host. The shipped drag mapping is primary :mode, middle :pan, secondary :host, and Space plus any of those buttons :pan. :mode follows the active cursor mode so draw, erase, marquee, item drag, resize, and drop interactions keep priority over camera pan.

Keyboard overrides replace or add chords by public runtime action name. The runtime ships Escape cancel, Ctrl/Cmd undo-redo-copy-cut-paste-duplicate-select, Delete/Backspace remove, arrow-key nudging, V/H/M select-pan-marquee, Space temporary pan, +/-/0/Home/F/Shift+F camera actions, bracket z-order actions, and the drawing tool keys 1 through 9, P, Shift+P, A, L, R, O, D, and E.

The JS entrypoint exports LIVEPANELS_INPUT_DEFAULTS for hosts that need to inspect the shipped keyboard.shortcuts and keyboard.reservedButUnbound tables before applying overrides.

What LivePanels Owns

LivePanels owns the accepted item frame, selection set, focus target, z-order, and command validation.

The browser owns transient previews. Your application owns where handles and controls appear and how focused or selected items look.