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: '', next: '', frame: '', undo: '', trash: '', share: '', fill: '', eraser: '', } 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(/ ``, ).join('') const sizes = [ ['small', 'Small brush', 8], ['medium', 'Medium brush', 16], ['large', 'Large brush', 26], ] .map( ([id, label, dot]) => ``, ) .join('') return `
${ICONS.frame} Coloring
` } 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 = '
' 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 } }, } })() `