← 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/coloring-page.ts
685 lines · 31.6 KB · TypeScriptimport { 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 } },
}
})()
`