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 │
└─────────────────────────────────────────────────────┘
Add :fresco (the viewer) and :etcher to your mix.exs:
def deps do
[
{:fresco, "~> 0.2"},
{:etcher, "~> 0.1"}
]
endWire 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.migrateAnd 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.)
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
endOpen 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.
<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>". |
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.
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)
# ...
endEtcher'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.
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.
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) |
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.
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.
All extension points beyond the LiveView events listed above. None are required — Etcher works with zero configuration.
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.
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.
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 }, ...]
}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 |
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) |
Stable helper exposed for use inside consumer slot functions. HTML-escapes &, <, >, ", '.
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.
- 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.
MIT. See LICENSE.