Colouring In
Pick a picture to paint!
Choose a color, then tap the areas with the matching number.
`; const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); const newWin = window.open(url, '_blank'); if (!newWin){ // Popup was blocked — fall back to opening the image data URL // directly so something at least happens. window.open(link.href, '_blank'); } // Don't revoke immediately; the new tab needs to keep loading from // this URL. 60s is plenty. setTimeout(() => URL.revokeObjectURL(url), 60000); } // On iOS Safari, sometimes navigates away. We catch the // click here and use a Blob-URL trigger when available so the page // stays put. Falls back to native anchor behaviour otherwise. function onPrintSaveClick(e){ const link = document.getElementById('printSaveLink'); if (!link.href || link.href.endsWith('#')) { e.preventDefault(); return; } // Convert data URL to blob so the download attribute is honoured // more reliably (especially in WebView contexts). if (link.href.startsWith('data:')){ e.preventDefault(); fetch(link.href).then(r => r.blob()).then(blob => { const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = link.download || 'picture.png'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(blobUrl), 1500); }); } } // Render the live SVG (with all the kid's paint strokes) to a PNG // data URL. Built up in three layered draws on a canvas: // // 1. Background — for worksheet mode, draw the source PNG via a // direct load (same-origin, doesn't taint the canvas); // for blank mode, fill white and stroke the rounded frame. // 2. Paint strokes — drawn DIRECTLY onto the canvas via 2D context // calls (ctx.arc, ctx.lineTo) by walking the live paint-layer // DOM. This avoids any SVG-serialise-then-rasterise round trip, // which was producing tainted/empty output on iPad Safari. // 3. Outlines — if `includeLines`, load the -lines.png and draw with // `globalCompositeOperation = 'multiply'` so black ink stays // black above the paint, white pixels pass through unchanged. // // We do NOT use `fetch()` — fetch on local file URLs is unreliable in // iPadOS / iOS Safari. Direct loads work everywhere the page // itself works. async function buildPaintedPngDataUrl(img, { includeLines = true } = {}){ const liveSvg = document.querySelector('#stage svg'); if (!liveSvg || !homeViewBox) return await buildBlankPngDataUrl(img); const W = homeViewBox.w, H = homeViewBox.h; const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, W, H); // --- Layer 1: background --- if (img.mode === 'worksheet'){ try { const bgImg = await loadImageEl(bgUrlFor(img)); ctx.drawImage(bgImg, 0, 0, W, H); } catch (e) { console.warn('Could not load background:', e); } } else if (img.mode === 'blank'){ const ab = img.artBox || { x: 30, y: 30, w: W - 60, h: H - 60 }; ctx.strokeStyle = '#22394A'; ctx.lineWidth = 3; _roundRect(ctx, ab.x, ab.y, ab.w, ab.h, 20); ctx.stroke(); } // --- Layer 2: paint strokes drawn directly onto the canvas --- // Walk the live paint-layer's circle/line elements and translate // each to a 2D context call. No SVG-to-image step (which was // failing on iPad), no taint risk. drawPaintStrokesToCanvas(ctx, liveSvg); // --- Layer 3: outlines on top (worksheets only, when wanted) --- if (includeLines && img.mode === 'worksheet'){ const linesHref = img.backgroundHref.replace(/\.png$/i, '-lines.png'); try { const linesImg = await loadImageEl(linesHref); ctx.globalCompositeOperation = 'multiply'; ctx.drawImage(linesImg, 0, 0, W, H); ctx.globalCompositeOperation = 'source-over'; } catch (e) { console.warn('Could not draw lines overlay:', e); } } try { return canvas.toDataURL('image/png'); } catch (e) { // Canvas was tainted (cross-origin image without CORS headers, // common in iPadOS WebViews with local file URLs). Fall back to // building an SVG that includes the paint inline and the bg/lines // by absolute URL — the SVG renders correctly in , in // download targets, and in new browser tabs without ever touching // a canvas. console.warn('Canvas tainted, falling back to SVG export:', e); return buildPaintedSvgUrl(img, { includeLines }); } } // SVG-based fallback for when canvas rasterisation can't be done. Builds // a self-contained SVG that: // - inlines all the paint strokes (already part of the live SVG), // - references the bg PNG by absolute URL, // - keeps the .deco line-art overlay with mix-blend-mode:multiply // (which works correctly in standalone SVG rendering), // and returns a blob URL the preview and save link can use. function buildPaintedSvgUrl(img, { includeLines = true } = {}){ const liveSvg = document.querySelector('#stage svg'); if (!liveSvg || !homeViewBox) return null; const cloneSvg = liveSvg.cloneNode(true); cloneSvg.setAttribute('viewBox', `${homeViewBox.x} ${homeViewBox.y} ${homeViewBox.w} ${homeViewBox.h}`); cloneSvg.setAttribute('width', homeViewBox.w); cloneSvg.setAttribute('height', homeViewBox.h); cloneSvg.querySelectorAll('.r').forEach(el => el.remove()); if (!includeLines){ cloneSvg.querySelectorAll('.deco').forEach(el => el.remove()); } // Resolve relative to absolute URLs so the SVG renders // its bg/lines correctly when displayed outside the page context. cloneSvg.querySelectorAll('image').forEach(im => { const h = im.getAttribute('href'); if (h && !h.startsWith('data:') && !h.startsWith('http')){ try { im.setAttribute('href', new URL(h, document.baseURI).href); } catch (e) {} } }); if (!cloneSvg.getAttribute('xmlns')) cloneSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); const xml = new XMLSerializer().serializeToString(cloneSvg); const blob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }); return URL.createObjectURL(blob); } // Walk the live paint-layer's stroke elements and draw each directly // onto a 2D canvas context for the Print PNG export. Three element // shapes are supported: // : dot from a quick tap (initial pointerdown) // : legacy segment from saved data made by the old code // : one whole stroke encoded as M/L commands (current code) // Coordinates are in the artwork's user space, mapping 1:1 to canvas // pixels. function drawPaintStrokesToCanvas(ctx, liveSvg){ const paintLayer = liveSvg && liveSvg.querySelector('#paint-layer'); if (!paintLayer) return; const elems = paintLayer.querySelectorAll('circle, line, path'); ctx.save(); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; elems.forEach(el => { const tag = (el.tagName || '').toLowerCase(); if (tag === 'circle'){ const cx = parseFloat(el.getAttribute('cx')); const cy = parseFloat(el.getAttribute('cy')); const r = parseFloat(el.getAttribute('r')); const fill = el.getAttribute('fill') || '#000'; if (!isFinite(cx) || !isFinite(cy) || !(r > 0)) return; ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); } else if (tag === 'line'){ const x1 = parseFloat(el.getAttribute('x1')); const y1 = parseFloat(el.getAttribute('y1')); const x2 = parseFloat(el.getAttribute('x2')); const y2 = parseFloat(el.getAttribute('y2')); const stroke = el.getAttribute('stroke') || '#000'; const sw = parseFloat(el.getAttribute('stroke-width')) || 4; if (![x1,y1,x2,y2].every(isFinite)) return; ctx.strokeStyle = stroke; ctx.lineWidth = sw; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } else if (tag === 'path'){ // Parse the d attribute. We only emit `M x y L x y L x y …` so // the parser is intentionally tiny — split on whitespace and // walk the tokens. If a saved-from-elsewhere path has anything // fancier (curves, arcs), it just won't render cleanly here, // but the main canvas still shows it via the regular SVG render. const d = el.getAttribute('d') || ''; const stroke = el.getAttribute('stroke') || '#000'; const sw = parseFloat(el.getAttribute('stroke-width')) || 4; const tokens = d.trim().split(/\s+/); ctx.strokeStyle = stroke; ctx.lineWidth = sw; ctx.beginPath(); let i = 0, started = false; while (i < tokens.length){ const cmd = tokens[i]; if (cmd === 'M' && i + 2 < tokens.length){ const x = parseFloat(tokens[i + 1]); const y = parseFloat(tokens[i + 2]); if (isFinite(x) && isFinite(y)){ ctx.moveTo(x, y); started = true; } i += 3; } else if (cmd === 'L' && i + 2 < tokens.length){ const x = parseFloat(tokens[i + 1]); const y = parseFloat(tokens[i + 2]); if (isFinite(x) && isFinite(y) && started){ ctx.lineTo(x, y); } i += 3; } else { // Unknown token — skip a single token so we don't loop forever. i += 1; } } ctx.stroke(); } }); ctx.restore(); } // Direct Image-element loader; same-origin local files don't taint // the canvas, no fetch involved. function loadImageEl(url){ return new Promise((resolve, reject) => { const im = new Image(); im.onload = () => resolve(im); im.onerror = () => reject(new Error('Image load failed: ' + url)); im.src = url; }); } // Trace a rounded rectangle path into the current canvas context. function _roundRect(ctx, x, y, w, h, r){ const rr = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + rr, y); ctx.arcTo(x + w, y, x + w, y + h, rr); ctx.arcTo(x + w, y + h, x, y + h, rr); ctx.arcTo(x, y + h, x, y, rr); ctx.arcTo(x, y, x + w, y, rr); ctx.closePath(); } // Blank version: for worksheets, just use the original source PNG URL // directly (the preview loads it natively, and the save link's // download attribute works on a regular URL too — no need to round-trip // through fetch+data URL, which can fail silently in some WebView // setups and leave the preview showing the previously-rendered image). // For the Free Draw blank-page mode, render the empty frame to PNG. async function buildBlankPngDataUrl(img){ if (img.mode === 'worksheet'){ return bgUrlFor(img); } const vb = img.viewBox.trim().split(/\s+/).map(Number); const [minX, minY, vbw, vbh] = vb; const ab = img.artBox || { x: 30, y: 30, w: vbw - 60, h: vbh - 60 }; const svgText = ` `; const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); const svgUrl = URL.createObjectURL(svgBlob); try { const tempImg = new Image(); await new Promise((res, rej) => { tempImg.onload = res; tempImg.onerror = rej; tempImg.src = svgUrl; }); const canvas = document.createElement('canvas'); canvas.width = vbw; canvas.height = vbh; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, vbw, vbh); ctx.drawImage(tempImg, 0, 0); return canvas.toDataURL('image/png'); } finally { URL.revokeObjectURL(svgUrl); } } async function fetchAsDataUrl(url){ const resp = await fetch(url); const blob = await resp.blob(); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(blob); }); } // ============ Start a painting session ============ async function startPaint(imgId){ const img = IMAGES.find(i => i.id === imgId); if (!img) return; currentImage = img; _lastPaintedImage = img; selectedNum = img.palette[0].num; filledCount = 0; // Reset stroke history + tools strokeStack = []; currentStrokeGroup = null; currentPath = null; // Clear any queued path tokens left over from a previous painting // (shouldn't happen — flushed at every stroke end — but defensive). _pendingPathD = ''; if (_pathFlushFrame != null){ cancelAnimationFrame(_pathFlushFrame); _pathFlushFrame = null; } setEraser(false); // Default to the smallest brush ("tip") on every fresh painting — // matches the picker chip styling and gives the kid the most control // straight away. They can step up to fine/small/medium/large from // the brush row when they want broader strokes. setBrush('tip'); updateUndoEnabled(); const isWorksheet = img.mode === 'worksheet'; // Build palette const palEl = document.getElementById('palette'); palEl.innerHTML = ''; effectivePalette(img).forEach((p, idx) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'color-btn' + (idx === 0 ? ' active' : ''); if (p.isExtra) b.classList.add('color-btn-extra'); b.dataset.num = p.num; // Title gives the colour name on hover/long-press for accessibility, // even though we no longer print the name under the swatch. b.title = p.name; // Worksheets use the number as a colour-by-numbers cue. Blank mode // turns it off (showNumbers: false) so swatches are pure colour. // Extras (White/Black) never show a number — they're tools, not key items. const numLabel = (img.showNumbers === false || p.hideNumber) ? '' : p.num; b.innerHTML = `${numLabel}`; b.addEventListener('click', () => selectColor(p.num)); palEl.appendChild(b); }); // Build SVG stage const stage = document.getElementById('stage'); const isBlank = img.mode === 'blank'; let svgBody; if (isBlank) { // Blank-page mode: a rounded-rectangle frame on a white sheet. // Same paint-clipping pattern as worksheet mode (a `.r` rect at the // artBox sets up the clip-path), with a visible frame outline drawn // on top of the paint so the kid can always see where their canvas // edges are. const vbParts = img.viewBox.trim().split(/\s+/).map(Number); const vbw = vbParts[2], vbh = vbParts[3]; const ab = img.artBox || { x: 30, y: 30, w: vbw - 60, h: vbh - 60 }; svgBody = ` `; } else if (isWorksheet) { // Worksheet mode layer stack (back-to-front): // 1. White background // 2. Source PNG (bg-image) — title, artwork with grey digits, footer // 3. Paint layer (inserted by JS, clipped to artwork area only) // 4. Invisible click rect over the artwork area // 5. SAME source PNG on top with mix-blend-mode: multiply — keeps // black line art visible through paint strokes (multiply makes // black × anything = black, white × paint = paint unchanged). const vbParts = img.viewBox.trim().split(/\s+/).map(Number); const vbw = vbParts[2], vbh = vbParts[3]; // Artwork area: the inside of the rounded-rectangle frame on the page. // Each entry has a precomputed `artBox` ({x, y, w, h}) detected from the // source PNG. If absent (shouldn't happen), fall back to a sensible // percentage region covering the artwork band. const ab = img.artBox || { x: Math.round(vbw * 0.02), y: Math.round(vbh * 0.22), w: Math.round(vbw * 0.96), h: Math.round(vbh * 0.70) }; const artX = ab.x, artY = ab.y, artW = ab.w, artH = ab.h; const bgUrl = img.backgroundHref; const linesUrl = img.backgroundHref.replace(/\.png$/i, '-lines.png'); svgBody = ` `; } else { svgBody = img.svg; } // Use a CROPPED viewBox for worksheet mode so the picture's own // numbered legend (which sits between the title and the artwork // frame in the source PNG) is clipped out — the kid only sees the // artwork area and the Springboard Minds footer below it. The // selectable palette above the stage is now the only visible // colour key. For non-worksheet modes (blank-page free draw, etc.) // we keep the full viewBox. let displayViewBox = img.viewBox; if (img.mode === 'worksheet' && img.artBox){ const fullVB = img.viewBox.trim().split(/\s+/).map(Number); const fullVBW = fullVB[2], fullVBH = fullVB[3]; const ab = img.artBox; // Crop top to the artBox.y (hides title + numbered legend). // Keep full width and full height-from-y (so the footer // "Springboard Minds" line below the frame stays visible). displayViewBox = `0 ${ab.y} ${fullVBW} ${fullVBH - ab.y}`; } stage.innerHTML = `${svgBody}`; const svg = stage.querySelector('svg'); // Match the stage container's aspect ratio to the visible (cropped) // viewBox so the worksheet fills its bezel without clipping or // letterboxing. Non-worksheet modes use the full viewBox. { const vbParts = displayViewBox.trim().split(/\s+/).map(Number); if (vbParts.length === 4 && vbParts[2] > 0 && vbParts[3] > 0) { stage.style.aspectRatio = `${vbParts[2]} / ${vbParts[3]}`; } else { stage.style.aspectRatio = ''; } } // Count regions (worksheet mode has 1; used for progress ratio only) totalRegions = stage.querySelectorAll('.r').length; // Single paint layer for ALL brush strokes. A clipPath matching the // union of every fillable region keeps strokes inside the picture // frame, but there's no per-colour restriction — the kid can paint // any area with any colour. const defs = document.createElementNS(SVG_NS, 'defs'); const sceneClip = document.createElementNS(SVG_NS, 'clipPath'); sceneClip.setAttribute('id', 'scene-clip'); svg.querySelectorAll('.r .fill').forEach(fillEl => { sceneClip.appendChild(fillEl.cloneNode(true)); }); defs.appendChild(sceneClip); svg.insertBefore(defs, svg.firstChild); const paintLayer = document.createElementNS(SVG_NS, 'g'); paintLayer.setAttribute('id', 'paint-layer'); paintLayer.setAttribute('clip-path', 'url(#scene-clip)'); paintLayer.style.pointerEvents = 'none'; paintGroups = { all: paintLayer }; // Restore saved progress for this picture (if any) strokeStack = await restorePaintingInto(paintLayer, img); updateUndoEnabled(); // Paint layer positioning differs by mode: // Normal: insert before the first .deco so strokes cover the white // fills but decorative details (eyes, mouth) stay on top. // Worksheet: the .deco element IS the background PNG. Paint must sit // ABOVE the PNG so strokes are visible. Insert just before // the invisible .r click-target group instead. const deco = svg.querySelector('.deco'); if (isWorksheet || isBlank) { const r = svg.querySelector('.r'); svg.insertBefore(paintLayer, r); } else { svg.insertBefore(paintLayer, deco || null); } // TOP LAYER: cloned black outlines only — drawn above the paint layer // so region borders are never covered by brush strokes. Skipped in // worksheet mode because the PNG already supplies the outlines, and in // blank mode because there are no regions to outline. if (!isWorksheet && !isBlank) { const topLayer = document.createElementNS(SVG_NS, 'g'); topLayer.setAttribute('id', 'top-layer'); topLayer.style.pointerEvents = 'none'; svg.querySelectorAll('.r .fill').forEach(fill => { const outline = fill.cloneNode(true); outline.removeAttribute('fill'); outline.removeAttribute('stroke'); outline.removeAttribute('stroke-width'); outline.classList.remove('fill'); outline.classList.add('outline'); topLayer.appendChild(outline); }); svg.insertBefore(topLayer, deco || null); } // Wire up pointer events on the SVG for brush painting. // Multi-touch wrapper: 1 finger = draw, 2 fingers = pinch zoom / pan. svg.addEventListener('pointerdown', onPointerDownWrapped); svg.addEventListener('pointermove', onPointerMoveWrapped); svg.addEventListener('pointerup', onPointerUpWrapped); svg.addEventListener('pointerleave', onPointerUpWrapped); svg.addEventListener('pointercancel', onPointerUpWrapped); // Trackpad pinch / Ctrl+wheel zoom: hijack `ctrl/meta+wheel` events // (which is what trackpad pinch fires on desktop browsers) and zoom // the SVG viewBox directly, anchored on the cursor — same internal // zoom path the touch pinch uses. Without this, the user's browser // would either swallow the gesture or zoom the whole page (which // doesn't grow the artwork the way they want). svg.addEventListener('wheel', onSvgWheelZoom, { passive: false }); // Reset zoom whenever entering a new picture initZoomForImage(svg, img.viewBox); document.getElementById('imageTitle').textContent = img.title; // Progress UI const counter = document.querySelector('.info-bar .counter'); if (isWorksheet || isBlank) { if (counter) counter.textContent = isBlank ? '🎨 Free draw' : '🎨 Free paint'; } else { if (counter) counter.innerHTML = '0 / ' + totalRegions + ''; } // Tone toggle button: visible whenever the palette has tone-able // entries (every worksheet does, via TONE_TABLE lookup by name). // Label shows the tone you'll get on next click — i.e. the opposite // of the current tone. const toneBtn = document.getElementById('toneBtn'); if (toneBtn) { toneBtn.style.display = paletteHasTones(img) ? '' : 'none'; toneBtn.textContent = paletteTone === 'pastel' ? '🎨 Strong' : '🎨 Pastel'; } applyToneToPalette(); showScreen('paintScreen'); // Reflect the open worksheet in the URL so the kid (or parent) can // bookmark a direct link to a specific picture, and back/forward // browser nav works naturally. Only set the hash if it's not already // matching this image, to avoid an unnecessary hashchange event loop // when this function was itself triggered by a hashchange. if (location.hash !== '#' + imgId) { location.hash = imgId; } } function svgPointFromEvent(svg, e){ const pt = svg.createSVGPoint(); pt.x = e.clientX; pt.y = e.clientY; return pt.matrixTransform(svg.getScreenCTM().inverse()); } // ============ Pinch-zoom / pan (viewBox-based) ============ // Instead of using CSS transform (which breaks getScreenCTM in subtle // ways), we zoom by setting the SVG's viewBox attribute. Then drawing // coords automatically land in the right place via getScreenCTM(). let activePointers = new Map(); // pointerId → {x, y} let pinchState = null; let homeViewBox = null; // original viewBox {x,y,w,h} const MIN_ZOOM = 1, MAX_ZOOM = 6; function initZoomForImage(svg, vbStr){ const p = vbStr.trim().split(/\s+/).map(Number); homeViewBox = { x: p[0], y: p[1], w: p[2], h: p[3] }; svg.setAttribute('viewBox', `${p[0]} ${p[1]} ${p[2]} ${p[3]}`); svg.style.transform = ''; // ensure no leftover CSS transforms pinchState = null; activePointers.clear(); } function applyViewTransform(svg){ /* no-op; kept for API compatibility */ } function resetZoom(){ const svg = document.querySelector('#stage svg'); if (svg && homeViewBox){ svg.setAttribute('viewBox', `${homeViewBox.x} ${homeViewBox.y} ${homeViewBox.w} ${homeViewBox.h}`); } } function currentViewBox(svg){ const p = svg.getAttribute('viewBox').trim().split(/\s+/).map(Number); return { x: p[0], y: p[1], w: p[2], h: p[3] }; } function screenToFraction(svg, pt){ const r = svg.getBoundingClientRect(); return { fx: (pt.x - r.left) / r.width, fy: (pt.y - r.top) / r.height }; } function screenToUser(svg, pt){ const s = svg.createSVGPoint(); s.x = pt.x; s.y = pt.y; return s.matrixTransform(svg.getScreenCTM().inverse()); } function dist2(a, b){ const dx = a.x-b.x, dy = a.y-b.y; return Math.sqrt(dx*dx + dy*dy); } // Zoom the SVG via mouse wheel / trackpad pinch (which fires // `ctrl/meta+wheel` events on desktop browsers). Anchors the zoom on // the cursor so the user-space point under the cursor stays put as // the viewBox shrinks/grows — same feel as a 2-finger touch pinch. function onSvgWheelZoom(e){ // Only intercept when ctrl or meta is held (trackpad pinch fires // ctrl+wheel; Ctrl/Cmd+scroll on a mouse wheel does the same). // Plain scroll keeps its default page-scroll behaviour. if (!(e.ctrlKey || e.metaKey)) return; e.preventDefault(); const svg = e.currentTarget; if (!homeViewBox) return; const vb = currentViewBox(svg); // Convert deltaY to a multiplicative zoom factor. Negative deltaY // means scroll up == zoom in; clamp the per-event amount so a single // hard scroll doesn't blast through the whole zoom range. const step = Math.exp(-Math.max(-50, Math.min(50, e.deltaY)) * 0.01); // Current zoom is home/current ratio; clamp the new zoom. const currentZoom = homeViewBox.w / vb.w; let nextZoom = currentZoom * step; nextZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, nextZoom)); if (Math.abs(nextZoom - currentZoom) < 1e-4) return; const nextW = homeViewBox.w / nextZoom; const nextH = homeViewBox.h / nextZoom; // Anchor on the cursor: the user-space point under the cursor before // the zoom should remain under the cursor after the zoom. const cursor = { x: e.clientX, y: e.clientY }; const userPt = screenToUser(svg, cursor); const frac = screenToFraction(svg, cursor); const nextX = userPt.x - frac.fx * nextW; const nextY = userPt.y - frac.fy * nextH; svg.setAttribute('viewBox', `${nextX} ${nextY} ${nextW} ${nextH}`); } function mid2(a, b){ return { x: (a.x+b.x)/2, y: (a.y+b.y)/2 }; } function onPointerDownWrapped(e){ activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); const svg = e.currentTarget; if (activePointers.size === 1 && !pinchState){ onPointerDown(e); } else if (activePointers.size >= 2){ // A second pointer arrived — pinch-zoom takes over. KEEP whatever // the kid has already drawn in the current stroke, instead of // removing it. The original code did `currentStrokeGroup.remove()` // which silently deleted long continuous drawings the moment a // palm/wrist/second finger landed on the iPad — explaining the // exact symptom of "continuous drawing gets cut off but dots after // save fine" (dots are quick separate strokes that complete before // a stray touch can land). We commit the partial stroke to the // undo stack so the kid can ↶ if it was unwanted, and schedule a // save so it's persisted alongside the rest of their work. if (painting){ // Flush any queued path tokens so the partial stroke captures // every segment the kid drew before the pinch interrupted. _flushPathDSync(); if (currentStrokeGroup){ if (currentStrokeGroup.children.length > 0){ strokeStack.push(currentStrokeGroup); updateUndoEnabled(); scheduleSavePainting(); } else { currentStrokeGroup.remove(); } currentStrokeGroup = null; currentPath = null; } painting = false; lastSvgPt = null; } const pts = [...activePointers.values()].slice(0, 2); const scrMid = mid2(pts[0], pts[1]); pinchState = { screenDist: dist2(pts[0], pts[1]), userPoint: screenToUser(svg, scrMid), initialZoom: homeViewBox.w / currentViewBox(svg).w, }; e.preventDefault(); } } function onPointerMoveWrapped(e){ if (activePointers.has(e.pointerId)){ activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); } if (pinchState && activePointers.size >= 2){ const svg = e.currentTarget; const pts = [...activePointers.values()].slice(0, 2); const newScrMid = mid2(pts[0], pts[1]); const newScrDist = dist2(pts[0], pts[1]); const scaleFactor = newScrDist / pinchState.screenDist; let zoom = pinchState.initialZoom * scaleFactor; zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)); const vbW = homeViewBox.w / zoom; const vbH = homeViewBox.h / zoom; const frac = screenToFraction(svg, newScrMid); const vbX = pinchState.userPoint.x - frac.fx * vbW; const vbY = pinchState.userPoint.y - frac.fy * vbH; svg.setAttribute('viewBox', `${vbX} ${vbY} ${vbW} ${vbH}`); e.preventDefault(); } else if (!pinchState){ onPointerMove(e); } } function onPointerUpWrapped(e){ activePointers.delete(e.pointerId); if (activePointers.size < 2){ pinchState = null; } if (activePointers.size === 0){ onPointerUp(e); } } // Find the paintable region under the pointer. Returns the // or null. function regionUnderPointer(e){ const el = document.elementFromPoint(e.clientX, e.clientY); return el ? el.closest('.r') : null; } function onPointerDown(e){ e.preventDefault(); const svg = e.currentTarget; svg.setPointerCapture && svg.setPointerCapture(e.pointerId); painting = true; lastSvgPt = svgPointFromEvent(svg, e); // Brush AND eraser share the same code path: both lay down round dabs and // line segments at the cursor. The only difference is the dab COLOUR — // brush uses the selected palette colour; eraser uses the worksheet's // white background, so it visually "undoes" paint with the exact same // size, shape, and feel as the brush. currentStrokeGroup = document.createElementNS(SVG_NS, 'g'); currentStrokeGroup.classList.add('stroke-group'); if (eraserOn) currentStrokeGroup.classList.add('erase-group'); paintGroups.all.appendChild(currentStrokeGroup); addDot(lastSvgPt.x, lastSvgPt.y); if (!eraserOn) maybeMarkTouched(e); } function onPointerMove(e){ if (!painting) return; const svg = e.currentTarget; // iOS Safari (and Chromium) coalesce fast pointermove events for // performance — only a throttled fraction of the actual motion fires // as a discrete event, so a fast continuous stroke can lose most of // its samples and end up looking sparse OR (in extreme cases on // iPad) end up with so few committed segments that the entire stroke // appears not to have been drawn at all once flushed to storage. // getCoalescedEvents() returns every motion sample the engine // observed, so we walk all of them and lay down a line for each. // Falls back gracefully to just the dispatched event when the API // isn't available or returned nothing. let events; if (e.getCoalescedEvents){ const c = e.getCoalescedEvents(); events = (c && c.length > 0) ? c : [e]; } else { events = [e]; } for (let i = 0; i < events.length; i++){ const ev = events[i]; const pt = svgPointFromEvent(svg, ev); addStrokeSegment(lastSvgPt, pt); lastSvgPt = pt; if (!eraserOn) maybeMarkTouched(ev); } } function maybeMarkTouched(e){ const region = regionUnderPointer(e); if (region && Number(region.dataset.num) === selectedNum){ markRegionTouched(region); } } function onPointerUp(e){ painting = false; lastSvgPt = null; // Flush any queued path tokens BEFORE we forget about currentPath — // otherwise the last few segments of the stroke would never make it // into the path's d attribute. _flushPathDSync(); // Close out the current stroke group and push to undo history. if (currentStrokeGroup){ if (currentStrokeGroup.children.length === 0){ currentStrokeGroup.remove(); } else { strokeStack.push(currentStrokeGroup); } currentStrokeGroup = null; currentPath = null; } updateUndoEnabled(); // Auto-save progress after each completed stroke (debounced — see // scheduleSavePainting comment for why). scheduleSavePainting(); } // The "stroke colour" for the current dab: the selected palette colour // when painting, or the worksheet's white background when erasing. The // eraser doesn't actually delete elements — it just paints white over // previous strokes, which is what makes it feel exactly like a brush. function currentColor(){ if (eraserOn) return '#FFFFFF'; const p = effectivePalette(currentImage).find(x => x.num === selectedNum); return p ? p.color : '#000'; } // Helper: round a coordinate to one decimal place. SVG sub-pixel // precision past one decimal is invisible and just bloats the saved // data. "105.7" instead of "105.71428571" saves ~5 bytes per segment; // over thousands of segments this is the difference between a fully- // painted worksheet fitting comfortably in storage and not. function _coord(v){ return Math.round(v * 10) / 10; } function addDot(x, y){ const c = document.createElementNS(SVG_NS, 'circle'); c.setAttribute('cx', _coord(x)); c.setAttribute('cy', _coord(y)); c.setAttribute('r', BRUSH_SIZES[brushSize].r); c.setAttribute('fill', currentColor()); (currentStrokeGroup || paintGroups.all).appendChild(c); } // One per stroke. The first segment creates the path with // `M x y L x y` and sets the stroke style; every subsequent segment // queues an ` L x y` token in `_pendingPathD` and a single // requestAnimationFrame flushes the queued tokens into ONE // setAttribute per frame (~16ms). Without batching, each pointermove // triggered a full re-parse of the growing d attribute and a // re-tessellation of the path — at iPad pointer rates this stacked // up enough to make long strokes feel sluggish. // // Existing saved paintings (which have elements from the old // code) still load and render correctly because restorePaintingInto // just sets innerHTML — the SVG parser doesn't care which shapes the // strokes are made of. let _pendingPathD = ''; // queued ' L x y' tokens, flushed once per rAF let _pathFlushFrame = null; // rAF handle for the pending flush function _flushPathD(){ _pathFlushFrame = null; if (currentPath && _pendingPathD){ currentPath.setAttribute('d', currentPath.getAttribute('d') + _pendingPathD); _pendingPathD = ''; } } // Synchronously commit any queued d tokens NOW. Called whenever the // stroke ends or the SVG is about to be serialised — both cases need // the path's d attribute to fully reflect what the kid has drawn. function _flushPathDSync(){ if (_pathFlushFrame != null){ cancelAnimationFrame(_pathFlushFrame); _pathFlushFrame = null; } if (currentPath && _pendingPathD){ currentPath.setAttribute('d', currentPath.getAttribute('d') + _pendingPathD); _pendingPathD = ''; } } function addStrokeSegment(from, to){ if (!currentPath){ currentPath = document.createElementNS(SVG_NS, 'path'); currentPath.setAttribute( 'd', 'M ' + _coord(from.x) + ' ' + _coord(from.y) + ' L ' + _coord(to.x) + ' ' + _coord(to.y) ); currentPath.setAttribute('stroke', currentColor()); currentPath.setAttribute('stroke-width', String(BRUSH_SIZES[brushSize].w)); currentPath.setAttribute('stroke-linecap', 'round'); currentPath.setAttribute('stroke-linejoin', 'round'); currentPath.setAttribute('fill', 'none'); (currentStrokeGroup || paintGroups.all).appendChild(currentPath); } else { _pendingPathD += ' L ' + _coord(to.x) + ' ' + _coord(to.y); if (_pathFlushFrame == null){ _pathFlushFrame = requestAnimationFrame(_flushPathD); } } } // ============ Undo ============ function undoLast(){ const sg = strokeStack.pop(); if (sg) sg.remove(); updateUndoEnabled(); scheduleSavePainting(); } function updateUndoEnabled(){ const btn = document.getElementById('undoBtn'); if (btn) btn.disabled = strokeStack.length === 0; } // ============ Eraser ============ // The eraser shares the same dab-and-line code path as the brush; it just // paints in white. See currentColor() and onPointerDown() for the actual // branching. This function only toggles the mode flag and the cursor. function toggleEraser(){ setEraser(!eraserOn); } function markRegionTouched(region){ if (region.classList.contains('filled')) return; region.classList.add('filled'); filledCount++; document.getElementById('progressCount').textContent = String(filledCount); if (filledCount === totalRegions){ setTimeout(() => { celebrate('🎉'); setTimeout(() => showScreen('doneScreen'), 1100); }, 400); } } function selectColor(num){ selectedNum = num; // Picking a colour snaps us back into painting mode automatically. setEraser(false); document.querySelectorAll('.color-btn').forEach(b => { b.classList.toggle('active', Number(b.dataset.num) === num); }); } function setBrush(size){ if (!BRUSH_SIZES[size]) return; brushSize = size; document.querySelectorAll('.size-btn').forEach(b => { b.classList.toggle('active', b.dataset.size === size); }); } function setEraser(on){ eraserOn = !!on; const btn = document.getElementById('eraserBtn'); if (btn) btn.classList.toggle('active', eraserOn); const stage = document.getElementById('stage'); if (stage) stage.classList.toggle('erasing', eraserOn); // Make the brush-size row's label reflect the current mode so it's // clear that the size buttons control the eraser too when it's on. const lbl = document.getElementById('brushRowLabel'); if (lbl) lbl.textContent = eraserOn ? 'Eraser' : 'Brush'; } async function resetPicture(){ if (!currentImage) return; const ok = await showConfirm({ icon: '🔁', title: 'Start over?', message: 'Your painting so far will be cleared.', confirmText: 'Start over', cancelText: 'Keep painting', danger: true }); if (!ok) return; // Reset wipes the saved paint AND the "✓ Done" flag — the kid is // starting this picture fresh. await clearProgressFor(currentImage.id); await unmarkDone(currentImage.id); startPaint(currentImage.id); } function paintSame(){ if (currentImage) startPaint(currentImage.id); } // Re-entrancy guard — exitToStart runs across multiple async stages // (confirm dialog → overlay → wait → save → finish), and we don't // want a second Exit tap to start a parallel exit mid-flight. let _exiting = false; async function exitToStart(opts){ opts = opts || {}; if (_exiting) return; _exiting = true; // Force-close any stroke that's still mid-flight BEFORE showing the // confirm dialog — if the kid's finger was still down when they // tapped Exit, commit whatever was drawn rather than letting it // linger as an open stroke. // Flush queued path tokens first so the saved stroke is complete. _flushPathDSync(); if (currentStrokeGroup){ if (currentStrokeGroup.children.length > 0){ strokeStack.push(currentStrokeGroup); updateUndoEnabled(); } else { currentStrokeGroup.remove(); } currentStrokeGroup = null; currentPath = null; } painting = false; lastSvgPt = null; if (typeof activePointers !== 'undefined' && activePointers && activePointers.clear){ activePointers.clear(); } // Capture which worksheet was open before we null out currentImage. const exitingId = currentImage ? currentImage.id : null; // Save-or-discard prompt. Two purposes: // 1. Lets the kid say "I don't want this one" without going to // Reset and then Exit (one tap instead of two). // 2. Buys real wall-clock time. While the dialog is up, iOS Safari // finishes flushing whatever pointer events were still queued // from a long continuous stroke. So by the time we save, the // DOM is fully up to date — no more "last bit of stroke missing" // symptom on iPad even for very long fills. // Can be skipped by internal flows that have already confirmed the // save decision before calling exitToStart(). let action = 'save'; if (!opts.skipPrompt){ const ok = await showConfirm({ icon: '🎨', title: 'Save your painting?', message: 'Keep your work to come back to, or start fresh next time?', confirmText: 'Save', cancelText: 'Discard', danger: false, // Backdrop tap and Escape do nothing — kid must explicitly // pick Save or Discard. An accidental backdrop tap shouldn't // silently throw away their painting. noDismiss: true }); action = ok ? 'save' : 'discard'; } if (action === 'discard'){ // Wipe this worksheet's saved progress entirely. No overlay needed // — there's nothing to wait for. if (exitingId) await clearProgressFor(exitingId); finishExit(exitingId); return; } // Save path: show the saving overlay briefly. Storage is in-memory // so the snapshot itself is instant, but the two-frame wait still // matters — it gives iOS Safari a paint cycle or two to flush any // final pointer events that might still be queued from a long // stroke before we read paintGroups.all.innerHTML. const overlay = document.getElementById('savingOverlay'); if (overlay) overlay.classList.remove('hidden'); // Two animation frames lets iOS commit any final pointermoves into // the DOM before we read paintGroups.all.innerHTML. await new Promise(resolve => { requestAnimationFrame(() => requestAnimationFrame(resolve)); }); try { await saveCurrentPainting(); } catch (e){ console.warn('exitToStart save failed:', e); } finishExitSave(exitingId); } // Shared teardown after either Save or Discard. Swaps the paint // screen out for the picker, scrolls to the worksheet card the kid // just left, and hides the saving overlay (if it was showing). function _finishExitTeardown(exitingId){ if (document.body.classList.contains('fullscreen-mode')) toggleFullscreen(); currentImage = null; showScreen('startScreen'); requestAnimationFrame(() => { renderPicker(); if (exitingId){ const card = document.querySelector('#picker .pick-card[data-id="' + exitingId + '"]'); if (card){ const section = card.closest('.picker-section'); if (section && section.classList.contains('collapsed')){ section.classList.remove('collapsed'); const hdr = section.querySelector('.picker-section-header'); if (hdr) hdr.setAttribute('aria-expanded', 'true'); if (section.classList.contains('sec-inprogress')) _pickerSectionsExpanded.inProgress = true; else if (section.classList.contains('sec-notstarted')) _pickerSectionsExpanded.notStarted = true; else if (section.classList.contains('sec-done')) _pickerSectionsExpanded.done = true; } card.scrollIntoView({ block: 'center', behavior: 'auto' }); } } const overlay = document.getElementById('savingOverlay'); if (overlay) overlay.classList.add('hidden'); _exiting = false; }); if (location.hash){ history.replaceState(null, '', location.pathname + location.search); } } function finishExit(exitingId){ _finishExitTeardown(exitingId); } function finishExitSave(exitingId){ _finishExitTeardown(exitingId); } // Page-lifecycle saves — iPad/iOS Safari fires `pagehide` and // `visibilitychange` (with document.hidden = true) when the user // switches apps, locks the screen, or backgrounds the tab. If we don't // flush here, an in-progress stroke is lost. Both events are the // reliable hooks for "the page is going away or might be killed". // Page-lifecycle saves — the in-memory snapshot lets the kid bounce // between worksheets within a session without losing strokes. The // data does not survive page reload. Fire-and-forget here. window.addEventListener('pagehide', () => { flushSavePainting().catch(() => {}); }); document.addEventListener('visibilitychange', () => { if (document.hidden) flushSavePainting().catch(() => {}); }); // Toggle immersive / fullscreen mode. Adds a CSS class that hides // chrome and lets the artwork stretch to the full width, AND calls // requestFullscreen() on browsers that support it (so the address bar // and tabs hide too). On iOS Safari, where the API is restricted, the // CSS class alone still gives a much bigger drawing area. function toggleFullscreen(){ const inFullscreen = document.body.classList.contains('fullscreen-mode'); const btn = document.getElementById('fullscreenBtn'); if (inFullscreen){ document.body.classList.remove('fullscreen-mode'); if (document.fullscreenElement && document.exitFullscreen){ document.exitFullscreen().catch(() => {}); } else if (document.webkitFullscreenElement && document.webkitExitFullscreen){ document.webkitExitFullscreen(); } if (btn) btn.textContent = '⛶ Fullscreen'; // Scroll to top so the page doesn't end up half-way under the // browser nav bar when it slides back in. window.scrollTo({ top: 0, behavior: 'instant' }); } else { document.body.classList.add('fullscreen-mode'); const root = document.documentElement; if (root.requestFullscreen){ root.requestFullscreen().catch(() => {}); } else if (root.webkitRequestFullscreen){ root.webkitRequestFullscreen(); } if (btn) btn.textContent = '⛶ Exit fullscreen'; } // Re-pin the toolbar to the new viewport top. scheduleStickyRecompute(); } // Browser-level fullscreen sometimes drops out unexpectedly (ESC key, // iOS gesture, switching apps, the on-screen keyboard appearing, etc.). // We DO NOT auto-clear our CSS fullscreen-mode when that happens — // the kid should keep the immersive layout until they explicitly tap // Exit. We just re-pin the toolbar so it stays anchored. document.addEventListener('fullscreenchange', () => { scheduleStickyRecompute(); }); document.addEventListener('webkitfullscreenchange', () => { scheduleStickyRecompute(); }); // In-app confirmation dialog. Returns a Promise that resolves to true // when the user confirms, false otherwise. Replaces window.confirm so // destructive prompts feel native to the tool. function showConfirm(opts = {}){ const { icon = '⚠️', title = 'Are you sure?', message = '', confirmText = 'OK', cancelText = 'Cancel', danger = false, // Set true when neither "outcome" should be triggered by an // ambiguous dismissal (backdrop tap, Escape key). Used by the // Save-or-Discard prompt at exit-time, where an accidental // backdrop tap shouldn't silently trash the kid's painting. noDismiss = false } = opts; return new Promise(resolve => { const modal = document.getElementById('confirmModal'); if (!modal){ resolve(window.confirm(message || title)); return; } // Move the modal up to so it isn't trapped inside // .container's stacking context (.container has z-index: 2 and // creates a stacking context that caps everything inside it). // Without this re-parent, the modal renders BEHIND the SBM Manage // Players overlay (z-index 1000 at body level) and the kid can't // see or tap the "Remove" / "Cancel" buttons. We only move once; // subsequent shows leave it at body level. if (modal.parentNode !== document.body){ document.body.appendChild(modal); } document.getElementById('confirmIcon').textContent = icon; document.getElementById('confirmTitle').textContent = title; document.getElementById('confirmMessage').textContent = message; const okBtn = document.getElementById('confirmOk'); const cancelBtn = document.getElementById('confirmCancel'); const backdrop = document.getElementById('confirmBackdrop'); okBtn.textContent = confirmText; cancelBtn.textContent = cancelText; okBtn.className = 'btn ' + (danger ? 'btn-warning' : 'btn-primary'); modal.classList.remove('hidden'); function done(value){ modal.classList.add('hidden'); okBtn.removeEventListener('click', onOk); cancelBtn.removeEventListener('click', onCancel); backdrop.removeEventListener('click', onBackdrop); document.removeEventListener('keydown', onKey); resolve(value); } function onKey(e){ if (e.key === 'Escape') { if (!noDismiss) done(false); } else if (e.key === 'Enter') done(true); } function onOk(){ done(true); } function onCancel(){ done(false); } function onBackdrop(){ if (!noDismiss) done(false); } okBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel); backdrop.addEventListener('click', onBackdrop); document.addEventListener('keydown', onKey); setTimeout(() => okBtn.focus(), 50); }); } function celebrate(em){ const el = document.getElementById('celebration'); const emEl = document.getElementById('celebrationEmoji'); emEl.textContent = em; el.classList.remove('hidden'); emEl.style.animation = 'none'; void emEl.offsetWidth; emEl.style.animation = ''; setTimeout(() => { el.classList.add('hidden'); }, 1400); } // ============ Init ============ // Direct-link routing: if the URL has a hash matching a worksheet id, // open that worksheet straight away (lets parents bookmark e.g. // .../colouring-in-practice.html#soccer-day). hashchange fires on back/forward // navigation and on manual URL edits, keeping screen + URL in sync. async function applyHashFromURL(){ const id = location.hash.replace(/^#/, ''); if (id){ const img = IMAGES.find(i => i.id === id); if (img){ if (!currentImage || currentImage.id !== id){ startPaint(id); } return; } // Hash doesn't match a known worksheet — drop it so the picker shows. history.replaceState(null, '', location.pathname + location.search); } // Empty (or just-cleared) hash: if we're mid-paint, go back to picker. // ORDER MATTERS — flushSavePainting() reads currentImage, so it must // run BEFORE we null it out. The previous order saved nothing because // saveCurrentPainting() bails early when currentImage is null, which // is how strokes drawn under a player could appear to vanish if the // kid exited via the iOS back gesture / hashchange instead of the // Exit button (the Exit button orders these correctly already). if (currentImage){ await flushSavePainting(); currentImage = null; showScreen('startScreen'); renderPicker(); } } window.addEventListener('hashchange', applyHashFromURL); // Shared privacy mode hides name collection. (function initPlayerPicker(){ if (!window.SBM) return; const host = document.getElementById('playerPicker'); // Centralised refresh handler. Shared privacy mode calls onChange // after hiding the name controls. async function syncFromPlayer(){ await flushSavePainting(); const hint = document.getElementById('playerHint'); if (hint) hint.classList.add('hidden'); renderPicker(); } if (host) SBM.injectPlayerPicker(host, { hideLocale: true, onChange: () => syncFromPlayer() }); const mgr = document.getElementById('managePlayersSlot'); if (mgr) SBM.injectManagePlayersButton(mgr, { onChange: () => syncFromPlayer() }); const hint = document.getElementById('playerHint'); if (hint) hint.classList.add('hidden'); })(); SBM.bindClicks({ undoBtn: undoLast, eraserBtn: toggleEraser, toneBtn: toggleTone, fullscreenBtn: toggleFullscreen, resetZoomBtn: resetZoom, printDialogBtn: openPrintDialog, resetPictureBtn: resetPicture, exitPaintBtn: exitToStart, paintSameBtn: paintSame, pickAnotherPictureBtn: exitToStart, fullscreenExitBtn: toggleFullscreen, printTabPainted: () => setPrintVariant('painted'), printTabBlank: () => setPrintVariant('blank'), closePrintBtn: closePrintModal, openPrintTabBtn: openPrintInNewTab, printSaveLink: onPrintSaveClick }); document.querySelectorAll('.size-btn[data-size]').forEach(btn => { btn.addEventListener('click', () => setBrush(btn.dataset.size)); }); const printBackdrop = document.getElementById('printBackdrop'); if (printBackdrop) printBackdrop.addEventListener('click', closePrintModal); renderPicker(); showScreen('startScreen'); // If the page was loaded with a hash, jump straight to that worksheet. if (location.hash) applyHashFromURL(); // ============ Back-to-top button ============ // Show the floating button once the user scrolls past 300px so it // only appears when there's actually somewhere to scroll back to. // Use rAF-throttled handler (passive listener) so this doesn't fight // with the worksheet picker's scroll performance on long lists. (function setupBackToTop(){ const btn = document.getElementById('backToTopBtn'); if (!btn) return; const SHOW_AT = 300; // px scrolled before button appears let ticking = false; function update(){ ticking = false; const y = window.pageYOffset || document.documentElement.scrollTop || 0; btn.classList.toggle('visible', y > SHOW_AT); } function onScroll(){ if (ticking) return; ticking = true; requestAnimationFrame(update); } window.addEventListener('scroll', onScroll, { passive: true }); update(); // initial state in case the page restored a scroll position btn.addEventListener('click', () => { // prefers-reduced-motion: respect the OS setting and jump instead. const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' }); }); })();