Skip to content
← 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.

FollowForkOpen app

src/coloring-page.ts

685 lines · 31.6 KB · TypeScript
Raw
import { COLORING_PAGES } from './coloring.ts'
import { imagePath } from './images.ts'

// Coloring view: a swipeable picker of official Church coloring pages and a simple
// touch editor (tap to fill, brush, eraser, undo, clear, save or share). Plain CSS and
// JS in raw strings: no backticks or template placeholders inside them.

export const COLORS = [
	['Red', '#e53935'],
	['Orange', '#fb8c00'],
	['Yellow', '#fdd835'],
	['Light green', '#9ccc65'],
	['Green', '#43a047'],
	['Teal', '#26a69a'],
	['Sky blue', '#4fc3f7'],
	['Blue', '#1e88e5'],
	['Purple', '#8e24aa'],
	['Pink', '#f48fb1'],
	['Brown', '#8d6e63'],
	['Peach', '#f5cba7'],
	['Tan', '#c68642'],
	['Gray', '#9e9e9e'],
	['Black', '#212121'],
	['White', '#ffffff'],
] as const

const ICONS = {
	back: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>',
	next: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>',
	frame: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="4.5" width="17" height="15" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M6 17l4.5-5 3 3.2 2-2.2L19 17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>',
	undo: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 7H5V3" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.5 7.2A8 8 0 1 1 4 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>',
	trash: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
	share: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V3M7.5 7.5L12 3l4.5 4.5M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
	fill: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 11.5L11 5l7 7-6.5 6.5a2 2 0 0 1-2.8 0L4.5 14.3a2 2 0 0 1 0-2.8z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M4.5 12.5h13" stroke="currentColor" stroke-width="2"/><path d="M20 15.5s1.8 2.2 1.8 3.4a1.8 1.8 0 0 1-3.6 0c0-1.2 1.8-3.4 1.8-3.4z" fill="currentColor"/></svg>',
	eraser: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.5 20H20M3.9 14.6l9.2-9.2a2 2 0 0 1 2.8 0l3.7 3.7a2 2 0 0 1 0 2.8L12 19.5H8.6l-4.7-4.7a.1.1 0 0 1 0-.2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M9 9.5l5.5 5.5" stroke="currentColor" stroke-width="2"/></svg>',
}

export function coloringBodyHtml(base: string) {
	const data = JSON.stringify(
		COLORING_PAGES.map((c) => ({ id: c.id, title: c.title, alt: c.alt, width: c.width, height: c.height, src: base + imagePath('c', c.id) })),
	).replace(/</g, '\\u003c')
	const swatches = COLORS.map(
		([name, hex], i) =>
			`<button type="button" class="swatch${i === 0 ? ' on' : ''}" data-color="${hex}" aria-label="${name}" aria-pressed="${i === 0}" style="--c:${hex}"></button>`,
	).join('')
	const sizes = [
		['small', 'Small brush', 8],
		['medium', 'Medium brush', 16],
		['large', 'Large brush', 26],
	]
		.map(
			([id, label, dot]) =>
				`<button type="button" class="tool" data-tool="brush" data-size="${id}" aria-label="${label}" aria-pressed="false"><span class="dot" style="width:${dot}px;height:${dot}px"></span></button>`,
		)
		.join('')
	return `<div id="picker" class="picker">
	<header class="bar">
		<a class="btn" href="${base}/" aria-label="Back to paintings">${ICONS.frame}</a>
		<span class="bar-title">Coloring</span>
		<span id="pk-count" class="bar-count"></span>
	</header>
	<div id="track" class="track" tabindex="0" aria-label="Coloring pages, swipe to choose"></div>
	<footer class="pick-actions">
		<button type="button" id="pk-prev" class="btn" aria-label="Previous page">${ICONS.back}</button>
		<button type="button" id="pk-open" class="primary">Color this page</button>
		<button type="button" id="pk-next" class="btn" aria-label="Next page">${ICONS.next}</button>
	</footer>
</div>
<div id="editor" class="editor" hidden>
	<header class="bar">
		<button type="button" id="ed-back" class="btn" aria-label="Back to the coloring pages">${ICONS.back}</button>
		<span id="ed-title" class="bar-title"></span>
		<button type="button" id="ed-undo" class="btn" aria-label="Undo" disabled>${ICONS.undo}</button>
		<button type="button" id="ed-clear" class="btn" aria-label="Clear the page">${ICONS.trash}</button>
		<button type="button" id="ed-share" class="btn" aria-label="Save or share the picture">${ICONS.share}</button>
	</header>
	<div id="stage" class="stage">
		<div id="sheet" class="sheet"><canvas id="paint"></canvas><img id="lines" alt="" draggable="false" /></div>
	</div>
	<footer class="tools">
		<div class="toolrow" role="group" aria-label="Tools">
			<button type="button" class="tool on" data-tool="fill" aria-label="Fill" aria-pressed="true">${ICONS.fill}</button>
			${sizes}
			<button type="button" class="tool" data-tool="eraser" aria-label="Eraser" aria-pressed="false">${ICONS.eraser}</button>
		</div>
		<div class="palette" role="group" aria-label="Colors">${swatches}</div>
	</footer>
	<div id="confirm" class="confirm" hidden role="dialog" aria-modal="true" aria-labelledby="confirm-text">
		<div class="confirm-box">
			<p id="confirm-text">Clear this page and start over?</p>
			<div class="confirm-actions"><button type="button" id="confirm-no" class="secondary">Keep it</button><button type="button" id="confirm-yes" class="danger">Clear</button></div>
		</div>
	</div>
	<div id="toast" class="toast" hidden></div>
</div>
<script id="coloring-data" type="application/json">${data}</script>`
}

