Phoenix LiveView function component that attaches Etcher's annotation
overlay to a named <Fresco.canvas> or <Fresco.scroll_strip>.
The component renders a hidden host <div phx-hook="EtcherLayer">. The
client-side hook:
- Looks up the named Fresco viewer via
window.Fresco.onReady/2 - Detects whether the handle is a
<Fresco.canvas>(single canvas- pixel coordinate space, exposesgetCanvasSize) or a<Fresco.scroll_strip>(per-image natural pixel coordinates, exposesscrollTo+getImages) and routes to the matching renderer - Appends a pencil button to Fresco's nav column via
handle.appendNavButton/3 - On pencil click, opens a bottom toolbar with the configured drawing tools and toggles annotation mode
- Draws shapes as SVG overlays — one canvas-spanning SVG in canvas mode, one per-image SVG sibling in strip mode
- Hydrates initial annotations from
handle.getExtension("etcher") - Pushes
etcher:annotations-changedevents to the consumer's LiveView with the full annotations array on every commit / edit / delete
Canvas mode
<Fresco.canvas id="board" canvas={@canvas} class="w-full h-screen" />
<Etcher.layer
fresco_id="board"
tools={[:grabber, :rectangle, :circle, :polygon, :freehand, :marker, :callout, :text, :dimension, :eraser]}
/>Strip mode
<Fresco.scroll_strip id="reader" sources={@pages} extensions={@reader_extensions} />
<Etcher.layer fresco_id="reader" tools={[:rectangle, :freehand, :text]} />The component is identical in both cases — fresco_id is the only
thing that changes. Strip-mode annotations carry an extra image_idx
field on each shape identifying which image they live on.
The event your LiveView must handle (canvas)
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)}
endThe event your LiveView must handle (strip)
Strip-mode annotations carry image_idx (which page they're on).
Strip viewers don't have a %Fresco.Canvas{} struct or a
put_extension/3 helper — the consumer just maintains the
:extensions map (a plain %{}) in socket assigns and threads it
through the component:
def handle_event("etcher:annotations-changed", %{"annotations" => annotations}, socket) do
extensions =
Map.put(socket.assigns.reader_extensions, "etcher", %{
"version" => "1",
"annotations" => annotations
})
{:noreply, assign(socket, reader_extensions: extensions)}
endWhen a LiveView hosts multiple Etcher layers, the
"etcher:annotations-changed" payload includes a "fresco_id" key
so consumers can pattern-match on the source:
def handle_event(
"etcher:annotations-changed",
%{"fresco_id" => "reader", "annotations" => annotations},
socket
) do
# ...
endUUIDs are generated client-side (UUIDv7) in both modes, so there's no tmp_id ⇄ real-uuid round-trip — the server never has to assign ids.
Color slots and the etcher:colors-changed hook
The toolbar shows 5 fixed, editable color slots. Clicking a slot selects
it; opening the hue picker and choosing a color overwrites the selected
slot in place. Each committed edit reports the full palette through two
channels (mirroring etcher:annotations-changed + the JS lifecycle
events) — Etcher itself persists nothing:
- a LiveView event your
handle_event/3can store, and - a bubbling DOM
CustomEventfor pure-JS consumers.
Seed the slots with the :colors attr (per-user palette) or via
extensions["etcher"]["colors"]; the save/load is generic so the same
hook can target a .fresco/extension blob here or a per-user store
(e.g. phoenix_kit custom_fields) in a host app.
def handle_event(
"etcher:colors-changed",
%{"fresco_id" => "board", "colors" => colors},
socket
) do
# `colors` is a list of "#rrggbb" strings. Persist however you
# like — alongside annotations in the canvas extension, or in the
# current user's metadata. Round-trip it back through `:colors`
# (or the extension) on the next mount.
etcher = Map.put(socket.assigns.canvas.extensions["etcher"] || %{}, "colors", colors)
{:noreply,
assign(socket,
canvas: Fresco.Canvas.put_extension(socket.assigns.canvas, "etcher", etcher)
)}
endProgrammatic control mirrors the colors: layer.getColors(),
layer.setColors([...]), layer.setSlotColor(i, "#rrggbb") on the
window.Etcher.layerFor(id) handle.
Line params and the etcher:line-params-changed hook
The Parameters popup (thickness / opacity / dash) sets the global default new strokes inherit. With no shape selected, committing a slider or picking a dash reports the new default through the same two channels as the colors hook, so you can persist per-user "ink":
def handle_event(
"etcher:line-params-changed",
%{"fresco_id" => "board", "line_params" => lp},
socket
) when is_map(lp) do
# `lp` is %{"width" => n, "opacity" => n, "dash" => "solid"|"dashed"|
# "dotted"}. Persist it (e.g. in the current user's metadata) and feed
# it back through the `:line_params` attr on the next mount.
{:noreply, socket}
endSeed the default with the :line_params attr; missing keys fall back to
the built-ins (width: 2, opacity: 1, dash: "solid"). Editing a
selected shape's style instead keeps flowing through
etcher:annotations-changed (it's saved with the shape) and does not
fire this event. Programmatic: layer.getLineParams() /
layer.setLineParams(%{...}) (the latter doesn't echo the event).
Tools
Configure which drawing tools appear in the bottom toolbar. The default exposes the navigation grabber, all drawing kinds, and the eraser:
tools={[:grabber, :rectangle, :circle, :polygon, :freehand, :marker, :callout, :text, :dimension, :line, :eraser]}Subsetting hides specific tools (e.g. only :rectangle, :freehand).
Drop :eraser if you don't want users deleting from the toolbar, or
:grabber if pan-only mode isn't needed.
Annotation hydration
Initial annotations come from the viewer's extensions.etcher map.
The consumer's mount/3 typically reads a .fresco file (canvas mode):
canvas = Fresco.Canvas.read!("/path/to/scene.fresco")
{:ok, assign(socket, canvas: canvas)}…or assigns the strip's extension map directly (strip mode):
extensions = %{
"etcher" => %{
"version" => "1",
"annotations" => [
%{"uuid" => "01HXY...", "kind" => "rectangle",
"geometry" => %{"x" => 100, "y" => 200, "w" => 50, "h" => 50},
"image_idx" => 2}
]
}
}
{:ok, assign(socket, pages: @pages, reader_extensions: extensions)}Either way <Etcher.layer> reads the annotations through Fresco's
handle at mount time and renders each shape on the matching page.
Coordinate spaces
Shape geometry is stored in the same coordinate system the host Fresco component reports for that image, never as a normalized fraction. Two cases:
- Strip mode (
<Fresco.scroll_strip>/<FrescoStrip.viewer>) — geometry is in source-pixel space: a circle'scx, cy, r, a polygon'spoints[].x/y, are integers in the image's own natural-pixel grid (e.g. a 720×9200 page uses 0..720 / 0..9200). Independent of the rendered display size, so shapes survive strip width changes without recomputation. - Canvas mode (
<Fresco.canvas>) — geometry is in canvas- pixel space: a unified stage spanning all images in the scene, sized in canvas-internal pixels (the dimensions returned by the canvas handle'sgetCanvasSize()). A single coord pair can address any image since they're all laid out on the same stage.
Consumers that scroll to a shape, render mini-maps, or persist
shape positions outside of Etcher should know which space they're
in. layer.getShape(uuid) returns the shape descriptor with
either image_idx (strip) or image_id (canvas multi-image)
attached so routing UI doesn't need to scrape DOM data-attrs.
Programmatic API
Each mounted layer registers a handle on window.Etcher.layerFor(id):
var layer = window.Etcher.layerFor("reader");
layer.setMode(true); // enter annotation mode
layer.selectTool("rectangle");
layer.deleteShape("01HXY...");
// Shape descriptors include image_idx (strip) / image_id (canvas):
var shape = layer.getShape("01HXY...");
// → { uuid, kind, geometry, style, metadata,
// image_idx?: 4, // strip mode
// image_id?: "page-5" } // canvas multi-image
// Reveal a shape — Promise-returning, polls for late-mounted
// shapes, optional pulse flash, fires `etcher:shape-revealed`.
layer.revealShape("01HXY...", { pulse: true })
.then(function (r) { console.log("revealed on image", r.image_idx); })
.catch(function (e) { console.warn(e.reason); });
// Hit-test a point against the current shapes. Returns the
// top-most shape descriptor (uuid, kind, geometry, image_idx /
// image_id, …) under `pt`, or null. Use when wiring custom
// tap-zone navigation so the tap can be ignored when it lands
// on an annotation:
// const hit = layer.shapeAt({ imageIdx: 2, x: 540, y: 920 });
// if (hit) return; // let etcher handle it
// Strip handles: pt = { imageIdx, x, y } in source-pixel space.
// Canvas handles: pt = { x, y } in canvas-pixel space.See priv/static/etcher.js for the full API surface.
Summary
Functions
Mounts an Etcher annotation layer onto a named Fresco canvas.
Functions
Mounts an Etcher annotation layer onto a named Fresco canvas.
Renders a hidden <div phx-hook="EtcherLayer"> that hosts the JS
engine; the visible UI (pencil nav button + bottom toolbar + SVG
shapes) is created by the hook on top of the Fresco canvas.
Attributes
fresco_id(:string) (required) - DOM id of the<Fresco.canvas>this layer attaches to.id(:string) - Optional DOM id for the layer host element; defaults to"etcher-layer-<fresco_id>". Defaults tonil.tools(:list) - Subset of drawing tools to show in the toolbar. Defaults to[:grabber, :rectangle, :circle, :polygon, :freehand, :marker, :callout, :text, :dimension, :eraser].colors(:list) - Optional seed for the toolbar's fixed, editable color slots — a list of"#rrggbb"strings (clamped/backfilled to 5). Supply the signed-in user's saved palette here for per-user colors.When omitted, the slots seed from
extensions["etcher"]["colors"](the same JSON the annotations ride in), then the preset palette.Edits are reported via the
etcher:colors-changedevent (see below); persist them wherever you keep per-user data and feed them back through this attr (or the extension) on the next mount.Defaults to
nil.line_params(:map) - Optional seed for the global stroke defaults new shapes inherit —%{"width" => number, "opacity" => number, "dash" => "solid" | "dashed" | "dotted"}. Any missing key falls back to the built-in default (width: 2,opacity: 1,dash: "solid"). Supply the signed-in user's saved line params here for per-user ink.When omitted, the built-in defaults apply (identical to prior behavior). Edits made via the Parameters popup with no shape selected are reported through the
etcher:line-params-changedevent; persist them per user and feed them back through this attr on the next mount. (Per-shape style edits keep flowing throughetcher:annotations-changed.)Defaults to
nil.nav_buttons(:list) - Allowlist of Etcher's nav-column buttons (appended to Fresco's nav). Atom list:[:pencil, :visibility].nil(default) — both enabled.[]— both hidden. Consumers shipping their own chrome wirehandle.toggleMode()/handle.toggleVisible()(orsetMode(true|false)/setVisible(true|false)) to their own buttons / shortcuts.- A subset list — only those buttons render.
Defaults to
nil.toolbar(:boolean) - Whether to render the bottom toolbar (cursor + drawing tools + undo/redo + color picker + close).falsehides it entirely; annotation mode still works programmatically — consumers wire their own toolbar UI tohandle.selectTool(...)/handle.selectColor(...)/handle.undo()/handle.redo()/handle.setMode(false)(close).Defaults to
true.Global attributes are accepted.