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={[:rectangle, :circle, :polygon, :freehand, :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.
Tools
Configure which drawing tools appear in the bottom toolbar. The default exposes all eight drawing kinds plus the eraser:
tools={[:rectangle, :circle, :polygon, :freehand, :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.
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.
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.revealShape("01HXY..."); // scroll to a shape
layer.deleteShape("01HXY...");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[:rectangle, :circle, :polygon, :freehand, :callout, :text, :dimension, :eraser].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.