Files
bible-app/popup-draw.js
T

376 lines
14 KiB
JavaScript
Raw Normal View History

2026-09-24 13:05:20 -04:00
/*
* 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);
})();