export const COLORING_CSS = String.raw`
:root { color-scheme: light; --ink: #2b2a33; --muted: #6b6878; --bar: #f6f2ea; --line: rgba(0, 0, 0, 0.08); --accent: #8a6d3b; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #ece6da; color: var(--ink); overflow: hidden; overscroll-behavior: none; -webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
body { font: 16px/1.3 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
button { font: inherit; color: inherit; }
[hidden] { display: none !important; }
.picker, .editor { position: fixed; inset: 0; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr); }
.bar { display: flex; align-items: center; gap: 6px; padding: calc(6px + env(safe-area-inset-top, 0px)) calc(8px + env(safe-area-inset-right, 0px)) 6px calc(8px + env(safe-area-inset-left, 0px)); background: var(--bar); border-bottom: 1px solid var(--line); min-height: 60px; }
.bar-title { flex: 1 1 auto; min-width: 0; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 4px; }
.bar-count { color: var(--muted); font-size: 14px; padding-right: 6px; }
.btn { flex: 0 0 auto; width: 52px; height: 52px; border: 0; border-radius: 16px; background: transparent; display: inline-flex; align-items: center; justify-content: center; color: var(--ink); text-decoration: none; cursor: pointer; }
.btn svg { width: 28px; height: 28px; }
.btn:active { background: rgba(0, 0, 0, 0.08); }
.btn:disabled { opacity: 0.3; }
.track { display: flex; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; outline: none; }
.track::-webkit-scrollbar { display: none; }
.card { flex: 0 0 100%; scroll-snap-align: center; scroll-snap-stop: always; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 14px 18px 6px; border: 0; background: none; cursor: pointer; min-height: 0; }
.card .art { position: relative; flex: 1 1 auto; min-height: 0; width: 100%; display: flex; align-items: center; justify-content: center; }
.card .page { position: relative; background: #fff; box-shadow: 0 2px 14px rgba(0, 0, 0, 0.14); border-radius: 6px; overflow: hidden; max-width: 100%; max-height: 100%; }
.card .page img { display: block; position: absolute; inset: 0; width: 100%; height: 100%; }
.card .page img.lines { mix-blend-mode: multiply; }
.card .t { font-size: 15px; font-weight: 500; text-align: center; color: var(--ink); max-width: 30rem; }
.pick-actions { display: flex; align-items: center; justify-content: center; gap: 14px; padding: 8px 12px calc(12px + env(safe-area-inset-bottom, 0px)); }
.primary { min-height: 56px; padding: 0 26px; border: 0; border-radius: 28px; background: var(--accent); color: #fff; font-size: 17px; font-weight: 600; cursor: pointer; box-shadow: 0 2px 8px rgba(138, 109, 59, 0.35); }
.primary:active { transform: scale(0.98); }
.stage { position: relative; min-height: 0; min-width: 0; width: 100%; display: flex; align-items: center; justify-content: center; padding: 8px; overflow: hidden; touch-action: none; }
.sheet { position: relative; background: #fff; box-shadow: 0 2px 14px rgba(0, 0, 0, 0.14); border-radius: 4px; overflow: hidden; touch-action: none; }
.sheet canvas, .sheet img { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.sheet img { mix-blend-mode: multiply; pointer-events: none; -webkit-user-drag: none; }
.tools { min-width: 0; background: var(--bar); border-top: 1px solid var(--line); padding: 8px calc(8px + env(safe-area-inset-right, 0px)) calc(8px + env(safe-area-inset-bottom, 0px)) calc(8px + env(safe-area-inset-left, 0px)); display: flex; flex-direction: column; gap: 8px; }
.toolrow { display: flex; justify-content: center; gap: 8px; }
.tool { width: 56px; height: 52px; border-radius: 16px; border: 2px solid transparent; background: #fff; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); }
.tool svg { width: 28px; height: 28px; }
.tool .dot { display: block; border-radius: 50%; background: var(--current, #e53935); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15); }
.tool.on { border-color: var(--ink); }
.palette { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 6px; max-width: 34rem; width: 100%; margin: 0 auto; }
.swatch { width: 100%; max-width: 52px; min-width: 0; aspect-ratio: 1; border-radius: 50%; border: 3px solid #fff; background: var(--c); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); cursor: pointer; padding: 0; justify-self: center; }
@media (max-width: 379px) { .palette { grid-template-columns: repeat(6, minmax(0, 1fr)); } }
.swatch.on { box-shadow: 0 0 0 3px var(--ink); transform: scale(1.06); }
.confirm { position: fixed; inset: 0; z-index: 20; background: rgba(20, 18, 24, 0.45); display: flex; align-items: center; justify-content: center; padding: 20px; }
.confirm-box { background: #fff; border-radius: 22px; padding: 22px; width: min(22rem, 100%); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25); text-align: center; }
.confirm-box p { margin: 0 0 18px; font-size: 18px; font-weight: 500; }
.confirm-actions { display: flex; gap: 12px; }
.confirm-actions button { flex: 1; min-height: 56px; border-radius: 18px; border: 0; font-size: 17px; font-weight: 600; cursor: pointer; }
.secondary { background: #ece6da; }
.danger { background: #c0392b; color: #fff; }
.toast { position: fixed; left: 50%; bottom: calc(150px + env(safe-area-inset-bottom, 0px)); transform: translateX(-50%); background: rgba(30, 28, 36, 0.9); color: #fff; padding: 10px 16px; border-radius: 14px; font-size: 15px; z-index: 15; }
@media (orientation: landscape) and (max-height: 560px) {
	.editor { grid-template-rows: auto minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) auto; }
	.editor .bar { grid-column: 1 / -1; min-height: 52px; padding-top: 2px; padding-bottom: 2px; }
	.editor .btn { height: 46px; }
	.tools { grid-row: 2; grid-column: 2; border-top: 0; border-left: 1px solid var(--line); flex-direction: row; overflow-y: auto; padding-top: 8px; }
	.toolrow { flex-direction: column; justify-content: flex-start; }
	.tool { width: 52px; height: 46px; }
	.palette { grid-template-columns: repeat(4, 44px); align-content: start; width: auto; gap: 6px; }
	.swatch { width: 44px; }
	.picker .bar { min-height: 50px; padding-top: 2px; padding-bottom: 2px; }
	.pick-actions { padding-top: 4px; padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px)); }
	.primary { min-height: 48px; }
}
`

