Files
bible-app/popup-draw.js
T
christianandClaude Opus 5 0db5e9b07d Bible App 1.4.34 — full source
First commit on the local Gitea server, made after moving the tree off the
Windows machine. Build outputs, node_modules and the AI models stay out
(see .gitignore); everything the installer build needs is tracked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 13:05:20 -04:00

376 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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);
})();