Etcher
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: ...
endThen 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>`
};-
Slots are functions
(shape) => string | null. -
Returning
nullorundefinedfalls back to Etcher's default for that slot. An empty return forbody/footeromits the row entirely. -
The whole
shapeobject is passed ({uuid, kind, geometry, style, metadata, …}) so consumers can build whatever HTML their data supports. - Etcher controls the wrapper, positioning, hover bridge, click-to-pin, and the trash button — slots only own content. This keeps delete + pin behavior consistent across consumers.
window.Etcher.escapeHtml(value)is exposed as a stable escape helper.
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:
.etcher-tooltip-body— flex row, thumbnail on the left, text column on the right (gap: 8px,max-width: 260px).etcher-tooltip-thumb— 40×40 rounded box for an<img>or icon span.etcher-tooltip-thumb-icon— modifier that centers an SVG icon inside the thumb box (paperclip-style fallback).etcher-tooltip-text— flex column container for the right-hand text.etcher-tooltip-quote— italic, two-line clamp for a quoted text preview
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:selected {uuid}on click (also pins the tooltip)etcher:deleted {uuid}when the user hits the trash button
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)
- Editing existing shapes after commit (drag handles, vertex move). v0.1 is draw-and-commit; to change a shape, delete and redraw.
- Touch + pinch gesture coexistence with Fresco's pan/zoom — annotation mode currently disables Fresco's drag-to-pan; refinement comes later.
- Custom tools beyond the four built-ins. The geometry kind is a string, so adding a new kind is straightforward; the toolbar wiring isn't pluggable yet.
- Annotation export / import in W3C Web Annotation Data Model JSON-LD.
License
MIT. See LICENSE.