# `FrescoStrip.Viewer`
[🔗](https://github.com/alexdont/fresco_strip/blob/v0.1.0/lib/fresco_strip/viewer.ex#L1)

Phoenix LiveView function component for vertical-image-strip scrolling.

Use this for content that is **read by scrolling continuously** through
a stack of full-width images: manhwa, long-form comics, IG-style feeds,
documentation snapshots. For deep-zoom imagery or paged layouts,
reach for `Fresco.viewer` / `Fresco.canvas` from the `fresco` package
instead.

## Why a dedicated package?

This was `Fresco.scroll_strip` in `fresco <= 0.5.9`. Extracted to
`fresco_strip` in 0.1.0 so consumers who only need the viewer /
canvas surface stay lightweight, and so strip mode can iterate on
its own release cadence. The component, JS hook, handle API, and
extension contract are byte-for-byte the same as the old
`Fresco.scroll_strip` — only the module name and the JS file you
import changed.

## Usage

    <FrescoStrip.viewer
      id="reader"
      sources={[
        %{url: "/img/page-01.jpg", width: 720, height: 9200},
        %{url: "/img/page-02.jpg", width: 720, height: 8800},
        %{url: "/img/page-03.jpg", width: 720, height: 9100}
      ]}
      class="w-full h-lvh"
    />

Each source map MUST include `:width` and `:height` in source pixels —
used to set inline `aspect-ratio` per `<img>`, which keeps the layout
stable through memory-windowing evict/restore cycles. Omitting them
raises `ArgumentError` at render time.

## Handle API

Look up the strip handle once it's mounted — the registry is shared
with `fresco`, so the same `window.Fresco.onReady(id, cb)` works
regardless of which package mounted the handle:

    window.Fresco.onReady("reader", function (handle) {
      handle.scrollTo({imageIdx: 3, y: 0, behavior: "smooth"});
      handle.scrollBy({dy: 500, behavior: "instant"});
      handle.getScrollState(); // { scrollTop, scrollHeight, viewportH, currentImageIdx, fractionWithin }

      handle.on("viewport-change", function (e) {
        // e.currentImageIdx, e.fractionWithin
      });
      handle.on("image-loaded", function (e) { /* e.imageIdx */ });
      handle.on("image-evicted", function (e) { /* e.imageIdx */ });
      handle.on("scroll", function (e) { /* e.scrollTop, e.scrollHeight */ });
      handle.on("open", function (e) { /* e.sources */ });
    });

Feature-detect the strip vs viewer/canvas handles via
`"scrollTo" in handle`.

## Server-pushed scrolling

Push `phx:scroll-to` from your LiveView to programmatically scroll —
useful for chapter-resume restoration:

    push_event(socket, "phx:scroll-to", %{imageIdx: 5, y: 0, behavior: "smooth"})

The hook forwards the payload straight to `handle.scrollTo/1`.

## Attaching annotation tools (or other peer libraries)

`<FrescoStrip.viewer>` doesn't use a `%Fresco.Canvas{}` struct — its
state is just `:sources` + `:extensions`, both passed directly via
assigns. Wire something like Etcher by keeping an `:extensions` map
in your LiveView assigns and re-rendering through it:

    def mount(_params, _session, socket) do
      sources = ... # %{url, width, height} list, loaded from your storage
      extensions = ... # %{"etcher" => %{"version" => "1", "annotations" => [...]}}
                        # — or %{} if you don't have any yet

      {:ok, assign(socket, sources: sources, extensions: extensions)}
    end

    def handle_event("etcher:annotations-changed", %{"annotations" => annotations}, socket) do
      new_extensions =
        Map.put(socket.assigns.extensions, "etcher", %{
          "version" => "1",
          "annotations" => annotations
        })

      {:noreply, assign(socket, extensions: new_extensions)}
    end

    def render(assigns) do
      ~H"""
      <FrescoStrip.viewer
        id="reader"
        sources={@sources}
        extensions={@extensions}
        class="w-full h-lvh"
      />

      <Etcher.layer fresco_id="reader" />
      """
    end

Etcher (or any peer library) reads its initial state via the strip
handle at mount — `handle.getExtension("etcher")` — and uses
`handle.getImages()` to discover per-image positions for overlay
placement. Mutating `@extensions` and re-assigning re-renders the
strip host with the new `data-extensions`; the handle's
`getExtension` returns the fresh data on the next call.

Symmetric with `<Fresco.canvas>`: the on-the-wire shape inside
`extensions.etcher` is identical, so a consumer that already
handles `etcher:annotations-changed` for canvas can reuse the
exact handler for strip — the only difference is that strip-mode
annotations carry an additional `image_idx` field in their
payload.

# `viewer`

Renders a vertical-image-strip scroll container.

Each source becomes a `<img loading="lazy">` inside the scroll
container, with inline `aspect-ratio` set from the source's
`width`/`height`. The companion JS hook (`FrescoScrollStrip`) attaches
on mount and wires the scroll bridge + memory windowing + handle
registry.

## Attributes

* `id` (`:string`) (required) - DOM id; must be unique on the page.
* `sources` (`:list`) (required) - Ordered list of images to render as a vertical strip. Each entry is
  a map:

      %{
        url: "/uploads/page-01.jpg",  # required — image URL
        width: 720,                    # required — source pixel width
        height: 9000                   # required — source pixel height
      }

  `width` and `height` are mandatory so the component can emit
  `aspect-ratio: <w> / <h>` on each `<img>`. That preserves layout
  through memory-windowing evict/restore cycles (removing `src`
  doesn't collapse the slot to 0px → no scroll-position jumps) and
  avoids cumulative layout shift before images decode.

* `class` (`:string`) - CSS classes for the scroll container. Defaults to `w-full h-screen`. Defaults to `"w-full h-screen"`.
* `theme` (`:atom`) - Color scheme for the strip's container background and scrollbar.
  Same semantics as `Fresco.viewer`'s `:theme`. With `:inherit`,
  define the `--fresco-*` custom properties on `.fresco-strip[data-fresco-theme="inherit"]`
  in your CSS.

  Defaults to `:system`. Must be one of `:system`, `:light`, `:dark`, or `:inherit`.
* `window_before` (`:integer`) - Memory windowing: how many images *before* the current dominant
  image to keep loaded. Default `1`. Images outside the
  `[current - window_before, current + window_after]` range get
  their `src` evicted to free decoded-image memory; they restore
  on re-entry.

  Defaults to `1`.
* `window_after` (`:integer`) - Memory windowing: how many images *after* the current dominant
  image to keep loaded. Default `3` (skewed forward because scroll
  is typically downward and prefetching ahead avoids visible loads).

  Defaults to `3`.
* `gap_px` (`:integer`) - Spacing between images, in CSS pixels. Default `0` (manhwa /
  long-comic convention — gutters live inside the image, not
  between images). Set to `8` or `16` for IG-feed-style layouts
  where each image is its own card.

  Defaults to `0`.
* `snap_to_image` (`:atom`) - CSS `scroll-snap` behavior for the container.

  - `:off` (default) — no snap; native scroll.
  - `:mandatory` — `scroll-snap-type: y mandatory`. Always locks the
    viewport to an image top. Right for short-image-per-screen
    content (IG-style feeds, slide decks).
  - `:proximity` — `scroll-snap-type: y proximity`. Snaps only if
    the user releases near a snap point.

  For tall continuous content (manhwa pages at 7-9k px), keep at
  `:off` — snap would either lock you to image tops (`:mandatory`)
  or yank mid-read (`:proximity`).

  Defaults to `:off`. Must be one of `:off`, `:mandatory`, or `:proximity`.
* `view_tracking` (`:boolean`) - Enables the `view-focus` / `view-blur` event channel for reading-
  time / engagement analytics on the strip. Same semantics as
  `<Fresco.canvas>`'s `:view_tracking`: when `true`, the engine
  watches which image is dominant and emits paired focus/blur events
  when it changes.

  The strip's notion of "dominant" is the existing `currentImageIdx`
  (image whose center is closest to the viewport center) — same
  image that drives the `viewport-change` event. The view-tracking
  layer adds a settle-time gate (so fast scrolls don't emit a focus
  for every page flown past) and a Page Visibility pause.

  Defaults to `false` so consumers who don't subscribe pay zero cost.

  Defaults to `false`.
* `view_settle_ms` (`:integer`) - Milliseconds the dominant image must hold before `view-focus`
  fires. Only consulted when `:view_tracking` is `true`. Default `150`.

  Defaults to `150`.
* `extensions` (`:map`) - Open map for peer-library state (annotation tools, ML overlays,
  comment threads, …). Rendered as `data-extensions={Jason.encode!(...)}`
  on the strip host so the JS engine can expose it via
  `handle.getExtension(name)`. Mirrors `<Fresco.canvas>`'s `:extensions`
  contract so consumers can persist the same shapes across both
  components.

  Default `%{}` — no `data-extensions` attribute emitted; existing
  strip consumers see no change.

  ## Attaching extensions

  A peer library like Etcher reads its initial state via the strip
  handle at mount, then renders per-image overlays as siblings of
  each `<img>`. Use `handle.getImages()` to discover per-image
  layout — positions in scroll-container coordinates — these come
  live from each `<img>`'s `offsetTop` / `offsetLeft` /
  `offsetWidth` / `offsetHeight`, padding-box-relative to the
  scroll container (which is the image's offset parent). The
  `naturalWidth` / `naturalHeight` fields report the bitmap's true
  intrinsic dimensions once loaded, falling back to the
  consumer-passed `sources[i].width` / `height` for unloaded
  images. All values stay valid across memory-windowing
  evict/restore because the component sets `aspect-ratio` per
  image.

  ```js
  window.Fresco.onReady("reader", function (handle) {
    var etcher = handle.getExtension("etcher");
    var pages = handle.getImages();
    // pages[i] = {
    //   idx, url, naturalWidth, naturalHeight,
    //   top, left, width, height, element
    // }
  });
  ```

  Consumers that mutate `<img>` layout via CSS after mount (a
  padding slider, an aspect-ratio correction class, container
  resize via the layout shell) should dispatch a `resize` event on
  the window after the mutation so peer libraries re-query:

  ```js
  window.dispatchEvent(new Event("resize"));
  ```

  `<FrescoStrip.viewer>` itself doesn't need the nudge — its own
  geometry is implicit in the DOM — but extensions that snapshot
  layout (Etcher's overlay sizing, ML overlay placement) do.

  Mutating the map server-side and re-assigning re-renders the
  strip host with the new `data-extensions`; consumers reading
  `handle.getExtension(name)` after the re-render see the fresh
  data.

  Defaults to `%{}`.
* Global attributes are accepted.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
