priv/static/fresco.js

// Fresco — polished pan-zoom image viewer for Phoenix apps.
//
// Wraps OpenSeadragon (lazy-loaded from jsDelivr) with a Phoenix LiveView
// hook, a Heroicons nav overlay, viewport clamping, and a small but
// deliberate extension surface so layered libraries (Tessera for deep zoom,
// future annotation packages, etc.) can plug in without forking.
//
// Public surface:
//
//   window.Fresco.viewerFor(domId)             // → viewer handle, or null
//   window.Fresco.onViewerReady(domId, cb)     // fires once when ready
//   window.Fresco.registerSourceProvider(predicate, factory)
//
// Viewer handle (returned by viewerFor):
//
//   { viewer, container,
//     imageToScreen(pt), screenToImage(pt),
//     getViewportBounds(),
//     fitBounds(rect, immediately),
//     setSource(url, opts), swapSourcePreservingBounds(url, opts),
//     on(eventName, handler) → unsubscribe }
//
// Parent app wiring:
//   import "../../deps/fresco/priv/static/fresco.js"
//   hooks: { ...window.FrescoHooks, ...colocatedHooks }

(function() {
  if (window.FrescoLoaded) return;
  window.FrescoLoaded = true;

  // ===========================================================================
  // Lazy OSD load
  // ===========================================================================

  var OSD_VERSION = "4.1.0";
  var OSD_CDN = "https://cdn.jsdelivr.net/npm/openseadragon@" + OSD_VERSION +
                "/build/openseadragon/openseadragon.min.js";
  var osdLoading = false;
  var osdLoadCallbacks = [];

  function loadOSD(callback) {
    if (window.OpenSeadragon) { callback(); return; }
    osdLoadCallbacks.push(callback);
    if (osdLoading) return;
    osdLoading = true;

    var script = document.createElement("script");
    script.src = OSD_CDN;
    script.onload = function() {
      osdLoadCallbacks.forEach(function(cb) { cb(); });
      osdLoadCallbacks = [];
    };
    script.onerror = function() {
      console.error("[Fresco] Failed to load OpenSeadragon from CDN");
    };
    document.head.appendChild(script);
  }

  // ===========================================================================
  // Extension surface
  // ===========================================================================

  var viewerRegistry = {};        // domId → viewer handle
  var readyCallbacks = {};        // domId → [callback, …]
  var sourceProviders = [];       // [{predicate, factory}]

  // Default source provider — last in the chain. Handles plain image URLs.
  sourceProviders.push({
    predicate: function() { return true; },
    factory: function(url) { return { type: "image", url: url }; }
  });

  function resolveTileSource(url) {
    for (var i = 0; i < sourceProviders.length; i++) {
      if (sourceProviders[i].predicate(url)) {
        return sourceProviders[i].factory(url);
      }
    }
    return { type: "image", url: url };
  }

  window.Fresco = {
    viewerFor: function(domId) {
      return viewerRegistry[domId] || null;
    },

    onViewerReady: function(domId, callback) {
      var handle = viewerRegistry[domId];
      if (handle) { callback(handle); return; }
      readyCallbacks[domId] = readyCallbacks[domId] || [];
      readyCallbacks[domId].push(callback);
    },

    // Register a source provider. Predicate is called with the source URL
    // before the default provider; first match wins. Providers added later
    // take precedence over the default (which always matches).
    registerSourceProvider: function(predicate, factory) {
      // Insert at the front so it beats the default catch-all.
      sourceProviders.unshift({ predicate: predicate, factory: factory });
    }
  };

  function publishReady(domId, handle) {
    viewerRegistry[domId] = handle;
    var cbs = readyCallbacks[domId] || [];
    delete readyCallbacks[domId];
    cbs.forEach(function(cb) { cb(handle); });
  }

  function unpublish(domId) {
    delete viewerRegistry[domId];
  }

  // ===========================================================================
  // Heroicons (outline, 24×24, stroke="currentColor")
  // ===========================================================================

  var ICONS = {
    zoomIn:  '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607ZM10.5 7.5v6m3-3h-6"/></svg>',
    zoomOut: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607ZM13.5 10.5h-6"/></svg>',
    reset:   '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>',
    expand:  '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"/></svg>'
  };

  var stylesInjected = false;
  function injectStyles() {
    if (stylesInjected) return;
    stylesInjected = true;

    var css = [
      ".fresco-nav {",
      "  position: absolute; top: 12px; left: 12px; z-index: 10;",
      "  display: flex; flex-direction: column; gap: 6px;",
      "  pointer-events: auto;",
      "}",
      ".fresco-nav button {",
      "  width: 36px; height: 36px;",
      "  display: inline-flex; align-items: center; justify-content: center;",
      "  border: none; padding: 0; cursor: pointer;",
      "  background: rgba(0, 0, 0, 0.55); color: #fff;",
      "  border-radius: 8px;",
      "  transition: background 120ms ease;",
      "}",
      ".fresco-nav button:hover { background: rgba(0, 0, 0, 0.78); }",
      ".fresco-nav button:focus-visible {",
      "  outline: 2px solid rgba(255, 255, 255, 0.7); outline-offset: 1px;",
      "}",
      ".fresco-nav svg { width: 18px; height: 18px; }"
    ].join("\n");

    var style = document.createElement("style");
    style.setAttribute("data-fresco", "");
    style.textContent = css;
    document.head.appendChild(style);
  }

  function makeButton(svg, title, onClick) {
    var btn = document.createElement("button");
    btn.type = "button";
    btn.title = title;
    btn.setAttribute("aria-label", title);
    btn.innerHTML = svg;
    btn.addEventListener("click", function(e) {
      e.preventDefault();
      e.stopPropagation();
      onClick();
    });
    return btn;
  }

  function buildNav(viewer, container) {
    injectStyles();

    var nav = document.createElement("div");
    nav.className = "fresco-nav";

    var zoomFactor = 1.4;

    nav.appendChild(makeButton(ICONS.zoomIn, "Zoom in", function() {
      viewer.viewport.zoomBy(zoomFactor);
      viewer.viewport.applyConstraints();
    }));

    nav.appendChild(makeButton(ICONS.zoomOut, "Zoom out", function() {
      viewer.viewport.zoomBy(1 / zoomFactor);
      viewer.viewport.applyConstraints();
    }));

    nav.appendChild(makeButton(ICONS.reset, "Reset view", function() {
      viewer.viewport.goHome();
    }));

    nav.appendChild(makeButton(ICONS.expand, "Toggle fullscreen", function() {
      viewer.setFullPage(!viewer.isFullPage());
    }));

    if (getComputedStyle(container).position === "static") {
      container.style.position = "relative";
    }
    container.appendChild(nav);

    return nav;
  }

  // ===========================================================================
  // Bounds-preserving source swap utility
  // ===========================================================================

  // Open a new tile source on an active viewer while preserving the
  // user's current viewport (pan + zoom). Used by extensions like Tessera
  // when swapping between resolution layers without jarring the user.
  function swapSourcePreservingBounds(viewer, url) {
    var keepBounds = viewer.viewport.getBounds();
    viewer.addOnceHandler("open", function() {
      try { viewer.viewport.fitBounds(keepBounds, true); } catch (_) {}
    });
    try { viewer.open(resolveTileSource(url)); } catch (_) { /* ignore */ }
  }

  // ===========================================================================
  // The viewer handle exposed via Fresco.viewerFor
  // ===========================================================================

  function makeHandle(viewer, container) {
    var subscribers = {};   // eventName → [handler, …]

    // Bridge OSD events to our subscriber list.
    function bridge(osdEvent, ourEvent) {
      viewer.addHandler(osdEvent, function(e) {
        var arr = subscribers[ourEvent] || [];
        for (var i = 0; i < arr.length; i++) {
          try { arr[i](e); } catch (_) {}
        }
      });
    }

    bridge("zoom", "zoom");
    bridge("pan", "pan");
    bridge("open", "open");
    bridge("resize", "resize");

    return {
      viewer: viewer,
      container: container,

      imageToScreen: function(pt) {
        return viewer.viewport.viewportToWindowCoordinates(
          viewer.viewport.imageToViewportCoordinates(pt.x, pt.y)
        );
      },

      screenToImage: function(pt) {
        return viewer.viewport.viewportToImageCoordinates(
          viewer.viewport.windowToViewportCoordinates(new window.OpenSeadragon.Point(pt.x, pt.y))
        );
      },

      getViewportBounds: function() {
        return viewer.viewport.getBounds();
      },

      fitBounds: function(rect, immediately) {
        viewer.viewport.fitBounds(rect, !!immediately);
      },

      setSource: function(url) {
        try { viewer.open(resolveTileSource(url)); } catch (_) {}
      },

      swapSourcePreservingBounds: function(url) {
        swapSourcePreservingBounds(viewer, url);
      },

      on: function(eventName, handler) {
        subscribers[eventName] = subscribers[eventName] || [];
        subscribers[eventName].push(handler);
        return function unsubscribe() {
          var arr = subscribers[eventName] || [];
          var idx = arr.indexOf(handler);
          if (idx !== -1) arr.splice(idx, 1);
        };
      }
    };
  }

  // ===========================================================================
  // FrescoViewer LiveView hook
  // ===========================================================================

  window.FrescoHooks = window.FrescoHooks || {};

  window.FrescoHooks.FrescoViewer = {
    mounted: function() {
      var self = this;
      loadOSD(function() {
        if (!self.el.isConnected) return;

        var src = self.el.dataset.src;
        if (!src) {
          console.warn("[Fresco] Missing data-src on element", self.el);
          return;
        }

        self.currentSrc = src;

        self.viewer = window.OpenSeadragon({
          element: self.el,
          tileSources: resolveTileSource(src),

          // Our Heroicons overlay replaces the built-in PNG-sprite nav.
          showNavigationControl: false,

          // Snappier than defaults (1.2s / 6.5) — tracks user input directly
          // without going fully instant.
          animationTime: 0.3,
          springStiffness: 10,

          // Reasonable headroom past native resolution for any consumer.
          // Extensions like Tessera can override per-layer if they want
          // tighter bounds.
          maxZoomPixelRatio: 8,

          // Clamp the image to the viewer rectangle — no off-screen drift,
          // no half-image floating in empty space.
          visibilityRatio: 1.0,
          constrainDuringPan: true,

          gestureSettingsTouch: { pinchToZoom: true, dragToPan: true },
          gestureSettingsMouse: {
            scrollToZoom: true,
            dragToPan: true,
            clickToZoom: true,
            dblClickToZoom: true
          }
        });

        // Built-in nav overlay (zoom in/out/home/fullscreen).
        self.nav = buildNav(self.viewer, self.el);

        // Publish the handle so extensions can attach.
        self.handle = makeHandle(self.viewer, self.el);
        publishReady(self.el.id, self.handle);
      });
    },

    updated: function() {
      if (!this.viewer) return;
      var newSrc = this.el.dataset.src;
      if (newSrc && newSrc !== this.currentSrc) {
        this.currentSrc = newSrc;
        swapSourcePreservingBounds(this.viewer, newSrc);
      }
    },

    destroyed: function() {
      if (this.el && this.el.id) unpublish(this.el.id);
      if (this.nav && this.nav.parentNode) {
        this.nav.parentNode.removeChild(this.nav);
      }
      this.nav = null;
      if (this.viewer) {
        try { this.viewer.destroy(); } catch (_) {}
        this.viewer = null;
      }
    }
  };
})();