← Public packages
@kentcdodds/christ-paintings
Official Latter-day Saint paintings of Jesus Christ, one at a time, to show children during church, plus coloring pages of the Savior. Swipe to move. Works offline.
src/client-script.ts
251 lines · 8.6 KB · TypeScript// 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 })
})()
`