Etcher.Layer (etcher v0.12.0)

Copy Markdown View Source

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.

Summary

Functions

Mounts an Etcher annotation layer onto a named Fresco canvas.

Functions

layer(assigns)

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.