376 lines
14 KiB
JavaScript
376 lines
14 KiB
JavaScript
/*
|
||||
|
|
* 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);
|
|||
|
|
})();
|