/* * popup-draw.js — draw on the word-definition popup cards. Isolated + ADDITIVE. * * The pastor uses the Greek/Hebrew cards as a teaching tool, so he wants to mark them up live: * underline a gloss, circle a root. This adds a drawing surface over the card's CONTENT area only — * the header stays a clean drag handle and the × stays tappable. * * Design notes (why it looks like this): * - Zero changes to app.js. Cards are found with a MutationObserver, so every path that creates one * (fresh double-tap, restored card, scripture-reference card) is covered automatically. * - The canvas only swallows touches while a DRAWING tool (pen / highlighter / eraser) is selected. * With the select tool or no tool, it is transparent to input, so scrolling the card and dragging * it by the header behave exactly as before. * - The canvas lives INSIDE the scrolling content and is as tall as the content, so marks stay * stuck to the words they annotate when the card is scrolled. * - Tool colour/size come from the app's own getCurrentDrawingSettings(), so the pen colour picker * and highlighter sizing apply here with no duplicated config. * - Marks persist per passage + card title, and are wiped by the same trash-hold that clears * annotations (see the '-popup-strokes' key in app.js's clear-all sweep). */ (function () { 'use strict'; var STORE_SUFFIX = '-popup-strokes'; var DRAW_TOOLS = ['pen', 'highlight', 'erase']; var armed = false; function activeTool() { try { return window.currentTool || null; } catch (_) { return null; } } function isDrawTool() { return DRAW_TOOLS.indexOf(activeTool()) !== -1; } function settings() { try { if (typeof window.getCurrentDrawingSettings === 'function') return window.getCurrentDrawingSettings(); } catch (_) {} return { color: '#ff0000', size: 4, alpha: 1, composite: 'source-over' }; } // ---- storage ------------------------------------------------------------- function passageKey() { // app.js does not export getCurrentPassageKey, so derive the same shape from the active tab. try { var app = window.bibleApp; if (!app || !app.tabs) return null; var tab = app.tabs.find(function (t) { return t.id === app.activeTabId; }); if (!tab || !tab.book) return null; // The sync tab follows the same split as the passage ink (Settings > ProPresenter Sync). var stem = (tab.ppsync && typeof window.ppsyncSeparateInk === 'function' && window.ppsyncSeparateInk()) ? 'ppsync-annotations-' : 'bible-annotations-'; return stem + tab.book + '-' + tab.chapter + '-' + (tab.translation || 'ESV'); } catch (_) { return null; } } function cardKey(popup) { try { var t = popup.querySelector('.word-info-title'); return (t && t.textContent ? t.textContent.trim() : '') || popup.dataset.popupKey || ''; } catch (_) { return ''; } } function readStore() { var pk = passageKey(); if (!pk) return {}; try { return JSON.parse(localStorage.getItem(pk + STORE_SUFFIX) || '{}') || {}; } catch (_) { return {}; } } function writeStore(obj) { var pk = passageKey(); if (!pk) return; try { if (!obj || !Object.keys(obj).length) localStorage.removeItem(pk + STORE_SUFFIX); else localStorage.setItem(pk + STORE_SUFFIX, JSON.stringify(obj)); } catch (_) {} } function saveStrokes(popup) { var k = cardKey(popup); if (!k) return; var store = readStore(); var strokes = popup.__pdStrokes || []; if (strokes.length) store[k] = strokes; else delete store[k]; writeStore(store); } function loadStrokes(popup) { var k = cardKey(popup); if (!k) return []; var s = readStore()[k]; return Array.isArray(s) ? s : []; } // ---- canvas ------------------------------------------------------------- function sizeCanvas(popup) { var content = popup.__pdContent, cv = popup.__pdCanvas; if (!content || !cv) return; var dpr = window.devicePixelRatio || 1; var w = content.clientWidth; var h = Math.max(content.scrollHeight, content.clientHeight); if (!w || !h) return; if (cv.__w === w && cv.__h === h) return; // no-op unless the box really changed cv.__w = w; cv.__h = h; cv.style.width = w + 'px'; cv.style.height = h + 'px'; cv.width = Math.max(1, Math.round(w * dpr)); cv.height = Math.max(1, Math.round(h * dpr)); var ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); redraw(popup); } function redraw(popup) { var cv = popup.__pdCanvas; if (!cv) return; var ctx = cv.getContext('2d'); ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, cv.width, cv.height); ctx.restore(); (popup.__pdStrokes || []).forEach(function (s) { paintStroke(ctx, s); }); } function paintStroke(ctx, s) { if (!s || !s.pts || s.pts.length < 1) return; ctx.save(); ctx.globalCompositeOperation = s.composite || 'source-over'; ctx.globalAlpha = (typeof s.alpha === 'number') ? s.alpha : 1; ctx.strokeStyle = s.color || '#ff0000'; ctx.lineWidth = s.size || 4; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(s.pts[0].x, s.pts[0].y); if (s.pts.length === 1) { // A tap: a dot, not nothing. ctx.lineTo(s.pts[0].x + 0.01, s.pts[0].y); } else { for (var i = 1; i < s.pts.length; i++) ctx.lineTo(s.pts[i].x, s.pts[i].y); } ctx.stroke(); ctx.restore(); } function posIn(cv, e) { var r = cv.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; } function attach(popup) { if (!popup || popup.__pdWired) return; var content = popup.querySelector('.word-info-content'); if (!content) return; popup.__pdWired = true; popup.__pdContent = content; popup.__pdStrokes = loadStrokes(popup); // The canvas has to sit in the content's own scroll space so marks track the text. if (getComputedStyle(content).position === 'static') content.style.position = 'relative'; var cv = document.createElement('canvas'); cv.className = 'popup-draw-canvas'; cv.style.cssText = 'position:absolute; left:0; top:0; pointer-events:none; z-index:5; touch-action:none;'; content.appendChild(cv); popup.__pdCanvas = cv; sizeCanvas(popup); /* * ONE finger draws, TWO fingers scroll — the same rule as the Bible text, so muscle memory * carries over. Without this the canvas swallowed the whole gesture and a two-finger scroll left * a scribble across the card. * * The card sets touch-action:none on itself (for dragging), so the browser will not scroll it for * us; we move the right container by hand, exactly as the laser/spotlight overlay does. */ var drawing = false, cur = null, activeId = null; var pointers = new Map(); var gesture = null; // null | 'draw' | 'scroll' var scrollTarget = null, lastCentroidY = 0; function centroidY() { var s = 0, n = 0; pointers.forEach(function (p) { s += p.y; n++; }); return n ? s / n : 0; } // Scroll the card's own text if it overflows, otherwise the page behind it. function resolveScrollTarget() { try { if (content.scrollHeight > content.clientHeight + 2) return content; var el = popup.parentElement; while (el && el !== document.body) { var st = getComputedStyle(el); if ((st.overflowY === 'auto' || st.overflowY === 'scroll' || el.id.indexOf('readingArea') === 0) && el.scrollHeight > el.clientHeight + 2) return el; el = el.parentElement; } } catch (_) {} return null; } function abortStroke() { drawing = false; cur = null; activeId = null; popup.__pdBase = null; redraw(popup); // wipe the partial mark the aborted gesture painted } /* * A translucent highlighter has to be painted as ONE path. Painting it as a chain of two-point * segments overlaps the round caps at every joint, and each overlap blends the colour twice — that * is the row of dark circles the operator saw while drawing, which then snapped to an even band on * release, because committing redraws the stroke whole. * * So when a stroke begins, freeze the finished strokes into an offscreen copy; on every move, * restore that copy and redraw the CURRENT stroke complete. The picture is then identical during * and after the stroke, and the per-move cost does not grow with how much is already on the card. */ function snapshotBase() { try { var base = popup.__pdBase || (popup.__pdBase = document.createElement('canvas')); base.width = cv.width; base.height = cv.height; var bctx = base.getContext('2d'); bctx.setTransform(1, 0, 0, 1, 0, 0); bctx.clearRect(0, 0, base.width, base.height); bctx.drawImage(cv, 0, 0); } catch (_) { popup.__pdBase = null; } } function paintLive() { if (!cur) return; var ctx = cv.getContext('2d'); ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); // device pixels for the straight copy ctx.clearRect(0, 0, cv.width, cv.height); if (popup.__pdBase) { try { ctx.drawImage(popup.__pdBase, 0, 0); } catch (_) {} } ctx.restore(); // back to the DPR transform for drawing paintStroke(ctx, cur); // whole stroke, one path, alpha applied once } cv.addEventListener('pointerdown', function (e) { pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (!isDrawTool()) return; // not drawing: card behaves normally if (pointers.size >= 2) { // A second finger means SCROLL. Throw away whatever the first finger started. if (drawing) abortStroke(); gesture = 'scroll'; scrollTarget = resolveScrollTarget(); lastCentroidY = centroidY(); e.preventDefault(); return; } if (gesture === 'scroll') return; // still finishing a scroll: never start a mark e.preventDefault(); e.stopPropagation(); // never let this start a card drag gesture = 'draw'; activeId = e.pointerId; try { cv.setPointerCapture(e.pointerId); } catch (_) {} var st = settings(); var isErase = activeTool() === 'erase'; cur = { color: st.color, size: st.size, alpha: isErase ? 1 : (typeof st.alpha === 'number' ? st.alpha : 1), composite: isErase ? 'destination-out' : (st.composite || 'source-over'), pts: [posIn(cv, e)] }; drawing = true; snapshotBase(); // freeze what is already on the card, so the live stroke can be redrawn whole }); cv.addEventListener('pointermove', function (e) { if (!pointers.has(e.pointerId)) return; pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (gesture === 'scroll' && pointers.size >= 2) { var y = centroidY(); var dy = y - lastCentroidY; lastCentroidY = y; if (scrollTarget) { try { scrollTarget.scrollTop -= dy; } catch (_) {} } e.preventDefault(); return; } if (gesture !== 'draw' || !drawing || e.pointerId !== activeId) return; e.preventDefault(); cur.pts.push(posIn(cv, e)); paintLive(); }); function release(e) { if (e) pointers.delete(e.pointerId); // Only settle up once EVERY finger is off — lifting one of two must not start a new mark. if (pointers.size > 0) return; if (gesture === 'draw' && drawing && cur && cur.pts.length) { popup.__pdStrokes.push(cur); saveStrokes(popup); redraw(popup); // commit cleanly (handles eraser compositing) } popup.__pdBase = null; // drop the frozen copy drawing = false; cur = null; activeId = null; gesture = null; scrollTarget = null; } cv.addEventListener('pointerup', release); cv.addEventListener('pointercancel', release); cv.addEventListener('pointerleave', function (e) { // A pointer that leaves the canvas mid-stroke would otherwise never be released. if (pointers.has(e.pointerId) && gesture === 'draw') release(e); }); // Keep the canvas matched to the card as content/height changes. try { var ro = new ResizeObserver(function () { sizeCanvas(popup); }); ro.observe(content); popup.__pdRO = ro; } catch (_) { setTimeout(function () { sizeCanvas(popup); }, 400); } setTimeout(function () { sizeCanvas(popup); }, 120); // after the show animation settles applyArmed(popup); } function applyArmed(popup) { var cv = popup && popup.__pdCanvas; if (!cv) return; // Only intercept touches while a drawing tool is selected. cv.style.pointerEvents = armed ? 'auto' : 'none'; } function refreshArmed() { var next = isDrawTool(); if (next === armed) return; armed = next; document.querySelectorAll('.word-info-popup').forEach(applyArmed); } function scanExisting() { document.querySelectorAll('.word-info-popup').forEach(attach); } function start() { scanExisting(); try { var mo = new MutationObserver(function (muts) { for (var i = 0; i < muts.length; i++) { var added = muts[i].addedNodes; for (var j = 0; j < added.length; j++) { var n = added[j]; if (!n || n.nodeType !== 1) continue; if (n.classList && n.classList.contains('word-info-popup')) attach(n); else if (n.querySelectorAll) n.querySelectorAll('.word-info-popup').forEach(attach); } } }); mo.observe(document.body, { childList: true, subtree: true }); } catch (_) {} // Cheap watcher: one string compare + a class flip when the tool changes. setInterval(refreshArmed, 300); } // Public handle: lets the app clear popup marks alongside other annotations. window.popupDraw = { clearCard: function (popup) { if (!popup) return; popup.__pdStrokes = []; saveStrokes(popup); redraw(popup); }, clearAllForPassage: function () { document.querySelectorAll('.word-info-popup').forEach(function (p) { p.__pdStrokes = []; redraw(p); }); writeStore({}); }, isArmed: function () { return armed; } }; if (document.readyState !== 'loading') start(); else document.addEventListener('DOMContentLoaded', start); })();