FrescoStrip.Viewer (fresco_strip v0.1.0)

Copy Markdown View Source

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.

Summary

Functions

Renders a vertical-image-strip scroll container.

Functions

viewer(assigns)

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.
    • :mandatoryscroll-snap-type: y mandatory. Always locks the viewport to an image top. Right for short-image-per-screen content (IG-style feeds, slide decks).
    • :proximityscroll-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.

    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:

    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.