<Fresco.canvas> — a layered scene of N images positioned at absolute
coordinates on a virtual canvas, plus an open extensions map for
annotation tools (future Etcher), ML overlays, and other peer packages.
Single-image is just the N=1 case — use Fresco.Viewer when you want the
bare "pan/zoom one image" component without the scene-document overhead.
The .fresco file format
Serializing a canvas yields a JSON document keyed by:
{
"version": "1",
"canvas": { "width": 4000, "height": 3000, "background": null },
"images": [
{
"id": "img-1",
"src": "/uploads/a.jpg",
"x": 0, "y": 0,
"width": 2000,
"z_index": 0,
"natural_width": 2000,
"natural_height": 1500
}
],
"extensions": {
"etcher": { "version": "1", "annotations": [...] },
"ml-overlay": { ... }
}
}canvas.width/canvas.height— the virtual canvas extent in canvas pixels. Reset-view fits this rectangle to the viewport.- Images are positioned at absolute canvas-pixel
(x, y)withwidthin canvas pixels. Height is derived from natural aspect ratio ifnatural_widthandnatural_heightare present (and saved into the file for forward compatibility). extensionsis an open map keyed by package name. Fresco never inspects the inside — each extension owns its own shape and version. Unknown extension keys are preserved verbatim across read/write so you can load → edit → save without losing data the current version doesn't understand.- Read-time forward-compatibility: any unknown top-level or
per-image key is preserved through a private
__extra__map and re-merged on write. A v1 reader of a future v2 file keeps the v2 fields it doesn't understand and writes them back unchanged.
Building a canvas
iex> canvas =
...> Fresco.Canvas.new(width: 4000, height: 3000)
...> |> Fresco.Canvas.add_image(%{src: "/a.jpg", x: 0, y: 0, width: 2000})
...> |> Fresco.Canvas.add_image(%{src: "/b.jpg", x: 2100, y: 0, width: 1800})
...> |> Fresco.Canvas.put_extension("etcher", %{"version" => "1", "annotations" => []})
iex> Enum.map(canvas.images, & &1.id)
["img-1", "img-2"]File I/O
Fresco.Canvas.write!("/tmp/scene.fresco", canvas)
canvas = Fresco.Canvas.read!("/tmp/scene.fresco")Writes are atomic: write/2 writes to <path>.tmp then renames, so an
interrupted save can't corrupt an existing file.
Extension contract — passive Fresco
Fresco is passive with respect to extensions. The file is the source
of truth; updates flow consumer LiveView → %Fresco.Canvas{} in
assigns → re-render. A peer package like the future Etcher reads its
initial state via handle.getExtension("etcher") at mount, pushes
edits to its own LiveView, which calls
Fresco.Canvas.put_extension(canvas, "etcher", new_data) and
re-assigns. Fresco's handle is intentionally read-only for extensions
— no setExtension method exists, so save timing is never racing
with annotation updates over channels.
See Fresco.Viewer for the simpler single-image component, and
Fresco.ScrollStrip for the long-scroll reader counterpart.
Summary
Functions
Append an image to the canvas.
Renders a Fresco canvas — N images positioned at absolute coordinates
on a virtual canvas, with pan/zoom/fit/fullscreen identical to
Fresco.viewer. Hooks the FrescoCanvas JS controller.
Parse a JSON string into a canvas struct. Returns {:ok, canvas} or
{:error, %Fresco.Canvas.SchemaError{}}.
Parse a JSON string into a canvas struct. Raises on invalid input.
Build a new empty canvas.
Put or overwrite an extension blob keyed by name (binary or atom).
Read a canvas from disk. Returns {:ok, canvas} or {:error, reason}.
Read a canvas from disk. Raises on failure.
Serialize a canvas to a JSON string. Returns {:ok, json} or
{:error, reason} (only on Jason encode failure — schema is validated
at struct-construction time).
Serialize a canvas to a JSON string. Raises on encode failure.
Write a canvas to disk atomically. Writes to <path>.tmp first then
renames so an interrupted save can't corrupt the existing file.
Write a canvas to disk atomically. Raises on failure.
Types
Functions
Append an image to the canvas.
Required attrs: :src (string URL/path), :x, :y, :width (numbers,
width > 0). Optional: :id (auto-assigned img-N when omitted),
:z_index, :natural_width, :natural_height.
Raises ArgumentError on invalid attrs.
Renders a Fresco canvas — N images positioned at absolute coordinates
on a virtual canvas, with pan/zoom/fit/fullscreen identical to
Fresco.viewer. Hooks the FrescoCanvas JS controller.
Coordinate space
Everything addressed through the canvas handle — fitBounds,
screenToImage / imageToScreen, the canvas extent reported by
getCanvasSize(), and any geometry persisted by peer libraries
(Etcher shape geometry, ML overlay boxes) — is in canvas-pixel
space: a single coordinate system that spans every image on the
canvas, sized in the canvas's internal pixels (the :width /
:height of the :canvas field on the %Fresco.Canvas{} struct).
This is distinct from the per-image source-pixel space used by
<Fresco.scroll_strip> / <FrescoStrip.viewer>, where geometry
lives in each image's natural-pixel grid. Code consuming geometry
off either viewer should know which space it's working in.
Attributes
id(:string) (required) - DOM id; must be unique on the page.canvas(Fresco.Canvas) (required) - A%Fresco.Canvas{}struct describing the scene: virtual canvas extent, the list of images with their canvas-pixel positions, and an openextensionsmap. Build one viaFresco.Canvas.new/1+Fresco.Canvas.add_image/2, or load one from a.frescofile viaFresco.Canvas.read!/1.class(:string) - CSS classes for the canvas host container. Defaults to"w-full h-96".infinite_canvas(:boolean) - Whentrue, drops the default "canvas must cover viewport" clamp so the user can pan freely beyond the canvas edges and zoom out until the whole layout is a thumbnail in the middle of an empty workspace.Defaults to
false.theme(:atom) - Color scheme. Same semantics asFresco.viewer's:theme. Defaults to:system. Must be one of:system,:light,:dark, or:inherit.zoom_floor(:float) - Optional minimum zoom scale, in engine units (screen-px-per-canvas-px). When set, the engine clamps every zoom path — wheel, pinch, double-click,fitBounds— at this floor.nil(default) falls through to the engine's normal floor (sFitclamped mode,sFit * 0.05infinite-canvas mode).Most often set at runtime via
handle.setZoomFloor(scale)— paged readers recompute it each time the user navigates to a new page so the floor tracks the current page's fit-to-viewport scale, not the whole-canvas fit.Defaults to
nil.zoom_ceiling(:float) - Optional maximum zoom scale. Symmetric to:zoom_floor.nil(default) uses the engine's default ceiling (8× canvas-pixel ratio capped by the 8192-px raster safety limit).Defaults to
nil.pan_locked(:boolean) - Whentrue, single-pointer pan gestures (mouse drag, touch drag, arrow keys) are suppressed. Two-pointer pinch still works for zoom. Toggle at runtime viahandle.setPanLocked(true|false).Defaults to
false.initial_fit_image_id(:string) - If set, the engine lands at the fit-to-viewport position for the image with this:idat first paint instead of fitting the whole canvas. Avoids the brief flash of "whole-canvas visible" before anonReadycallback re-fits.Falls back to canvas-wide fit (with a
console.warn) if no image matches the id. If both:initial_fit_image_idand:initial_fit_boundsare provided, image-id wins.Defaults to
nil.initial_fit_bounds(:map) - Like:initial_fit_image_idbut for a custom rect. Map of%{x: number, y: number, width: number, height: number}in canvas-pixel coords. Serialized to JSON onto adata-*attr and parsed by the JS engine at mount.Defaults to
nil.memory_window(:integer) - Auto-evictsrcfor images more than this many viewport-widths/ heights from the current viewport. Same memory-saving trick<Fresco.scroll_strip>uses, generalized to 2D canvas layouts.A value of
2keeps a 5×5 viewport-rect window of images loaded around the current view (1 viewport in the center + 2 viewports of padding on each side). Defaultnil= disabled. Evicted images can be detected viahandle.on("image-evicted", e => ...)/image-restoredevents.Defaults to
nil.gestures(:list) - Allowlist of enabled gestures. Atom list:[:pan, :pinch, :wheel, :double_click, :keyboard]. Defaultnilenables all. Omitted entries are disabled.Useful for kiosks (drop
:keyboard), swipe-paged readers that handle their own page-turn taps (drop:double_click), embedded viewers that defer scroll to the page (drop:wheel).Defaults to
nil.nav_buttons(:list) - Allowlist of enabled built-in nav buttons. Atom list:[:home, :zoom_in, :zoom_out, :rotate, :fullscreen].nil(default) — every button enabled.[]— every button hidden. Useful for consumers building their own chrome; wire your buttons tohandle.zoomIn()/handle.zoomOut()/handle.rotateBy(90)/handle.toggleFullscreen()/handle.requestHome()to get identical behavior to the built-ins.- A subset list — only those buttons render.
Defaults to
nil.initial_rotation(:integer) - Initial rotation in degrees, snapped to one of{0, 90, 180, 270}at mount time. Pre-0.5.7 behavior (no rotation) corresponds to0. Consumers persisting a per-canvas rotation choice (a rotated single panel inside a paged reader, for example) pass it here so the first paint already shows the rotated content — no flash of unrotated → rotated.Runtime control via
handle.setRotation(deg)/handle.rotateBy(delta); the built-in:rotatenav button cycles+90°per click. Only the stage rotates — host element, nav overlay, and any consumer overlays outside the stage stay unrotated.Defaults to
0.view_tracking(:boolean) - Enables theview-focus/view-blurevent channel for reading- time / engagement analytics. Whentrue, the engine watches which image is dominant in the viewport and emits paired focus/blur events on the bus when that image changes. Defaults tofalseso consumers who don't subscribe pay zero cost.Consumer-side:
handle.on("view-focus", e => { // e.imageId, e.previousImageId, e.atMs }) handle.on("view-blur", e => { // e.imageId, e.durationMs, e.atMs, e.reason // e.reason ∈ "viewport-change" | "page-hidden" | "disabled" | "destroyed" })Runtime alternatives if you want to toggle tracking on/off without a re-render:
handle.enableViewTracking(opts)/handle.disableViewTracking()/handle.getFocusedImage().Defaults to
false.view_settle_ms(:integer) - Milliseconds the viewport must stay on a new dominant image beforeview-focusfires. Filters out pan-throughs and momentum-scroll fly-bys so analytics events only fire on actual reads. Only consulted when:view_trackingistrue. Default150.Defaults to
150.view_threshold(:float) - Fraction of an image's area that must intersect the viewport for it to qualify as "dominant". Lower values make focus changes more eager; higher values make focus stickier. Only consulted when:view_trackingistrue. Default0.5.Defaults to
0.5.Global attributes are accepted.
Parse a JSON string into a canvas struct. Returns {:ok, canvas} or
{:error, %Fresco.Canvas.SchemaError{}}.
Unknown top-level and per-image keys are preserved via a private
__extra__ map; round-tripping through to_json/from_json keeps them
intact so v1 readers of a future v2 file don't lose v2-only data.
Parse a JSON string into a canvas struct. Raises on invalid input.
Build a new empty canvas.
Options:
:width— virtual canvas width in canvas pixels (default0):height— virtual canvas height (default0):background— optional CSS color string for the stage background
Put or overwrite an extension blob keyed by name (binary or atom).
Fresco never inspects the contents — each peer package owns the inner shape and self-versions inside its own blob.
Read a canvas from disk. Returns {:ok, canvas} or {:error, reason}.
Read a canvas from disk. Raises on failure.
Serialize a canvas to a JSON string. Returns {:ok, json} or
{:error, reason} (only on Jason encode failure — schema is validated
at struct-construction time).
Serialize a canvas to a JSON string. Raises on encode failure.
Write a canvas to disk atomically. Writes to <path>.tmp first then
renames so an interrupted save can't corrupt the existing file.
Returns {:ok, path} or {:error, reason}.
Write a canvas to disk atomically. Raises on failure.