Fresco.Canvas (fresco v0.7.1)

Copy Markdown View Source

<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) with width in canvas pixels. Height is derived from natural aspect ratio if natural_width and natural_height are present (and saved into the file for forward compatibility).
  • extensions is 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 (default true) — fit to the new canvas after the swap; false keeps 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 a getBoundingClientRect / imageBoundsFor read on the next line sees the new state.
  • handle.setImageSrc(id, url) — swaps a single image's src (real URL ↔ placeholder) without a full relayout, re-latching load tracking so image-loaded fires 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

image()

@type image() :: %{
  :id => String.t(),
  :src => String.t(),
  :x => number(),
  :y => number(),
  :width => number(),
  optional(:z_index) => integer(),
  optional(:natural_width) => number(),
  optional(:natural_height) => number(),
  optional(:__extra__) => map()
}

t()

@type t() :: %Fresco.Canvas{
  __extra__: map(),
  canvas: %{
    :width => number(),
    :height => number(),
    optional(:background) => String.t() | nil
  },
  extensions: %{optional(String.t()) => any()},
  images: [image()],
  version: String.t()
}

Functions

add_image(canvas, attrs)

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.

canvas(assigns)

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 open extensions map. Build one via Fresco.Canvas.new/1 + Fresco.Canvas.add_image/2, or load one from a .fresco file via Fresco.Canvas.read!/1.

  • class (:string) - CSS classes for the canvas host container. Defaults to "w-full h-96".

  • infinite_canvas (:boolean) - When true, 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 as Fresco.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 (sFit clamped mode, sFit * 0.05 infinite-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) - When true, single-pointer pan gestures (mouse drag, touch drag, arrow keys) are suppressed. Two-pointer pinch still works for zoom. Toggle at runtime via handle.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 :id at first paint instead of fitting the whole canvas. Avoids the brief flash of "whole-canvas visible" before an onReady callback re-fits.

    Falls back to canvas-wide fit (with a console.warn) if no image matches the id. If both :initial_fit_image_id and :initial_fit_bounds are provided, image-id wins.

    Defaults to nil.

  • initial_fit_bounds (:map) - Like :initial_fit_image_id but for a custom rect. Map of %{x: number, y: number, width: number, height: number} in canvas-pixel coords. Serialized to JSON onto a data-* attr and parsed by the JS engine at mount.

    Defaults to nil.

  • memory_window (:integer) - Auto-evict src for 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 2 keeps 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). Default nil = disabled. Evicted images can be detected via handle.on("image-evicted", e => ...) / image-restored events.

    Defaults to nil.

  • gestures (:list) - Allowlist of enabled gestures. Atom list: [:pan, :pinch, :wheel, :double_click, :keyboard]. Default nil enables 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 to handle.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 to 0. 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 :rotate nav 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 the view-focus / view-blur event channel for reading- time / engagement analytics. When true, the engine watches which image is dominant in the viewport and emits paired focus/blur events on the bus when that image changes. Defaults to false so 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 before view-focus fires. Filters out pan-throughs and momentum-scroll fly-bys so analytics events only fire on actual reads. Only consulted when :view_tracking is true. Default 150.

    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_tracking is true. Default 0.5.

    Defaults to 0.5.

  • Global attributes are accepted.

from_json(json)

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.

from_json!(json)

Parse a JSON string into a canvas struct. Raises on invalid input.

new(opts \\ [])

Build a new empty canvas.

Options:

  • :width — virtual canvas width in canvas pixels (default 0)
  • :height — virtual canvas height (default 0)
  • :background — optional CSS color string for the stage background

put_extension(canvas, name, value)

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(path)

Read a canvas from disk. Returns {:ok, canvas} or {:error, reason}.

read!(path)

Read a canvas from disk. Raises on failure.

to_json(canvas)

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).

to_json!(canvas)

Serialize a canvas to a JSON string. Raises on encode failure.

write(path, canvas)

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!(path, canvas)

Write a canvas to disk atomically. Raises on failure.