export const COLORING_JS = String.raw`
(function () {
	'use strict'
	var root = document.documentElement
	var base = root.getAttribute('data-app-base') || ''
	var pages = JSON.parse(document.getElementById('coloring-data').textContent || '[]')
	var KEY = 'christ-paintings:coloring'
	var MAX_SIDE = 1280
	var MAX_UNDO = 10
	var SIZES = { small: 8, medium: 18, large: 36 }
	var picker = document.getElementById('picker')
	var editor = document.getElementById('editor')
	var track = document.getElementById('track')
	var countEl = document.getElementById('pk-count')
	var stage = document.getElementById('stage')
	var sheet = document.getElementById('sheet')
	var paint = document.getElementById('paint')
	var lines = document.getElementById('lines')
	var titleEl = document.getElementById('ed-title')
	var undoBtn = document.getElementById('ed-undo')
	var confirmEl = document.getElementById('confirm')
	var toastEl = document.getElementById('toast')
	var ctx = paint.getContext('2d', { willReadFrequently: true })
	var state = { index: 0, editing: -1, tool: 'fill', size: 'medium', color: '#e53935', walls: null, undo: [], saving: null, dirty: false, lastFill: null }

	function clampIndex(i) { return Math.max(0, Math.min(pages.length - 1, i)) }
	try { state.index = clampIndex(parseInt(localStorage.getItem(KEY + ':index') || '0', 10) || 0) } catch (e) {}

	// IndexedDB: one record per page with the painted layer as a PNG blob.
	var dbPromise = null
	function db() {
		if (dbPromise) return dbPromise
		dbPromise = new Promise(function (resolve, reject) {
			if (!('indexedDB' in window)) return reject(new Error('no indexedDB'))
			var req = indexedDB.open('christ-paintings-coloring', 1)
			req.onupgradeneeded = function () { req.result.createObjectStore('pages') }
			req.onsuccess = function () { resolve(req.result) }
			req.onerror = function () { reject(req.error) }
		})
		return dbPromise
	}
	function idb(mode, fn) {
		return db().then(function (d) {
			return new Promise(function (resolve, reject) {
				var tx = d.transaction('pages', mode)
				var out = fn(tx.objectStore('pages'))
				tx.oncomplete = function () { resolve(out && 'result' in out ? out.result : undefined) }
				tx.onerror = function () { reject(tx.error) }
			})
		})
	}
	function loadSaved(id) { return idb('readonly', function (s) { return s.get(id) }).catch(function () { return null }) }
	function storeSaved(id, blob) { return idb('readwrite', function (s) { return s.put({ blob: blob, updated: Date.now() }, id) }).catch(function () {}) }
	function deleteSaved(id) { return idb('readwrite', function (s) { return s.delete(id) }).catch(function () {}) }

	// ---------- Picker ----------
	var cards = []
	function fitPage(card) {
		var art = card.querySelector('.art')
		var pageEl = card.querySelector('.page')
		var p = pages[+card.getAttribute('data-i')]
		var w = art.clientWidth, h = art.clientHeight
		if (!w || !h) return
		var s = Math.min(w / p.width, h / p.height)
		pageEl.style.width = Math.floor(p.width * s) + 'px'
		pageEl.style.height = Math.floor(p.height * s) + 'px'
	}
	function buildPicker() {
		pages.forEach(function (p, i) {
			var card = document.createElement('button')
			card.type = 'button'
			card.className = 'card'
			card.setAttribute('data-i', String(i))
			card.setAttribute('aria-label', 'Color ' + p.title)
			card.innerHTML = '<div class="art"><div class="page"><img class="paint" alt="" hidden /><img class="lines" alt="" draggable="false" /></div></div><span class="t"></span>'
			card.querySelector('.t').textContent = p.title
			var img = card.querySelector('.lines')
			img.alt = p.alt
			if (Math.abs(i - state.index) <= 1) img.src = p.src
			else img.setAttribute('data-src', p.src)
			card.addEventListener('click', function () { openEditor(i) })
			track.appendChild(card)
			cards.push(card)
		})
		refreshThumbs()
	}
	function loadNear(i) {
		for (var k = i - 1; k <= i + 1; k++) {
			var c = cards[k]
			if (!c) continue
			var img = c.querySelector('.lines')
			var ds = img.getAttribute('data-src')
			if (ds) { img.src = ds; img.removeAttribute('data-src') }
		}
	}
	var thumbUrls = {}
	function refreshThumb(i) {
		var p = pages[i]
		return loadSaved(p.id).then(function (rec) {
			var img = cards[i].querySelector('.paint')
			if (thumbUrls[p.id]) { URL.revokeObjectURL(thumbUrls[p.id]); delete thumbUrls[p.id] }
			if (rec && rec.blob) {
				thumbUrls[p.id] = URL.createObjectURL(rec.blob)
				img.src = thumbUrls[p.id]
				img.hidden = false
			} else {
				img.removeAttribute('src')
				img.hidden = true
			}
		})
	}
	function refreshThumbs() { pages.forEach(function (p, i) { refreshThumb(i) }) }
	function fitAll() { cards.forEach(fitPage) }
	function showCount() { countEl.textContent = (state.index + 1) + ' of ' + pages.length }
	function scrollToIndex(i, smooth) {
		state.index = clampIndex(i)
		track.scrollTo({ left: state.index * track.clientWidth, behavior: smooth ? 'smooth' : 'auto' })
		showCount()
		loadNear(state.index)
		try { localStorage.setItem(KEY + ':index', String(state.index)) } catch (e) {}
	}
	var scrollTimer = 0
	track.addEventListener('scroll', function () {
		clearTimeout(scrollTimer)
		scrollTimer = setTimeout(function () {
			var i = clampIndex(Math.round(track.scrollLeft / Math.max(1, track.clientWidth)))
			if (i !== state.index) {
				state.index = i
				showCount()
				try { localStorage.setItem(KEY + ':index', String(i)) } catch (e) {}
			}
			loadNear(i)
		}, 60)
	}, { passive: true })
	document.getElementById('pk-prev').addEventListener('click', function () { scrollToIndex(state.index - 1, true) })
	document.getElementById('pk-next').addEventListener('click', function () { scrollToIndex(state.index + 1, true) })
	document.getElementById('pk-open').addEventListener('click', function () { openEditor(state.index) })
	document.addEventListener('keydown', function (e) {
		if (!editor.hidden) {
			if ((e.metaKey || e.ctrlKey) && e.key === 'z') { e.preventDefault(); undo() }
			if (e.key === 'Escape' && !confirmEl.hidden) closeConfirm()
			return
		}
		if (e.key === 'ArrowRight') { e.preventDefault(); scrollToIndex(state.index + 1, true) }
		else if (e.key === 'ArrowLeft') { e.preventDefault(); scrollToIndex(state.index - 1, true) }
		else if (e.key === 'Enter') { e.preventDefault(); openEditor(state.index) }
	})

	// ---------- Editor ----------
	function layoutSheet() {
		if (state.editing < 0) return
		var w = stage.clientWidth - 16, h = stage.clientHeight - 16
		if (w <= 0 || h <= 0) return
		var s = Math.min(w / paint.width, h / paint.height)
		sheet.style.width = Math.floor(paint.width * s) + 'px'
		sheet.style.height = Math.floor(paint.height * s) + 'px'
	}
	function loadImage(src) {
		return new Promise(function (resolve, reject) {
			var img = new Image()
			img.decoding = 'async'
			img.onload = function () { resolve(img) }
			img.onerror = function () { reject(new Error('image failed: ' + src)) }
			img.src = src
		})
	}
	function computeWalls(img, w, h) {
		var c = document.createElement('canvas')
		c.width = w; c.height = h
		var x = c.getContext('2d', { willReadFrequently: true })
		x.fillStyle = '#fff'
		x.fillRect(0, 0, w, h)
		x.drawImage(img, 0, 0, w, h)
		var d = x.getImageData(0, 0, w, h).data
		var walls = new Uint8Array(w * h)
		for (var i = 0, j = 0; i < walls.length; i++, j += 4) {
			// Line art is dark on white. Anything darker than this counts as a line.
			if (d[j] * 0.299 + d[j + 1] * 0.587 + d[j + 2] * 0.114 < 175) walls[i] = 1
		}
		return walls
	}
	var openToken = 0
	function openEditor(i) {
		i = clampIndex(i)
		var p = pages[i]
		var token = ++openToken
		state.editing = i
		state.undo = []
		state.walls = null
		state.dirty = false
		updateUndo()
		titleEl.textContent = p.title
		picker.hidden = true
		editor.hidden = false
		var scale = Math.min(1, MAX_SIDE / Math.max(p.width, p.height))
		paint.width = Math.round(p.width * scale)
		paint.height = Math.round(p.height * scale)
		ctx.clearRect(0, 0, paint.width, paint.height)
		lines.alt = p.alt
		lines.src = p.src
		layoutSheet()
		if (!(history.state && history.state.coloring)) history.pushState({ coloring: true }, '', location.pathname)
		Promise.all([loadImage(p.src), loadSaved(p.id)]).then(function (res) {
			if (token !== openToken) return
			state.walls = computeWalls(res[0], paint.width, paint.height)
			var rec = res[1]
			if (rec && rec.blob) {
				return createImageBitmap(rec.blob).then(function (bmp) {
					if (token !== openToken) return
					ctx.drawImage(bmp, 0, 0, paint.width, paint.height)
				}).catch(function () {})
			}
		}).catch(function () { toast('This page needs a connection the first time.') })
	}
	function closeEditor() {
		flushSave().then(function () {
			var i = state.editing
			state.editing = -1
			state.undo = []
			editor.hidden = true
			picker.hidden = false
			fitAll()
			scrollToIndex(i >= 0 ? i : state.index, false)
			if (i >= 0) refreshThumb(i)
		})
	}
	document.getElementById('ed-back').addEventListener('click', function () {
		if (history.state && history.state.coloring) history.back()
		else closeEditor()
	})

	function updateUndo() { undoBtn.disabled = state.undo.length === 0 }
	function pushUndo() {
		try { state.undo.push(ctx.getImageData(0, 0, paint.width, paint.height)) } catch (e) { return }
		if (state.undo.length > MAX_UNDO) state.undo.shift()
		updateUndo()
	}
	function undo() {
		var snap = state.undo.pop()
		if (!snap) return
		ctx.putImageData(snap, 0, 0)
		updateUndo()
		scheduleSave()
	}
	undoBtn.addEventListener('click', undo)

	var saveTimer = 0
	function scheduleSave() {
		state.dirty = true
		clearTimeout(saveTimer)
		saveTimer = setTimeout(flushSave, 700)
	}
	function isBlank() {
		var d = ctx.getImageData(0, 0, paint.width, paint.height).data
		for (var i = 3; i < d.length; i += 4) if (d[i]) return false
		return true
	}
	function flushSave() {
		clearTimeout(saveTimer)
		if (!state.dirty || state.editing < 0) return state.saving || Promise.resolve()
		state.dirty = false
		var id = pages[state.editing].id
		if (isBlank()) { state.saving = deleteSaved(id); return state.saving }
		state.saving = new Promise(function (resolve) {
			paint.toBlob(function (blob) {
				if (!blob) return resolve()
				storeSaved(id, blob).then(resolve, resolve)
			}, 'image/png')
		})
		return state.saving
	}
	document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') flushSave() })
	window.addEventListener('pagehide', function () { flushSave() })

	// Tools and colors
	var toolButtons = Array.prototype.slice.call(document.querySelectorAll('.tool'))
	var swatches = Array.prototype.slice.call(document.querySelectorAll('.swatch'))
	function selectTool(btn) {
		state.tool = btn.getAttribute('data-tool')
		if (state.tool === 'brush') state.size = btn.getAttribute('data-size')
		toolButtons.forEach(function (b) {
			var on = b === btn
			b.classList.toggle('on', on)
			b.setAttribute('aria-pressed', String(on))
		})
	}
	function selectColor(btn) {
		state.color = btn.getAttribute('data-color')
		document.querySelector('.tools').style.setProperty('--current', state.color)
		swatches.forEach(function (b) {
			var on = b === btn
			b.classList.toggle('on', on)
			b.setAttribute('aria-pressed', String(on))
		})
		if (state.tool === 'eraser') selectTool(toolButtons[0])
	}
	toolButtons.forEach(function (b) { b.addEventListener('click', function () { selectTool(b) }) })
	swatches.forEach(function (b) { b.addEventListener('click', function () { selectColor(b) }) })

	function hexToRgb(hex) {
		var n = parseInt(hex.slice(1), 16)
		return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
	}

	// Tap to fill: fills the area enclosed by the line art (ignoring earlier paint), then
	// grows 2px into the lines so no white halo is left. The lines sit on top (multiply),
	// so they are never covered.
	function fillAt(x, y) {
		var w = paint.width, h = paint.height, walls = state.walls
		if (!walls) return false
		x = Math.floor(x); y = Math.floor(y)
		if (x < 0 || y < 0 || x >= w || y >= h) return false
		var start = y * w + x
		if (walls[start]) {
			start = -1
			for (var r = 1; r <= 8 && start < 0; r++) {
				for (var dy = -r; dy <= r && start < 0; dy++) {
					for (var dx = -r; dx <= r; dx++) {
						var nx = x + dx, ny = y + dy
						if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue
						if (!walls[ny * w + nx]) { start = ny * w + nx; break }
					}
				}
			}
			if (start < 0) return false
		}
		var region = new Uint8Array(w * h)
		var stack = [start]
		region[start] = 1
		while (stack.length) {
			var p = stack.pop()
			var px = p % w
			var l = p
			while (px > 0 && !walls[l - 1] && !region[l - 1]) { l--; px--; region[l] = 1 }
			var rr = p
			var rx = p % w
			while (rx < w - 1 && !walls[rr + 1] && !region[rr + 1]) { rr++; rx++; region[rr] = 1 }
			for (var q = l; q <= rr; q++) {
				var up = q - w, down = q + w
				if (up >= 0 && !walls[up] && !region[up]) { region[up] = 1; stack.push(up) }
				if (down < w * h && !walls[down] && !region[down]) { region[down] = 1; stack.push(down) }
			}
		}
		for (var pass = 0; pass < 2; pass++) {
			var grow = []
			for (var i = 0; i < region.length; i++) {
				if (region[i] || !walls[i]) continue
				var cx = i % w
				if ((cx > 0 && region[i - 1] === 1) || (cx < w - 1 && region[i + 1] === 1) || (i >= w && region[i - w] === 1) || (i + w < region.length && region[i + w] === 1)) grow.push(i)
			}
			for (var g = 0; g < grow.length; g++) region[grow[g]] = 1
		}
		pushUndo()
		var img = ctx.getImageData(0, 0, w, h)
		var d = img.data
		var rgb = hexToRgb(state.color)
		for (var k = 0, j = 0; k < region.length; k++, j += 4) {
			if (!region[k]) continue
			d[j] = rgb[0]; d[j + 1] = rgb[1]; d[j + 2] = rgb[2]; d[j + 3] = 255
		}
		ctx.putImageData(img, 0, 0)
		state.lastFill = { x: x, y: y, color: state.color }
		scheduleSave()
		return true
	}

	// Pointer input on the sheet: a tap fills (in fill mode); drags paint or erase.
	var stroke = null
	function toCanvas(e) {
		var r = paint.getBoundingClientRect()
		return { x: (e.clientX - r.left) * (paint.width / r.width), y: (e.clientY - r.top) * (paint.height / r.height), scale: paint.width / r.width }
	}
	function lineWidth(scale) { return (state.tool === 'eraser' ? 30 : SIZES[state.size] || 18) * scale }
	function drawSegment(a, b) {
		ctx.save()
		ctx.globalCompositeOperation = state.tool === 'eraser' ? 'destination-out' : 'source-over'
		ctx.strokeStyle = state.color
		ctx.fillStyle = state.color
		ctx.lineCap = 'round'
		ctx.lineJoin = 'round'
		ctx.lineWidth = lineWidth(a.scale)
		ctx.beginPath()
		if (a.x === b.x && a.y === b.y) {
			ctx.arc(a.x, a.y, ctx.lineWidth / 2, 0, Math.PI * 2)
			ctx.fill()
		} else {
			ctx.moveTo(a.x, a.y)
			ctx.lineTo(b.x, b.y)
			ctx.stroke()
		}
		ctx.restore()
	}
	sheet.addEventListener('pointerdown', function (e) {
		if (e.button > 0 || state.editing < 0) return
		if (stroke) {
			// A second finger: treat it as a pinch or an accident and undo the stroke.
			if (stroke.drew) undo()
			stroke = null
			return
		}
		e.preventDefault()
		try { sheet.setPointerCapture(e.pointerId) } catch (err) {}
		var pt = toCanvas(e)
		stroke = { id: e.pointerId, last: pt, start: pt, drew: false }
		if (state.tool === 'fill') return
		pushUndo()
		stroke.drew = true
		drawSegment(pt, pt)
	})
	sheet.addEventListener('pointermove', function (e) {
		if (!stroke || e.pointerId !== stroke.id || state.tool === 'fill') return
		e.preventDefault()
		var events = e.getCoalescedEvents ? e.getCoalescedEvents() : [e]
		if (!events.length) events = [e]
		for (var i = 0; i < events.length; i++) {
			var pt = toCanvas(events[i])
			drawSegment(stroke.last, pt)
			stroke.last = pt
		}
	})
	function endStroke(e) {
		if (!stroke || e.pointerId !== stroke.id) return
		var s = stroke
		stroke = null
		if (state.tool === 'fill') {
			if (e.type === 'pointerup') fillAt(s.start.x, s.start.y)
			return
		}
		if (s.drew) scheduleSave()
	}
	sheet.addEventListener('pointerup', endStroke)
	sheet.addEventListener('pointercancel', endStroke)
	stage.addEventListener('touchmove', function (e) { e.preventDefault() }, { passive: false })

	// Clear with a confirm step
	function openConfirm() { confirmEl.hidden = false; document.getElementById('confirm-no').focus() }
	function closeConfirm() { confirmEl.hidden = true }
	document.getElementById('ed-clear').addEventListener('click', openConfirm)
	document.getElementById('confirm-no').addEventListener('click', closeConfirm)
	confirmEl.addEventListener('click', function (e) { if (e.target === confirmEl) closeConfirm() })
	document.getElementById('confirm-yes').addEventListener('click', function () {
		closeConfirm()
		pushUndo()
		ctx.clearRect(0, 0, paint.width, paint.height)
		scheduleSave()
	})

	// Save or share the finished picture (paint plus line art on white)
	function toast(text) {
		toastEl.textContent = text
		toastEl.hidden = false
		clearTimeout(toast.t)
		toast.t = setTimeout(function () { toastEl.hidden = true }, 2600)
	}
	function composite() {
		var c = document.createElement('canvas')
		c.width = paint.width; c.height = paint.height
		var x = c.getContext('2d')
		x.fillStyle = '#fff'
		x.fillRect(0, 0, c.width, c.height)
		x.drawImage(paint, 0, 0)
		x.globalCompositeOperation = 'multiply'
		x.drawImage(lines, 0, 0, c.width, c.height)
		return new Promise(function (resolve) { c.toBlob(resolve, 'image/png') })
	}
	function slug(text) { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'coloring-page' }
	document.getElementById('ed-share').addEventListener('click', function () {
		if (state.editing < 0) return
		var p = pages[state.editing]
		composite().then(function (blob) {
			if (!blob) return toast('Could not make the picture.')
			var name = slug(p.title) + '.png'
			var file = null
			try { file = new File([blob], name, { type: 'image/png' }) } catch (e) {}
			if (file && navigator.canShare && navigator.canShare({ files: [file] })) {
				return navigator.share({ files: [file], title: p.title }).catch(function (err) {
					if (err && err.name === 'AbortError') return
					download(blob, name)
				})
			}
			download(blob, name)
		})
	})
	function download(blob, name) {
		var url = URL.createObjectURL(blob)
		var a = document.createElement('a')
		a.href = url
		a.download = name
		document.body.appendChild(a)
		a.click()
		a.remove()
		setTimeout(function () { URL.revokeObjectURL(url) }, 4000)
		toast('Saved the picture.')
	}

	window.addEventListener('resize', function () { fitAll(); layoutSheet(); if (editor.hidden) scrollToIndex(state.index, false) })
	window.addEventListener('popstate', function () { if (!editor.hidden) closeEditor() })

	if (history.state && history.state.coloring) history.replaceState(null, '', location.pathname)
	buildPicker()
	requestAnimationFrame(function () { fitAll(); scrollToIndex(state.index, false) })
	selectColor(swatches[0])
	selectTool(toolButtons[0])

	if ('serviceWorker' in navigator) {
		navigator.serviceWorker.ready.then(function (reg) {
			var worker = reg.active || navigator.serviceWorker.controller
			if (worker) worker.postMessage({ type: 'WARM_ALL' })
		}).catch(function () {})
	}

	window.christColoring = {
		state: state,
		pages: pages,
		open: openEditor,
		close: closeEditor,
		fillAt: fillAt,
		flush: flushSave,
		pixel: function (x, y) { var d = ctx.getImageData(x, y, 1, 1).data; return [d[0], d[1], d[2], d[3]] },
		wallAt: function (x, y) { return state.walls ? state.walls[y * paint.width + x] : null },
		size: function () { return { w: paint.width, h: paint.height } },
	}
})()
`