Etcher

Hex.pmHex DocsLicense

Etcher is the annotation layer for Fresco-based image viewers in Phoenix.

Users draw shapes (rectangle, circle, polygon, freehand) on top of any Fresco viewer; your LiveView receives geometry events; you decide what to persist. A bundled Ecto schema + migration generator covers the common case; consumers with richer needs implement a behaviour and plug in their own storage.

An etcher is the tool that incises marks into a surface — Etcher does the same digitally.

┌─────────────────────────────────────────────────────┐
│  <Fresco.viewer id="photo" src="/uploads/img.jpg"/> │
│   ┌──┐                                              │
│   │+ │  ← fresco's nav column                       │
│   │- │                                              │
│   │⟲ │                                              │
│   │⛶ │                                              │
│   │✎ │  ← added by <Etcher.layer />                 │
│   └──┘                                              │
│                                                     │
│         ┌───┐  ┌────────┐                           │
│         │   │  │        │   ← drawn annotations     │
│         │   │  │        │                           │
│         └───┘  └────────┘                           │
│                                                     │
│         [⌖] [▭] [○] [⬡] [〰] [×]   ← bottom toolbar  │
└─────────────────────────────────────────────────────┘

Installation

Add :fresco (the viewer) and :etcher to your mix.exs:

def deps do
  [
    {:fresco, "~> 0.2"},
    {:etcher, "~> 0.1"}
  ]
end

Wire the JS hooks in your assets/js/app.js:

import "../../deps/fresco/priv/static/fresco.js"
import "../../deps/etcher/priv/static/etcher.js"

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { ...window.FrescoHooks, ...window.EtcherHooks, ...colocatedHooks }
})

If you want the bundled etcher_annotations table, run:

mix etcher.gen.migration
mix ecto.migrate

And point Etcher at your Repo in config/config.exs:

config :etcher, repo: MyApp.Repo

