<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.
Replacing the image set in place — handle.setSources/2
handle.setSources(sources, opts) swaps the canvas's whole image set
without remounting the DOM, so state tied to the page session — Pointer
Lock, audio/video pipelines, peer overlays bound via handle.on(...) —
survives. It's the in-place alternative to a full navigation; consumers
building paged readers use it for instant chapter transitions.
await handle.setSources(images, {
reset_view: true, # fit-to-canvas after swap (default)
extensions: %{ etcher: %{ annotations: shapes } } # optional, replaced atomically
})sources is an array of %{src, x?, y?, width?, height?, id?, z_index?}
(the getImages() shape). opts:
reset_view(defaulttrue) — fit to the new canvas after the swap;falsekeeps the current pan/zoom (clamped to the new bounds).extensions— replaces the canvas-level extension map atomically with the swap (omitted → the current map is left untouched).canvasWidth/canvasHeight— explicit canvas extent; omitted → derived from the new images' bounding box.
Returns a Promise that resolves after the first post-swap frame paints
(the new images are positioned/sized), so imageBoundsFor(...) returns
measurable rects on the next line; it rejects on empty/malformed input
(so the consumer can fall back to a full navigation). It clears the
hidden-image set (setImageVisible bookkeeping doesn't carry across a
swap), fires open and a dedicated sources-changed event so overlays
bound to those rebuild, and is programmatic-only — the consumer owns
persistence, URL history, etc.
Per-image helpers (paged readers)
Paged readers that show one page at a time and cycle a "load window" of in-flight images use two companions, both on the canvas handle:
handle.setImageVisible(id, visible)— toggles one image's visibility. The inline style flips synchronously, so agetBoundingClientRect/imageBoundsForread on the next line sees the new state.handle.setImageSrc(id, url)— swaps a single image'ssrc(real URL ↔ placeholder) without a full relayout, re-latching load tracking soimage-loadedfires on the new source.
After any such mutation, await handle.whenLayoutSettled() resolves once
the next frame has painted, for consumers that need to measure between
swaps.
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.