# `Etcher.Layer`
[🔗](https://github.com/alexdont/etcher/blob/v0.6.6/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.

## 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 drawing tools to show in the toolbar. Defaults to `[:grabber, :rectangle, :circle, :polygon, :freehand, :marker, :callout, :text, :dimension, :eraser]`.
* `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`.
* Global attributes are accepted.

---

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