(You can skip both steps if you're implementing custom storage — see below.)

Quick start

defmodule MyAppWeb.PhotoLive do
  use MyAppWeb, :live_view

  def render(assigns) do
    ~H"""
    <Fresco.viewer id="photo" src={~p"/uploads/photo.jpg"} class="w-full h-[80vh]" />

    <Etcher.layer
      fresco_id="photo"
      target_type="file"
      target_uuid={@file.uuid}
      initial_annotations={@annotations}
    />
    """
  end

  def handle_event("etcher:created", attrs, socket) do
    case Etcher.create_annotation(Map.put(attrs, "creator_uuid", socket.assigns.current_user.uuid)) do
      {:ok, annotation} ->
        # Reflect the persisted uuid back to the client so subsequent
        # updates/deletes can reference the saved row.
        {:noreply,
         push_event(socket, "etcher:annotation-saved", %{
           tmp_id: attrs["tmp_id"],
           uuid: annotation.uuid
         })}

      {:error, _changeset} ->
        {:noreply, put_flash(socket, :error, "Could not save annotation")}
    end
  end

  def handle_event("etcher:selected", %{"uuid" => uuid}, socket) do
    {:noreply, assign(socket, :selected_annotation_uuid, uuid)}
  end
end

Open the page, click the pencil in Fresco's nav column → the bottom toolbar appears with the four drawing tools. Pick rectangle, drag on the image, release — handle_event("etcher:created", …) fires with the geometry in image pixel coordinates.

The component

<Etcher.layer
  fresco_id="photo"
  target_type="file"
  target_uuid={@file.uuid}
  initial_annotations={@annotations}
  tools={[:rectangle, :circle, :polygon, :freehand]}
/>
Attr Required Notes
fresco_id yes DOM id of the <Fresco.viewer> this layer attaches to.
target_type yes What the annotation is on — "file", "document", "product", etc. Echoed back in every event.
target_uuid yes UUID of the resource being annotated.
initial_annotations no Pre-existing annotations to render on mount. Each needs at least :uuid, :kind, :geometry.
tools no Subset of drawing tools to expose. Defaults to all four.
id no DOM id of the layer host element. Defaults to "etcher-layer-<fresco_id>".

Events

The component emits four LiveView events. The consumer's LiveView handles whichever ones it cares about.

def handle_event("etcher:created", attrs, socket), do: ...
def handle_event("etcher:updated", %{"uuid" => uuid, "geometry" => geom}, socket), do: ...
def handle_event("etcher:deleted", %{"uuid" => uuid}, socket), do: ...
def handle_event("etcher:selected", %{"uuid" => uuid}, socket), do: ...

The etcher:created payload includes:

%{
  "target_type" => "file",
  "target_uuid" => "...",
  "kind" => "rectangle" | "circle" | "polygon" | "freehand",
  "geometry" => %{ ... },              # shape-specific, image-pixel coords
  "tmp_id" => "tmp-abc123-..."          # client-side temp id
}

After persisting, push back the saved uuid so the client can adopt it:

push_event(socket, "etcher:annotation-saved", %{tmp_id: tmp_id, uuid: annotation.uuid})

Geometry shapes:

kind geometry
rectangle%{"x" => x, "y" => y, "w" => w, "h" => h}
circle%{"cx" => cx, "cy" => cy, "r" => r}
polygon%{"points" => [[x1, y1], [x2, y2], ...]}
freehand%{"points" => [[x1, y1], [x2, y2], ...]}

All coordinates are in image pixels — Fresco's pan/zoom rescales them automatically.

Custom storage

Etcher.Storage is a behaviour. The default implementation is fine for most consumers, but you can swap in your own — useful when annotations need to be linked to other tables (comments, notifications, audit trails) inside the same transaction.

defmodule MyApp.AnnotationStorage do
  @behaviour Etcher.Storage

  alias MyApp.Repo
  alias MyApp.{Annotation, Comment}

  def create(attrs) do
    Repo.transaction(fn ->
      {:ok, comment} = %Comment{}
                       |> Comment.changeset(%{kind: "annotation", author_uuid: attrs.creator_uuid})
                       |> Repo.insert()

      {:ok, annotation} = %Annotation{}
                          |> Annotation.changeset(Map.put(attrs, :comment_uuid, comment.uuid))
                          |> Repo.insert()

      annotation
    end)
  end

  def list_for(target_type, target_uuid), do: ...
  def update(uuid, attrs), do: ...
  def delete(uuid), do: ...
end

Then in your LiveView:

def handle_event("etcher:created", attrs, socket) do
  {:ok, annotation} = MyApp.AnnotationStorage.create(attrs)
  # ...
end

Etcher's component doesn't run any persistence itself — it fires events and trusts the consumer. The bundled Etcher.create_annotation/1 is just a shortcut for Etcher.Storage.Default.create/1.

Customizing the tooltip

Hovering or clicking an annotation pops up a small tooltip with a trash button (for persisted shapes) and three content slots: header, footer, and body. The defaults read a few generic metadata keys and degrade to just the shape kind if those are absent, but a consumer can replace any slot with its own rendering by setting window.Etcher.tooltipSlots:

window.Etcher.tooltipSlots = {
  header: (shape) => Etcher.escapeHtml(shape.metadata.author || shape.kind),
  footer: (shape) => shape.metadata.last_edited || null,
  body:   (shape) => `<p>${Etcher.escapeHtml(shape.metadata.note || "")}</p>`
};

Default slot keys

If you don't register custom slots but want a meaningful tooltip, populate these on each annotation's metadata (server-side, in initial_annotations):

Slot Read from Fallback
header metadata.title capitalized shape.kind
body metadata.body (none — row omitted)
footer metadata.subtitle (none — row omitted)

Styling primitives

Etcher's stylesheet ships a handful of opt-in classes consumers can use inside their slot HTML for a layout consistent with the default look:

These are entirely optional. A slot that just returns <p>plain text</p> lays out fine without any of them.

Lifecycle events

Slot APIs cover content. For interaction wiring the existing LiveView events still fire:

etcher:tooltip-show / -hide / -pin events would be a natural follow-up if a consumer needs them; not in v0.1.

Hooks reference

All extension points beyond the LiveView events listed above. None are required — Etcher works with zero configuration.

window.Etcher.colorSwatches — palette override

Replace the bundled pastel rainbow + monochrome bookends with your own swatches:

window.Etcher.colorSwatches = [
  { key: "brand",   color: "#ff6f00", title: "Brand orange" },
  { key: "muted",   color: "#9ca3af", title: "Muted gray" },
  { key: "ink",     color: "#0f172a", title: "Ink" }
];

Falls back to the default palette if unset or not an array.

window.Etcher.defaultColor — initial active color

Override which swatch starts pre-selected when annotation mode opens:

window.Etcher.defaultColor = "#ff6f00";

Falls back to the "blue" swatch in the active palette (back-compat) or the first swatch.

window.Etcher.layerFor(frescoId) — programmatic control

Returns the layer's control surface, or null if no layer is mounted for that fresco id. Lets you drive Etcher from outside (URL handlers, keyboard shortcuts, command palettes):

const layer = window.Etcher.layerFor("photo");
if (layer) {
  layer.setMode(true);           // enter annotation mode (toolbar opens)
  layer.exitDrawing();           // back to cursor (annotation mode stays on)
  layer.selectShape("uuid-…");   // pin the tooltip for that shape
  const shapes = layer.getShapes();
  // → [{ uuid, kind, geometry, style, metadata }, ...]
}

Lifecycle DOM events

Etcher dispatches bubbling CustomEvents on the layer's host element so consumer JS can react without reaching into the hook. Listen on the host or any ancestor:

document.addEventListener("etcher:tooltip-show", (e) => {
  console.log("Tooltip showing for", e.detail.uuid, "at", e.detail.anchor);
});
Event detail When
etcher:tooltip-show{ uuid, anchor: {x, y} } Tooltip rendered (hover or pin)
etcher:tooltip-hide{ uuid } Tooltip closes (hover-away timeout or pin dismissed)
etcher:tooltip-pin{ uuid } User clicked a shape to pin its tooltip
etcher:tooltip-unpin{ uuid } User clicked elsewhere / re-clicked to unpin
etcher:mode-changed{ annotationMode: bool } User toggled annotation mode
etcher:tool-changed{ tool: string | null } User picked a drawing tool (null = cursor)
etcher:color-changed{ color: string } User picked a swatch

Server → client LiveView events

In addition to the create / update / delete / selected client→server events documented above, the server can push state into a running viewer via Phoenix.LiveView.push_event/3:

Event Payload Behavior
etcher:annotation-saved{ tmp_id, uuid } Client adopts the persisted uuid for a temp shape
etcher:annotation-added{ uuid, kind, geometry, style?, metadata? } Renders a new shape locally (collaboration / external create)
etcher:annotation-updated{ uuid, metadata } Merges fresh tooltip metadata into an existing shape
etcher:annotation-removed{ uuid } Removes a shape from the overlay
etcher:exit-drawing{} Switches to cursor mode (annotation mode stays on)

window.Etcher.escapeHtml(value) — escape helper

Stable helper exposed for use inside consumer slot functions. HTML-escapes &, <, >, ", '.

How it fits with Fresco

Etcher uses Fresco 0.2's handle.appendNavButton/3 extension point to add the pencil button — no other extension surface required. Drawing input is delivered as plain pointerdown / pointermove / pointerup events on an SVG overlay anchored to Fresco's image coordinate space, so shapes stay locked to image pixels through pan and zoom.

Out of scope (for now)

License

MIT. See LICENSE.