# Etcher
[](https://hex.pm/packages/etcher)
[](https://hexdocs.pm/etcher)
[](LICENSE)
**Etcher** is the annotation layer for [Fresco](https://hex.pm/packages/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`:
```elixir
def deps do
[
{:fresco, "~> 0.2"},
{:etcher, "~> 0.1"}
]
end
```
Wire the JS hooks in your `assets/js/app.js`:
```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:
```bash
mix etcher.gen.migration
mix ecto.migrate
```
And point Etcher at your Repo in `config/config.exs`:
```elixir
config :etcher, repo: MyApp.Repo
```
(You can skip both steps if you're implementing custom storage — see below.)
## Quick start
```elixir
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
```heex
<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.
```elixir
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:
```elixir
%{
"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:
```elixir
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.
```elixir
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:
```elixir
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`:
```js
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 `null` or `undefined` falls back to Etcher's default for that slot. An empty return for `body` / `footer` omits the row entirely.
- The whole `shape` object 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:
```js
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:
```js
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):
```js
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 `CustomEvent`s on the layer's host element so consumer JS can react without reaching into the hook. Listen on the host or any ancestor:
```js
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](LICENSE).