# `Etcher.Layer`
[🔗](https://github.com/alexdont/etcher/blob/v0.12.1/lib/etcher/layer.ex#L1)

Phoenix LiveView function component that attaches Etcher's annotation
overlay to a named `<Fresco.canvas>` or `<Fresco.scroll_strip>`.

The component renders a hidden host `<div phx-hook="EtcherLayer">`. The
client-side hook:

  * Looks up the named Fresco viewer via `window.Fresco.onReady/2`
  * Detects whether the handle is a `<Fresco.canvas>` (single canvas-
    pixel coordinate space, exposes `getCanvasSize`) or a
    `<Fresco.scroll_strip>` (per-image natural pixel coordinates,
    exposes `scrollTo` + `getImages`) and routes to the matching
    renderer
  * Appends a pencil button to Fresco's nav column via
    `handle.appendNavButton/3`
  * On pencil click, opens a bottom toolbar with the configured
    drawing tools and toggles annotation mode
  * Draws shapes as SVG overlays — one canvas-spanning SVG in canvas
    mode, one per-image SVG sibling in strip mode
  * Hydrates initial annotations from `handle.getExtension("etcher")`
  * Pushes `etcher:annotations-changed` events to the consumer's
    LiveView with the full annotations array on every commit / edit
    / delete

## Canvas mode

    <Fresco.canvas id="board" canvas={@canvas} class="w-full h-screen" />

    <Etcher.layer
      fresco_id="board"
      tools={[:grabber, :rectangle, :circle, :polygon, :freehand, :marker, :callout, :text, :dimension, :eraser]}
    />

## Strip mode

    <Fresco.scroll_strip id="reader" sources={@pages} extensions={@reader_extensions} />

    <Etcher.layer fresco_id="reader" tools={[:rectangle, :freehand, :text]} />

The component is identical in both cases — `fresco_id` is the only
thing that changes. Strip-mode annotations carry an extra `image_idx`
field on each shape identifying which image they live on.

## The event your LiveView must handle (canvas)

    def handle_event("etcher:annotations-changed", %{"annotations" => annotations}, socket) do
      new_canvas =
        Fresco.Canvas.put_extension(socket.assigns.canvas, "etcher", %{
          "version" => "1",
          "annotations" => annotations
        })

      {:noreply, assign(socket, canvas: new_canvas)}
    end

## The event your LiveView must handle (strip)

Strip-mode annotations carry `image_idx` (which page they're on).
Strip viewers don't have a `%Fresco.Canvas{}` struct or a
`put_extension/3` helper — the consumer just maintains the
`:extensions` map (a plain `%{}`) in socket assigns and threads it
through the component:

    def handle_event("etcher:annotations-changed", %{"annotations" => annotations}, socket) do
      extensions =
        Map.put(socket.assigns.reader_extensions, "etcher", %{
          "version" => "1",
          "annotations" => annotations
        })

      {:noreply, assign(socket, reader_extensions: extensions)}
    end

When a LiveView hosts multiple Etcher layers, the
`"etcher:annotations-changed"` payload includes a `"fresco_id"` key
so consumers can pattern-match on the source:

    def handle_event(
          "etcher:annotations-changed",
          %{"fresco_id" => "reader", "annotations" => annotations},
          socket
        ) do
      # ...
    end

UUIDs are generated client-side (UUIDv7) in both modes, so there's no
tmp_id ⇄ real-uuid round-trip — the server never has to assign ids.

## Color slots and the `etcher:colors-changed` hook

The toolbar shows 5 fixed, editable color slots. Clicking a slot selects
it; opening the hue picker and choosing a color overwrites the selected
slot in place. Each committed edit reports the full palette through two
channels (mirroring `etcher:annotations-changed` + the JS lifecycle
events) — Etcher itself persists nothing:

  * a LiveView event your `handle_event/3` can store, and
  * a bubbling DOM `CustomEvent` for pure-JS consumers.

Seed the slots with the `:colors` attr (per-user palette) or via
`extensions["etcher"]["colors"]`; the save/load is generic so the same
hook can target a `.fresco`/extension blob here or a per-user store
(e.g. phoenix_kit `custom_fields`) in a host app.

    def handle_event(
          "etcher:colors-changed",
          %{"fresco_id" => "board", "colors" => colors},
          socket
        ) do
      # `colors` is a list of "#rrggbb" strings. Persist however you
      # like — alongside annotations in the canvas extension, or in the
      # current user's metadata. Round-trip it back through `:colors`
      # (or the extension) on the next mount.
      etcher = Map.put(socket.assigns.canvas.extensions["etcher"] || %{}, "colors", colors)
      {:noreply,
       assign(socket,
         canvas: Fresco.Canvas.put_extension(socket.assigns.canvas, "etcher", etcher)
       )}
    end

Programmatic control mirrors the colors: `layer.getColors()`,
`layer.setColors([...])`, `layer.setSlotColor(i, "#rrggbb")` on the
`window.Etcher.layerFor(id)` handle.

## Line params and the `etcher:line-params-changed` hook

The Parameters popup (thickness / opacity / dash) sets the global default
new strokes inherit. With **no shape selected**, committing a slider or
picking a dash reports the new default through the same two channels as
the colors hook, so you can persist per-user "ink":

    def handle_event(
          "etcher:line-params-changed",
          %{"fresco_id" => "board", "line_params" => lp},
          socket
        ) when is_map(lp) do
      # `lp` is %{"width" => n, "opacity" => n, "dash" => "solid"|"dashed"|
      # "dotted"}. Persist it (e.g. in the current user's metadata) and feed
      # it back through the `:line_params` attr on the next mount.
      {:noreply, socket}
    end

Seed the default with the `:line_params` attr; missing keys fall back to
the built-ins (`width: 2`, `opacity: 1`, `dash: "solid"`). Editing a
*selected* shape's style instead keeps flowing through
`etcher:annotations-changed` (it's saved with the shape) and does **not**
fire this event. Programmatic: `layer.getLineParams()` /
`layer.setLineParams(%{...})` (the latter doesn't echo the event).

## Tools

Configure which drawing tools appear in the bottom toolbar. The default
exposes the navigation grabber, all drawing kinds, and the eraser:

    tools={[:grabber, :rectangle, :circle, :polygon, :freehand, :marker, :callout, :text, :dimension, :line, :eraser]}

Subsetting hides specific tools (e.g. only `:rectangle, :freehand`).
Drop `:eraser` if you don't want users deleting from the toolbar, or
`:grabber` if pan-only mode isn't needed.

## Annotation hydration

Initial annotations come from the viewer's `extensions.etcher` map.
The consumer's `mount/3` typically reads a `.fresco` file (canvas mode):

    canvas = Fresco.Canvas.read!("/path/to/scene.fresco")
    {:ok, assign(socket, canvas: canvas)}

…or assigns the strip's extension map directly (strip mode):

    extensions = %{
      "etcher" => %{
        "version" => "1",
        "annotations" => [
          %{"uuid" => "01HXY...", "kind" => "rectangle",
            "geometry" => %{"x" => 100, "y" => 200, "w" => 50, "h" => 50},
            "image_idx" => 2}
        ]
      }
    }
    {:ok, assign(socket, pages: @pages, reader_extensions: extensions)}

Either way `<Etcher.layer>` reads the annotations through Fresco's
handle at mount time and renders each shape on the matching page.

## Coordinate spaces

Shape geometry is stored in the **same coordinate system the host
Fresco component reports for that image**, never as a normalized
fraction. Two cases:

  * **Strip mode** (`<Fresco.scroll_strip>` / `<FrescoStrip.viewer>`)
    — geometry is in **source-pixel space**: a circle's `cx, cy, r`,
    a polygon's `points[].x/y`, are integers in the image's own
    natural-pixel grid (e.g. a 720×9200 page uses 0..720 / 0..9200).
    Independent of the rendered display size, so shapes survive
    strip width changes without recomputation.
  * **Canvas mode** (`<Fresco.canvas>`) — geometry is in **canvas-
    pixel space**: a unified stage spanning all images in the scene,
    sized in canvas-internal pixels (the dimensions returned by the
    canvas handle's `getCanvasSize()`). A single coord pair can
    address any image since they're all laid out on the same stage.

Consumers that scroll to a shape, render mini-maps, or persist
shape positions outside of Etcher should know which space they're
in. `layer.getShape(uuid)` returns the shape descriptor with
either `image_idx` (strip) or `image_id` (canvas multi-image)
attached so routing UI doesn't need to scrape DOM data-attrs.

## Z-order (arrange)

Shapes paint in list order, so the newest covers everything drawn before
it — an image pasted after a caption hides it. The four arrange actions
move the current selection (the multi-selection when there is one, else
the shape in edit mode) through that order:

    layer.bringToFront();   // ⌘] — past everything
    layer.bringForward();   // ]  — one step
    layer.sendBackward();   // [  — one step
    layer.sendToBack();     // ⌘[ — behind everything

Each returns `true` when something actually moved and `false` at the edge
or with nothing selected, so external buttons can reflect that;
`layer.canArrange()` answers the same question without moving anything.
The same four sit in the toolbar's `[⋯]` overflow, disabled until
something is selected — they act on a selection rather than being drawing
modes, so they belong with undo/redo rather than in `:tools`.

Reordering is undoable and re-emits `etcher:annotations-changed` with the
new order, so a host that persists the annotation list persists the
layering with it, and collaborators receive it like any other edit.

`layer.setShapeOrder(uuids)` forces an explicit order — shapes missing
from the list keep their relative position at the end. It deliberately
records no undo entry and emits nothing, because it exists for applying an
order that arrived from somewhere authoritative (a peer, a server load),
where echoing it back would loop.

### Reaching a buried shape

A pointer event only ever reaches the topmost shape under it, which would
leave anything fully covered impossible to select — and so impossible to
arrange out from underneath. Tapping the same spot again therefore walks
*down* the stack instead of re-selecting the top, putting the covered
shape two taps away. Moving more than ~14px, or pausing longer than 1.5s,
starts a fresh cycle.

## Programmatic API

Each mounted layer registers a handle on `window.Etcher.layerFor(id)`:

    var layer = window.Etcher.layerFor("reader");
    layer.setMode(true);                       // enter annotation mode
    layer.selectTool("rectangle");
    layer.deleteShape("01HXY...");

    // Shape descriptors include image_idx (strip) / image_id (canvas):
    var shape = layer.getShape("01HXY...");
    // → { uuid, kind, geometry, style, metadata,
    //     image_idx?: 4,         // strip mode
    //     image_id?: "page-5" }  // canvas multi-image

    // Reveal a shape — Promise-returning, polls for late-mounted
    // shapes, optional pulse flash, fires `etcher:shape-revealed`.
    layer.revealShape("01HXY...", { pulse: true })
      .then(function (r) { console.log("revealed on image", r.image_idx); })
      .catch(function (e) { console.warn(e.reason); });

    // Hit-test a point against the current shapes. Returns the
    // top-most shape descriptor (uuid, kind, geometry, image_idx /
    // image_id, …) under `pt`, or null. Use when wiring custom
    // tap-zone navigation so the tap can be ignored when it lands
    // on an annotation:
    //   const hit = layer.shapeAt({ imageIdx: 2, x: 540, y: 920 });
    //   if (hit) return; // let etcher handle it
    // Strip handles: pt = { imageIdx, x, y } in source-pixel space.
    // Canvas handles: pt = { x, y } in canvas-pixel space.

See `priv/static/etcher.js` for the full API surface.

# `layer`

Mounts an Etcher annotation layer onto a named Fresco canvas.

Renders a hidden `<div phx-hook="EtcherLayer">` that hosts the JS
engine; the visible UI (pencil nav button + bottom toolbar + SVG
shapes) is created by the hook on top of the Fresco canvas.

## Attributes

* `fresco_id` (`:string`) (required) - DOM id of the `<Fresco.canvas>` this layer attaches to.
* `id` (`:string`) - Optional DOM id for the layer host element; defaults to `"etcher-layer-<fresco_id>"`. Defaults to `nil`.
* `tools` (`:list`) - Subset of tools to show in the toolbar. Drawing tools: `:grabber`,
  `:rectangle`, `:circle`, `:polygon`, `:freehand`, `:marker`, `:callout`,
  `:text`, `:dimension`, `:line`, `:eraser`. Add `:image` for the image
  tool — a one-shot action (not a drawing mode) that inserts an image via
  the OS file picker or a host uploader (see `:image_source`).

  `:pointer` is the red pointer: while it is armed the user's position is
  drawn as a red dot with a trail and emitted as `etcher:pointer-moved`,
  for a host to relay to everyone else on the board. It draws nothing and
  stores nothing. See "Red pointer" below.

  Defaults to `[:grabber, :rectangle, :circle, :polygon, :freehand, :marker, :callout, :text, :dimension, :eraser, :pointer]`.
* `image_source` (`:atom`) - What the `:image` toolbar tool does when clicked:

  - `:file_picker` (default) — open the OS file picker and insert the
    chosen file as a `data:` URL. Zero host code.
  - `:custom` — emit `etcher:image-insert-requested` (a LiveView hook
    event **and** a bubbling DOM `CustomEvent`) instead of picking a file.
    The host opens its own uploader / media modal, then calls
    `window.Etcher.layerFor(fresco_id).insertImage(href, opts)` with the
    resulting URL. `opts` accepts `at: {x, y}` (image coords, default
    viewport center) and `width`/`height` or `maxSide`.

  Defaults to `:file_picker`. Must be one of `:file_picker`, or `:custom`.
* `paste_images` (`:boolean`) - When `true` (default), an image pasted onto the canvas (⌘/Ctrl-V) is
  inserted automatically at the viewport center — no `:image` tool
  required. Pastes into a focused text field (including Etcher's own text
  editor) are left alone. Set `false` to disable paste-to-insert.

  Defaults to `true`.
* `colors` (`:list`) - Optional seed for the toolbar's fixed, editable color slots — a list
  of `"#rrggbb"` strings (clamped/backfilled to 5). Supply the signed-in
  user's saved palette here for per-user colors.

  When omitted, the slots seed from `extensions["etcher"]["colors"]`
  (the same JSON the annotations ride in), then the preset palette.

  Edits are reported via the `etcher:colors-changed` event (see below);
  persist them wherever you keep per-user data and feed them back through
  this attr (or the extension) on the next mount.

  Defaults to `nil`.
* `line_params` (`:map`) - Optional seed for the global stroke defaults new shapes inherit —
  `%{"width" => number, "opacity" => number, "dash" => "solid" | "dashed"
  | "dotted"}`. Any missing key falls back to the built-in default
  (`width: 2`, `opacity: 1`, `dash: "solid"`). Supply the signed-in user's
  saved line params here for per-user ink.

  When omitted, the built-in defaults apply (identical to prior behavior).
  Edits made via the Parameters popup with no shape selected are reported
  through the `etcher:line-params-changed` event; persist them per user and
  feed them back through this attr on the next mount. (Per-shape style edits
  keep flowing through `etcher:annotations-changed`.)

  Defaults to `nil`.
* `nav_buttons` (`:list`) - Allowlist of Etcher's nav-column buttons (appended to Fresco's
  nav). Atom list: `[:pencil, :visibility]`.

  - `nil` (default) — both enabled.
  - `[]` — both hidden. Consumers shipping their own chrome wire
    `handle.toggleMode()` / `handle.toggleVisible()` (or
    `setMode(true|false)` / `setVisible(true|false)`) to their own
    buttons / shortcuts.
  - A subset list — only those buttons render.

  Defaults to `nil`.
* `toolbar` (`:boolean`) - Whether to render the bottom toolbar (cursor + drawing tools +
  undo/redo + color picker + close). `false` hides it entirely;
  annotation mode still works programmatically — consumers wire
  their own toolbar UI to `handle.selectTool(...)` /
  `handle.selectColor(...)` / `handle.undo()` / `handle.redo()`
  / `handle.setMode(false)` (close).

  Defaults to `true`.
* `connectors` (`:boolean`) - Host default for the connector anchors — the eight bindable dots
  that appear on shape hover for drawing arrows between shapes.
  Off by default: they are an affordance for boards where people
  draw connectors, and everywhere else they are eight dots that
  appear under the cursor on every shape passed over. The user's
  own toggle (persisted through the prefs mechanism) always wins
  over this default, in either direction.

  Defaults to `false`.
* `snap` (`:boolean`) - Host default for drag snapping — dragged shapes magnetizing to
  the edges and centers of their neighbours, with alignment guide
  lines. Off by default: the pull is an acquired taste. The user's
  own toggle (persisted through the prefs mechanism, next to the
  grid and connector toggles) always wins over this default, in
  either direction; ⌘/Ctrl held during a drag bypasses the snap
  even when it is on.

  Defaults to `false`.
* Global attributes are accepted.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
