Etcher is the annotation overlay for Fresco canvases in Phoenix.
An etcher is the tool that incises marks into a surface — Etcher the
library does the same digitally: users draw shapes (rectangle, circle,
polygon, freehand, callout, text, dimension) on top of a <Fresco.canvas>;
the LiveView fires events with the resulting annotations; the consumer
stores them inside the canvas's extensions.etcher blob and saves
the whole canvas to a .fresco file.
Architecture
Etcher has two pieces:
- Client side: an
<Etcher.layer>component that attaches to a named<Fresco.canvas>, adds a pencil button to its nav, opens a bottom toolbar with drawing tools when active. Built from scratch — no Annotorious dependency. - Server side: passive. Etcher pushes a single
etcher:annotations-changedevent with the full annotations array whenever the user edits. The consumer's LiveView pipes that intoFresco.Canvas.put_extension/3and re-assigns the canvas; saving the file is a separateFresco.Canvas.write!/2call when the consumer is ready.
No Ecto schema, no migration, no adapter pattern. The .fresco file
IS the storage; Etcher annotations live inside its extensions.etcher
map alongside the image layout.
Quick start
Install (in your mix.exs):
def deps do
[
{:fresco, "~> 0.5"},
{:etcher, "~> 0.3"}
]
endWire the JS hook (in 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 }
})Drop in a LiveView. Build the canvas in mount/3, render <Fresco.canvas>
alongside <Etcher.layer>, handle the etcher:annotations-changed event:
@impl true
def mount(_params, _session, socket) do
canvas =
Fresco.Canvas.new(width: 4000, height: 3000)
|> Fresco.Canvas.add_image(%{src: "/uploads/a.jpg", x: 0, y: 0, width: 2000})
{:ok, assign(socket, canvas: canvas)}
end
@impl true
def render(assigns) do
~H"""
<Fresco.canvas id="board" canvas={@canvas} class="w-full h-screen" />
<Etcher.layer fresco_id="board" />
<button phx-click="save">Save canvas</button>
"""
end
@impl true
def handle_event("etcher:annotations-changed", %{"annotations" => annotations}, socket) do
new_canvas =
Fresco.Canvas.put_extension(socket.assigns.canvas, "etcher", %{
"version" => "1",
"annotations" => annotations
})
{:noreply, assign(socket, canvas: new_canvas)}
end
def handle_event("save", _params, socket) do
:ok = Fresco.Canvas.write!("/tmp/scene.fresco", socket.assigns.canvas)
{:noreply, socket}
endRe-mounting later with Fresco.Canvas.read!/1 re-hydrates the canvas
- annotations in one shot —
<Etcher.layer>reads its initial state fromhandle.getExtension("etcher")at mount time.
Storage flexibility
The .fresco file isn't the only target. Fresco.Canvas.to_json!/1
yields a JSON string you can stash anywhere: a Postgres :jsonb column,
an S3 object, a Redis key, a file. Etcher annotations travel with the
canvas wherever it goes.
See Etcher.Layer for the component reference.
Summary
Functions
See Etcher.Layer.layer/1.