// Viewer script, inlined on the main page. Plain JS in a raw string: no backticks or // template placeholders inside. export const CLIENT_JS = String.raw` (function () { 'use strict' var KEY_CURRENT = 'christ-paintings:current' var KEY_CAPTION = 'christ-paintings:caption' var GAP = 24 var DURATION = 340 var EASE = 'cubic-bezier(0.22, 0.61, 0.36, 1)' var root = document.documentElement var appBase = root.getAttribute('data-app-base') || '' var paintings = JSON.parse(document.getElementById('paintings-data').textContent || '[]') var count = paintings.length var viewer = document.getElementById('viewer') var caption = document.getElementById('caption') var capTitle = document.getElementById('cap-title') var capArtist = document.getElementById('cap-artist') var aboutLink = document.getElementById('about-link') var coloringLink = document.getElementById('coloring-link') var slides = Array.prototype.slice.call(document.querySelectorAll('.slide')) var state = { index: 0, id: '', count: count, captionHidden: false, animating: false, dragging: false, moves: 0, wakeLock: 'unsupported', loaded: {}, } window.christPaintings = { state: state, next: function () { go(1) }, prev: function () { go(-1) }, show: show } function wrap(i) { return ((i % count) + count) % count } function src(i) { return appBase + '/img/p/' + paintings[wrap(i)].id + '.jpg' } function readStore(key) { try { return localStorage.getItem(key) } catch (e) { return null } } function writeStore(key, value) { try { localStorage.setItem(key, value) } catch (e) {} } var saved = readStore(KEY_CURRENT) for (var s = 0; s < count; s++) if (paintings[s].id === saved) state.index = s state.captionHidden = readStore(KEY_CAPTION) === 'hidden' // slides[k] holds painting index + offset[k]; offsets are -1, 0, 1. var offsets = [-1, 0, 1] function width() { return viewer.clientWidth || window.innerWidth } function place(dx, animate) { var w = width() slides.forEach(function (el, k) { el.style.transition = animate ? 'transform ' + DURATION + 'ms ' + EASE : 'none' el.style.transform = 'translate3d(' + (offsets[k] * (w + GAP) + dx) + 'px,0,0)' el.setAttribute('aria-hidden', offsets[k] === 0 ? 'false' : 'true') }) } function setImage(el, i) { var img = el.querySelector('img') var p = paintings[wrap(i)] var url = src(i) if (img.getAttribute('data-id') === p.id) return img.classList.remove('ready') img.setAttribute('data-id', p.id) img.alt = p.alt || p.title img.width = p.width img.height = p.height img.onload = function () { if (img.getAttribute('data-id') !== p.id) return state.loaded[p.id] = true img.classList.add('ready') } img.src = url if (img.complete && img.naturalWidth) img.onload() } var warm = {} function preload(i) { var url = src(i) if (warm[url]) return warm[url] = new Image() warm[url].decoding = 'async' warm[url].src = url } function renderCaption() { var p = paintings[state.index] capTitle.textContent = p.title capArtist.textContent = p.artist caption.classList.toggle('hidden', state.captionHidden) caption.setAttribute('aria-hidden', state.captionHidden ? 'true' : 'false') aboutLink.tabIndex = state.captionHidden ? -1 : 0 if (coloringLink) coloringLink.tabIndex = state.captionHidden ? -1 : 0 } function render() { slides.forEach(function (el, k) { setImage(el, state.index + offsets[k]) }) preload(state.index + 2) preload(state.index - 2) state.id = paintings[state.index].id writeStore(KEY_CURRENT, state.id) renderCaption() viewer.setAttribute('aria-label', 'Painting ' + (state.index + 1) + ' of ' + count + ': ' + paintings[state.index].title) place(0, false) } function show(i) { state.index = wrap(i) render() } // Slide the track one step (dir 1 = next, -1 = previous), then recycle the slide // that went off screen so the next and previous paintings are always loaded. var settleTimer = null function go(dir, fromDx) { if (!count) return if (state.animating) finish() state.animating = true var w = width() place(fromDx || 0, false) void viewer.offsetWidth place(-dir * (w + GAP), true) pending = dir settleTimer = setTimeout(finish, DURATION + 40) } var pending = 0 function finish() { clearTimeout(settleTimer) if (!pending) { state.animating = false; return } var dir = pending pending = 0 state.index = wrap(state.index + dir) state.moves++ offsets = offsets.map(function (o) { var n = o - dir if (n < -1) n = 1 if (n > 1) n = -1 return n }) state.animating = false render() } function snapBack(fromDx) { place(fromDx, false) void viewer.offsetWidth place(0, true) } function toggleCaption() { state.captionHidden = !state.captionHidden writeStore(KEY_CAPTION, state.captionHidden ? 'hidden' : 'shown') renderCaption() } // ---------- Pointer gestures ---------- var start = null var samples = [] viewer.addEventListener('pointerdown', function (e) { if (e.target.closest && e.target.closest('a')) return if (start || (e.pointerType === 'mouse' && e.button !== 0)) return requestWakeLock() if (state.animating) finish() start = { id: e.pointerId, x: e.clientX, y: e.clientY, t: e.timeStamp, dx: 0, horizontal: null } samples = [{ x: e.clientX, t: e.timeStamp }] try { viewer.setPointerCapture(e.pointerId) } catch (err) {} }) viewer.addEventListener('pointermove', function (e) { if (!start || e.pointerId !== start.id) return var dx = e.clientX - start.x var dy = e.clientY - start.y if (start.horizontal === null && (Math.abs(dx) > 8 || Math.abs(dy) > 8)) { start.horizontal = Math.abs(dx) >= Math.abs(dy) } samples.push({ x: e.clientX, t: e.timeStamp }) while (samples.length > 2 && e.timeStamp - samples[0].t > 100) samples.shift() if (start.horizontal) { state.dragging = true viewer.classList.add('dragging') start.dx = dx place(dx, false) } }) function end(e, cancelled) { if (!start || e.pointerId !== start.id) return var s = start start = null state.dragging = false viewer.classList.remove('dragging') var w = width() var dx = e.clientX - s.x var dy = e.clientY - s.y if (cancelled) { if (s.horizontal) snapBack(s.dx); return } if (s.horizontal) { var first = samples[0] var dt = Math.max(1, e.timeStamp - first.t) var v = (e.clientX - first.x) / dt var commit = Math.abs(dx) > w * 0.18 || (Math.abs(v) > 0.35 && Math.abs(dx) > 24) if (commit && dx !== 0) go(dx < 0 ? 1 : -1, dx) else snapBack(dx) return } var moved = Math.abs(dx) > 10 || Math.abs(dy) > 10 if (moved || e.timeStamp - s.t > 600) return var rect = viewer.getBoundingClientRect() var x = (e.clientX - rect.left) / rect.width if (x < 0.22) go(-1) else if (x > 0.78) go(1) else toggleCaption() } viewer.addEventListener('pointerup', function (e) { end(e, false) }) viewer.addEventListener('pointercancel', function (e) { end(e, true) }) viewer.addEventListener('contextmenu', function (e) { e.preventDefault() }) document.addEventListener('keydown', function (e) { if (e.altKey || e.ctrlKey || e.metaKey) return if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); go(1) } else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); go(-1) } else if (e.key === 'Home') { e.preventDefault(); show(0) } else if (e.key === 'End') { e.preventDefault(); show(count - 1) } }) window.addEventListener('resize', function () { if (!start) place(0, false) }) // ---------- Keep the screen on while viewing ---------- var lock = null function requestWakeLock() { if (!('wakeLock' in navigator)) { state.wakeLock = 'unsupported'; return } if (lock || document.visibilityState !== 'visible') return navigator.wakeLock.request('screen').then(function (l) { lock = l state.wakeLock = 'active' l.addEventListener('release', function () { lock = null; state.wakeLock = 'released' }) }, function () { state.wakeLock = 'denied' }) } document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible') requestWakeLock() }) // ---------- Offline: ask the service worker to save every image ---------- function warmCache() { if (!('serviceWorker' in navigator)) return navigator.serviceWorker.ready.then(function (reg) { var worker = reg.active || navigator.serviceWorker.controller if (!worker) return worker.postMessage({ type: 'WARM_ALL' }) }).catch(function () {}) } render() requestWakeLock() if (document.readyState === 'complete') setTimeout(warmCache, 1500) else window.addEventListener('load', function () { setTimeout(warmCache, 1500) }) viewer.focus({ preventScroll: true }) })() `