# 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, callout, text, dimension) 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 │
│ │ │ │ │ │
│ └───┘ └────────┘ │
│ │
│ [⌖] [▭] [○] [⬡] [〰] [💬] [T] [⟷] [⌫] ← toolbar │
└─────────────────────────────────────────────────────┘
```
## Installation
Add `:fresco` (the viewer) and `:etcher` to your `mix.exs`:
```elixir
def deps do
[
{:fresco, "~> 0.1"},
{:etcher, "~> 0.2"}
]
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 }
})
```
The hook name is `EtcherLayer` — if you maintain an explicit hooks map instead of spreading `window.EtcherHooks`, register it as `{ EtcherLayer: window.EtcherHooks.EtcherLayer }` (alongside Fresco's `FrescoViewer`).
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 seven drawing tools (rectangle, circle, polygon, freehand, callout, text, dimension) plus an eraser. 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, :callout, :text, :dimension, :eraser]}
/>
```
| 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 seven drawable kinds plus `:eraser`. |
| `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" | "callout" | "text" | "dimension",
"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], ...]}` |
| `callout` | `%{"anchor" => [x, y], "text_box" => %{"x" => x, "y" => y, "w" => w, "h" => h}}` |
| `text` | `%{"x" => x, "y" => y, "w" => w, "h" => h}` |
| `dimension` | `%{"a" => [x, y], "b" => [x, y]}` (label text + position live in `metadata.title` / `metadata.title_offset`) |
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. Every built-in button (toolbar tools, color swatches, undo/redo, the eye visibility toggle, the pencil annotation-mode toggle) delegates to a method on this object — so you can drive Etcher headlessly (custom toolbar, keyboard shortcuts, command palette, URL handlers, automated tests):
```js
const layer = window.Etcher.layerFor("photo");
if (!layer) return;
// Mode / visibility
layer.setMode(true); // enter annotation mode
layer.toggleVisible(); // show / hide annotations
layer.isVisible(); // → boolean
// Tools
layer.tools(); // → ["rectangle", "circle", ...]
layer.selectTool("rectangle");
layer.selectTool(null); // back to cursor (alias: exitDrawing())
layer.getTool(); // → "rectangle" | null
// Color
layer.swatches(); // → [{ color, title }, ...]
layer.setColor("#fca5a5");
layer.getColor(); // → "#fca5a5" | null
// History
if (layer.canUndo()) layer.undo();
if (layer.canRedo()) layer.redo();
// Shapes
const shapes = layer.getShapes();
// → [{ uuid, kind, geometry, style, metadata }, ...]
const one = layer.getShape("uuid-…");
layer.selectShape("uuid-…"); // pins the tooltip
layer.enterEditMode("uuid-…");
layer.exitEditMode();
layer.deleteShape("uuid-…");
```
### 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 (or API) toggled annotation mode |
| `etcher:tool-changed` | `{ tool: string \| null }` | Drawing tool changed (null = cursor) |
| `etcher:color-changed` | `{ color: string }` | Active color changed |
| `etcher:visibility-changed` | `{ visible: bool }` | Annotations hidden / shown |
| `etcher:history-changed` | `{ canUndo: bool, canRedo: bool }` | Undo/redo stack updated — useful for keeping a custom toolbar in sync |
### 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)
- Custom tools beyond the seven built-in kinds. The geometry kind is just a string, so the schema doesn't care, but the toolbar + drawing-loop wiring isn't pluggable yet — adding a kind today means a fork.
- Touch + pinch gesture coexistence with Fresco's pan/zoom — annotation mode currently disables Fresco's drag-to-pan; refinement comes later.
- Annotation export / import in W3C Web Annotation Data Model JSON-LD.
## License
MIT. See [LICENSE](LICENSE).