// Tessera — LiveView hook that mounts OpenSeadragon on an image source.
//
// Lazy-loads OpenSeadragon from jsDelivr on first use, then initializes a
// viewer per element bearing `phx-hook="TesseraViewer"`. The element's
// `data-src` attribute is the source URL: a `.dzi` manifest for deep zoom,
// or a plain image (`.jpg`, `.png`, etc.) for basic pan + zoom.
//
// We disable OSD's built-in navigation controls (which ship as PNG sprites)
// and render our own Heroicons-based overlay instead — looks better against
// arbitrary image content and avoids the prefixUrl/CDN dance.
//
// Parent app wiring:
// import "../../deps/tessera/priv/static/tessera.js"
// hooks: { ...window.TesseraHooks, ...colocatedHooks }
(function() {
if (window.TesseraLoaded) return;
window.TesseraLoaded = true;
window.TesseraHooks = window.TesseraHooks || {};
var OSD_VERSION = "4.1.0";
var OSD_CDN = "https://cdn.jsdelivr.net/npm/openseadragon@" + OSD_VERSION + "/build/openseadragon/openseadragon.min.js";
var loading = false;
var callbacks = [];
function loadOSD(callback) {
if (window.OpenSeadragon) {
callback();
return;
}
callbacks.push(callback);
if (loading) return;
loading = true;
var script = document.createElement("script");
script.src = OSD_CDN;
script.onload = function() {
callbacks.forEach(function(cb) { cb(); });
callbacks = [];
};
script.onerror = function() {
console.error("[Tessera] Failed to load OpenSeadragon from CDN");
};
document.head.appendChild(script);
}
// ---------------------------------------------------------------------------
// 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>'
};
// ---------------------------------------------------------------------------
// Toolbar styles (injected once per page)
// ---------------------------------------------------------------------------
var stylesInjected = false;
function injectStyles() {
if (stylesInjected) return;
stylesInjected = true;
var css = [
".tessera-nav {",
" position: absolute; top: 12px; left: 12px; z-index: 10;",
" display: flex; flex-direction: column; gap: 6px;",
" pointer-events: auto;",
"}",
".tessera-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;",
"}",
".tessera-nav button:hover { background: rgba(0, 0, 0, 0.78); }",
".tessera-nav button:focus-visible {",
" outline: 2px solid rgba(255, 255, 255, 0.7); outline-offset: 1px;",
"}",
".tessera-nav svg { width: 18px; height: 18px; }"
].join("\n");
var style = document.createElement("style");
style.setAttribute("data-tessera", "");
style.textContent = css;
document.head.appendChild(style);
}
// ---------------------------------------------------------------------------
// Toolbar construction
// ---------------------------------------------------------------------------
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 = "tessera-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());
}));
// The OSD root needs `position: relative` so our absolute nav is positioned
// against the viewer (not whatever ancestor happens to be relative).
if (getComputedStyle(container).position === "static") {
container.style.position = "relative";
}
container.appendChild(nav);
return nav;
}
// ---------------------------------------------------------------------------
// Source-type detection (DZI vs plain image)
// ---------------------------------------------------------------------------
function isDziUrl(url) {
if (!url) return false;
var qIdx = url.indexOf("?");
var path = qIdx === -1 ? url : url.substring(0, qIdx);
return path.toLowerCase().endsWith(".dzi");
}
function tileSourceFor(url) {
if (isDziUrl(url)) return url;
return { type: "image", url: url };
}
// Swap an open OSD viewer's tile source while preserving where the
// user was zoomed/panned. open() resets to home by default; we capture
// the current bounds and restore them on the new source's open event
// without animation, so visually it's just the image getting sharper
// (or fuzzier) — no jump back to fit-to-view.
function swapSourcePreservingBounds(viewer, url) {
var keepBounds = viewer.viewport.getBounds();
viewer.addOnceHandler("open", function() {
try { viewer.viewport.fitBounds(keepBounds, true); } catch (_) {}
});
try { viewer.open(tileSourceFor(url)); } catch (_) { /* ignore */ }
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
// Multiplier on each non-DZI layer's strict 1:1 zoom point. A small
// amount of upscaling is invisible; this gives every layer a 2× zoom
// budget past its strict threshold before we swap up.
var UPGRADE_HEADROOM = 2.0;
// Downgrade hysteresis: only fall back to a lower layer when zoom is
// 15% below the previous layer's upgrade threshold. Prevents flicker
// when the user oscillates around a boundary.
var DOWNGRADE_HYSTERESIS = 0.85;
function parseSources(el) {
var raw = el.dataset.sources;
if (!raw) return null;
try {
var parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
} catch (_) {
console.warn("[Tessera] Failed to parse data-sources", raw);
}
return null;
}
// For each source, the zoom ratio (over home zoom) at which the layer
// should swap to the next one up. DZI / unknown-width sources get
// Infinity — they always count as the top layer.
function computeThresholds(sources, containerWidth) {
if (!containerWidth || containerWidth <= 0) {
return sources.map(function() { return Infinity; });
}
return sources.map(function(source) {
if (!source.width || isDziUrl(source.url)) return Infinity;
return (source.width / containerWidth) * UPGRADE_HEADROOM;
});
}
// Pick which layer index is appropriate for the current zoom ratio,
// applying hysteresis around the boundary between `currentLayer` and
// `currentLayer - 1`. Returns an index in [0, sources.length).
function pickLayer(currentLayer, ratio, thresholds) {
var next = currentLayer;
// Upgrade as far as needed (handles fast zooms that skip multiple layers).
while (next + 1 < thresholds.length && ratio > thresholds[next]) {
next += 1;
}
// Downgrade with hysteresis. Don't downgrade past 0.
while (next > 0 && ratio < thresholds[next - 1] * DOWNGRADE_HYSTERESIS) {
next -= 1;
}
return next;
}
window.TesseraHooks.TesseraViewer = {
mounted: function() {
var self = this;
loadOSD(function() {
// Element may be gone (modal closed, navigation) before OSD loads.
if (!self.el.isConnected) return;
var sources = parseSources(self.el);
if (!sources) {
console.warn("[Tessera] Missing or invalid data-sources on element", self.el);
return;
}
self.sources = sources;
self.currentLayer = 0;
self.thresholds = computeThresholds(sources, self.el.clientWidth);
self.swapDebounce = null;
self.viewer = window.OpenSeadragon({
element: self.el,
tileSources: tileSourceFor(sources[0].url),
// Built-in PNG sprite nav is replaced by our heroicon overlay.
showNavigationControl: false,
// Default 1.1 barely lets you zoom past 100% of native resolution.
// 8x is enough headroom for inspecting detail in plain-image mode
// (DZI is naturally capped by its tile pyramid's max level).
maxZoomPixelRatio: 8,
// Snappier feel: tighten the spring + cut tween duration. Default
// (1.2s / 6.5) feels like slow-motion drift; these values track
// user input more directly without going fully instant.
animationTime: 0.3,
springStiffness: 10,
// Keep the image fully clamped to the viewer rectangle. OSD's
// defaults (visibilityRatio 0.5, constrainDuringPan false) let
// the user drag the image until only half of it is on-screen
// and only snap back on release — felt loose and floaty.
// 1.0 + true together pin the image's edges to the viewport edges
// when zoomed out, and pin the viewport inside the image bounds
// when zoomed in. Either way, no empty space drift.
visibilityRatio: 1.0,
constrainDuringPan: true,
gestureSettingsTouch: { pinchToZoom: true, dragToPan: true },
gestureSettingsMouse: { scrollToZoom: true, dragToPan: true, clickToZoom: true, dblClickToZoom: true }
});
self.nav = buildNav(self.viewer, self.el);
// Recompute thresholds when the container resizes (modal fullscreen
// toggle, browser window resize). Layers themselves don't change —
// just the zoom ratios at which we swap between them.
if (typeof ResizeObserver === "function") {
self.resizeObserver = new ResizeObserver(function() {
self.thresholds = computeThresholds(self.sources, self.el.clientWidth);
});
self.resizeObserver.observe(self.el);
}
// Multi-layer progressive zoom. As the user zooms, swap to whichever
// layer's native resolution best matches the viewport's current
// rendered pixel density. Each layer covers a zoom band:
//
// layer 0 (lowest quality) | up to thresholds[0]
// layer 1 | thresholds[0] .. thresholds[1]
// ...
// layer N-1 (top, usually | thresholds[N-2] .. ∞
// a DZI source)
//
// The decision is debounced 200ms after the last zoom event.
// viewer.open() fires transient zoom events at the new source's
// home zoom before fitBounds restores the user's position; the
// debounce lets the viewport settle so those transients don't
// re-trigger a swap.
//
// Viewport bounds are preserved across each swap so the user
// never sees a jump to home.
if (sources.length > 1) {
self.viewer.addHandler("zoom", function() {
if (self.swapDebounce) clearTimeout(self.swapDebounce);
self.swapDebounce = setTimeout(function() {
self.swapDebounce = null;
if (!self.viewer || !self.viewer.viewport) return;
var currentZoom = self.viewer.viewport.getZoom();
var homeZoom = self.viewer.viewport.getHomeZoom();
var ratio = currentZoom / homeZoom;
var nextLayer = pickLayer(self.currentLayer, ratio, self.thresholds);
if (nextLayer !== self.currentLayer) {
self.currentLayer = nextLayer;
swapSourcePreservingBounds(self.viewer, self.sources[nextLayer].url);
}
}, 200);
});
}
});
},
destroyed: function() {
if (this.swapDebounce) {
clearTimeout(this.swapDebounce);
this.swapDebounce = null;
}
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.nav && this.nav.parentNode) {
this.nav.parentNode.removeChild(this.nav);
}
this.nav = null;
if (this.viewer) {
try { this.viewer.destroy(); } catch (e) { /* ignore */ }
this.viewer = null;
}
}
};
})();