No Greek dictionary entry found.
';
}
} else {
wordInfo.sourceLabel = 'WLCa';
wordInfo.sourceLang = 'he';
wordInfo.sourceDir = 'rtl';
wordInfo.sourceClass = 'hebrew';
wordInfo.dictionaryLabel = wordInfo.dictionaryLabel || 'BDBT';
wordInfo.dictionaryClass = wordInfo.dictionaryClass || 'hebrew bdbt';
}
} catch (_) {}
const shouldRefreshPopup = (popup) => {
if (!popup) return false;
if (!isNTPopup) return false;
try {
const labels = Array.from(popup.querySelectorAll('.word-info-label')).map(el => (el.textContent || '').trim());
const hasWlca = labels.some(l => l.toLowerCase() === 'wlca');
const hasBdbt = labels.some(l => l.toLowerCase() === 'bdbt');
const hasGreek = labels.some(l => l.toLowerCase().includes('strong'));
return hasWlca || hasBdbt || !hasGreek;
} catch (_) {
return false;
}
};
// Get reading area early to check for hidden popups
const wordSpan = event && event.target ? event.target : null;
const readingArea = wordSpan ? wordSpan.closest('.reading-area') : null;
// Early return if we can't find the word span or reading area
if (!wordSpan || !readingArea) {
console.error('Cannot show popup: missing wordSpan or readingArea');
return;
}
// Check if popup for this word+reference+testament already exists (including hidden ones)
const existingPopup = activeWordPopups.find(popup => {
if (!popup || !popup.dataset) return false;
const popupKeyAttr = popup.dataset.popupKey || popup.getAttribute('data-popup-key');
return popupKeyAttr === popupKey && popup.parentElement === readingArea;
});
// Also check for hidden popups in the reading area (from inactive tabs)
let hiddenPopup = null;
if (!existingPopup && readingArea) {
const allPopups = readingArea.querySelectorAll('.word-info-popup');
for (const popup of allPopups) {
// Check both dataset.popupKey and getAttribute for compatibility
const popupKeyAttr = popup.dataset?.popupKey || popup.getAttribute('data-popup-key');
if (popupKeyAttr === popupKey) {
hiddenPopup = popup;
break;
}
}
}
const popupToUse = existingPopup || hiddenPopup;
if (popupToUse) {
normalizePopupTestament(popupToUse);
if (shouldRefreshPopup(popupToUse)) {
closeSpecificWordPopup(popupToUse);
} else {
// If popup exists (or was hidden), show it and bring it to front
if (hiddenPopup && !existingPopup) {
// Restore hidden popup to active list
popupToUse.style.display = '';
popupToUse.style.visibility = '';
if (activeWordPopups.indexOf(popupToUse) === -1) {
activeWordPopups.push(popupToUse);
}
}
// Bring to front and highlight briefly
popupToUse.style.zIndex = Math.max(...activeWordPopups.map(p => parseInt(p.style.zIndex) || 30000)) + 1;
popupToUse.classList.add('highlight-existing');
setTimeout(() => popupToUse.classList.remove('highlight-existing'), 500);
console.log('Word popup already exists for:', popupKey, hiddenPopup ? '(was hidden, now shown)' : '');
return;
}
}
// Create popup element
const popup = document.createElement('div');
popup.className = 'word-info-popup';
// Generate unique ID for this popup
const popupId = `word-popup-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
popup.id = popupId;
popup.dataset.popupKey = popupKey;
// With a verse-exact card, the title is the LANGUAGE + reference ("Greek — John 3:16") —
// the original word itself is displayed large on the card.
const title = (wordInfo && wordInfo.interlinear)
? `${isNTPopup ? 'Greek' : 'Hebrew'}${wordInfo.reference ? ' — ' + wordInfo.reference : ''}`
: (wordInfo && wordInfo.reference ? `${wordInfo.word} — ${wordInfo.reference}` : `${wordInfo.word}`);
const sourceLabel = wordInfo && wordInfo.sourceLabel ? String(wordInfo.sourceLabel) : 'WLCa';
const sourceHtml = wordInfo && (wordInfo.sourceHtml || wordInfo.wlcaHtml) ? (wordInfo.sourceHtml || wordInfo.wlcaHtml) : '';
const sourceClass = wordInfo && wordInfo.sourceClass ? String(wordInfo.sourceClass) : (sourceLabel === 'NTGT' ? 'greek' : 'hebrew');
const sourceLang = wordInfo && wordInfo.sourceLang ? String(wordInfo.sourceLang) : (sourceClass === 'hebrew' ? 'he' : 'el');
const sourceDir = wordInfo && wordInfo.sourceDir ? String(wordInfo.sourceDir) : (sourceClass === 'hebrew' ? 'rtl' : 'ltr');
const dictionaryLabel = wordInfo && wordInfo.dictionaryLabel ? String(wordInfo.dictionaryLabel) : 'BDBT';
const dictionaryHtml = wordInfo && (wordInfo.dictionaryHtml || wordInfo.bdbtHtml) ? (wordInfo.dictionaryHtml || wordInfo.bdbtHtml) : '';
const dictionaryClass = wordInfo && wordInfo.dictionaryClass ? String(wordInfo.dictionaryClass) : 'hebrew bdbt';
// Check visibility settings - granular OT and NT toggles
const showTitle = isPopupSectionVisible('title');
const showOTWLCa = isPopupSectionVisible('ot_wlca') && !isNTPopup;
const showNTNTGT = isPopupSectionVisible('nt_ntgt') && isNTPopup;
const showDivider = isPopupSectionVisible('divider');
// Check if dictionary has content (if any individual field toggle is ON and has data)
// The section-level toggle (ot_bdbt/nt_strongs_greek) acts as a master switch,
// but if individual fields are enabled, they should show
const otBdbtMasterToggle = isPopupSectionVisible('ot_bdbt');
const ntStrongsMasterToggle = isPopupSectionVisible('nt_strongs_greek');
const dictionaryHasContent = dictionaryHtml && dictionaryHtml.trim() &&
!dictionaryHtml.match(/^No (dictionary|Greek dictionary) entry found/);
// The dictionary section shows only when its master toggle is ON and there is content —
// the toggle must actually hide it (the verse-exact card carries the definition now).
const showOTBDBT = !isNTPopup && otBdbtMasterToggle && dictionaryHasContent;
const showNTStrongsGreek = isNTPopup && ntStrongsMasterToggle && dictionaryHasContent;
// Build popup HTML based on visibility settings
let popupContent = '';
let hasContent = false;
// Verse-exact original word card (STEP interlinear) — shown FIRST; every field is
// individually customizable in Settings (separate Hebrew/OT and Greek/NT groups).
try {
if (wordInfo && wordInfo.interlinear) {
const ilHtml = buildInterlinearSectionHtml(wordInfo.interlinear, isNTPopup, wordInfo.interlinearAttribution || '');
if (ilHtml) {
popupContent += ilHtml;
if (showDivider) popupContent += `
`;
hasContent = true;
}
}
} catch (_) {}
// OT sections
if (!isNTPopup) {
if (showOTWLCa) {
popupContent += `
${escapeHtml(sourceLabel)}
${sourceHtml || ''}
`;
hasContent = true;
}
// Only add divider if both sections will actually have content
if (showDivider && showOTWLCa && showOTBDBT && dictionaryHasContent) {
popupContent += `
`;
}
if (showOTBDBT && dictionaryHasContent) {
// Add section if dictionary HTML has content
popupContent += `
${escapeHtml(dictionaryLabel)}
${dictionaryHtml}
`;
hasContent = true;
}
}
// NT sections
if (isNTPopup) {
if (showNTNTGT) {
popupContent += `
${escapeHtml(sourceLabel)}
${sourceHtml || ''}
`;
hasContent = true;
}
// Only add divider if both sections will actually have content
if (showDivider && showNTNTGT && showNTStrongsGreek && dictionaryHasContent) {
popupContent += `
`;
}
if (showNTStrongsGreek && dictionaryHasContent) {
// Add section if dictionary HTML has content
popupContent += `
${escapeHtml(dictionaryLabel)}
${dictionaryHtml}
`;
hasContent = true;
}
}
// An empty card teaches nobody anything — it is just a grey "No information available" box in
// the middle of the screen that has to be dismissed live. If there is nothing to show, show
// nothing. (This is the card the duplicate double-tap used to produce.)
if (!hasContent && !popupContent) {
try { popup.remove(); } catch (_) {}
console.log('[POPUP] no data for this word — not opening an empty card');
return;
}
popup.innerHTML = `
${popupContent || '
No information available
'}
`;
// Stagger multiple open cards vertically only — horizontal stays centred.
/*
* The stagger that keeps stacked cards from hiding each other is counted from cards STILL ON SCREEN.
* activeWordPopups only shrinks via closeSpecificWordPopup, so any card removed another way (a tab
* switch, a passage change, a reload of the reading area) left a ghost in the array and every new card
* opened 20px lower than the last — measured 225px below the tapped line instead of 12px, which is why
* the card stopped appearing under the word. Prune the dead entries, and cap the stagger so a stack of
* cards can never walk far from the line either.
*/
for (let i = activeWordPopups.length - 1; i >= 0; i--) {
const p = activeWordPopups[i];
if (!p || !p.isConnected) activeWordPopups.splice(i, 1);
}
const popupOffset = Math.min(activeWordPopups.length, 3) * 20;
popup.style.position = 'absolute';
popup.style.zIndex = 30000 + activeWordPopups.length;
// Append popup to reading area so it scrolls with the text
readingArea.appendChild(popup);
// Measure and place only now that it is in the DOM (see positionWordPopup).
positionWordPopup(popup, wordSpan, readingArea, popupOffset);
activeWordPopups.push(popup);
// Store which tab this popup belongs to
const activeTabId = getActiveTabId();
popup.dataset.tabId = activeTabId || '';
// Normalize popup testament as a safety measure
normalizePopupTestament(popup);
// Show popup with animation, then re-place it once the real content height is known (fonts,
// images and the interlinear rows can all change the height after the first measurement).
setTimeout(() => {
popup.classList.add('show');
setTimeout(() => {
if (!popup._hasBeenDragged) positionWordPopup(popup, wordSpan, readingArea, popupOffset);
}, 60);
}, 10);
// Add close button event listener - use specific popup closure.
// Wired on pointerup/touchend as well as click: a click-only close is at the mercy of anything that
// cancels the compatibility click on a touchscreen, and then the X does nothing at all. The guard
// stops it running twice for one tap.
const closeBtn = popup.querySelector('.word-info-close');
let closeFired = 0;
const onCloseTap = (e) => {
if (window._popupDragging) return;
if (Date.now() - closeFired < 400) return; // same tap arriving by another route
closeFired = Date.now();
try { e.preventDefault(); e.stopPropagation(); } catch (_) {}
closeSpecificWordPopup(popup);
};
closeBtn.addEventListener('click', onCloseTap);
closeBtn.addEventListener('touchend', onCloseTap, { passive: false });
closeBtn.addEventListener('pointerup', onCloseTap, { passive: true });
// Make popup draggable with improved performance (relative to reading area)
makePopupDraggable(popup, readingArea);
// Ensure popup stays within the full scrollable content area
adjustPopupPosition(popup, readingArea);
// Save popup data for persistence
savePopupData(popup);
/*
* The positioning debug block that used to sit here referenced variables that do not exist in this
* scope (wordOffsetLeft, wordRect, areaRect, ...), so EVERY popup ended in a ReferenceError. It was
* caught upstream and only showed as a console error, but it aborted the rest of handleWordClick after
* the card appeared — and it ran a dozen console.log calls on the way. Removed: the card is on screen
* by this point and nothing after it needs to be logged.
*/
}
// Close specific word popup
function closeSpecificWordPopup(popup) {
if (!popup) return;
// Clean up drag event listeners
if (popup._cleanupDragListeners) {
popup._cleanupDragListeners();
}
// Remove from active popups array
const index = activeWordPopups.indexOf(popup);
if (index > -1) {
activeWordPopups.splice(index, 1);
}
// Remove popup data from storage
const passageKey = getCurrentPassageKey();
if (passageKey && popupHistory[passageKey]) {
const word = popup.querySelector('.word-info-title').textContent;
popupHistory[passageKey] = popupHistory[passageKey].filter(p => p.word !== word);
// Update localStorage
try {
localStorage.setItem(passageKey + '-popups', JSON.stringify(popupHistory[passageKey]));
} catch (e) {
console.log('[DEBUG] Failed to update popup data after close:', e);
}
}
popup.classList.remove('show');
// Remove from DOM after animation
setTimeout(() => {
if (popup && popup.parentNode) {
popup.parentNode.removeChild(popup);
}
}, 300);
console.log('Word popup closed. Remaining popups:', activeWordPopups.length);
}
// Close all word popups
function closeWordPopup() {
console.log('Closing all word popups:', activeWordPopups.length);
// Close all popups
activeWordPopups.forEach(popup => {
if (popup) {
// Clean up drag event listeners
if (popup._cleanupDragListeners) {
popup._cleanupDragListeners();
}
popup.classList.remove('show');
// Remove from DOM after animation
setTimeout(() => {
if (popup && popup.parentNode) {
popup.parentNode.removeChild(popup);
}
}, 300);
}
});
// Clear popup data from storage for ALL passages. We're removing every popup from the DOM
// (across all tabs/passages), so clearing only the current passage's storage would orphan the
// rest and resurrect them on a later visit. Keep DOM, array, and storage consistent.
try {
Object.keys(popupHistory).forEach(key => { popupHistory[key] = []; });
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (k && k.endsWith('-popups')) localStorage.removeItem(k);
}
} catch (_) {}
// Clear the array
activeWordPopups = [];
console.log('All word popups closed');
}
// Make popup draggable - SIMPLE & RELIABLE
function makePopupDraggable(popup, readingArea) {
let isDragging = false;
let offsetX = 0;
let offsetY = 0;
let activePointerType = null; // 'mouse' | 'touch' | 'pointer'
let activePointerId = null; // pointerId for Pointer Events
let activeTouchId = null; // identifier for Touch Events
let dragUsedFixedPosition = false;
let originalPositionCss = '';
let originalLeftCss = '';
let originalTopCss = '';
// Throw-to-close: samples of the horizontal movement, so a flick to EITHER side dismisses the card.
let throwStartX = 0, throwStartT = 0, throwLastX = 0, throwLastT = 0, throwPrevX = 0, throwPrevT = 0;
// Ensure the popup and key children do not trigger native panning/zooming on touch
try {
popup.style.touchAction = 'none';
const headerEl = popup.querySelector('.word-info-header');
const contentEl = popup.querySelector('.word-info-content');
if (headerEl) headerEl.style.touchAction = 'none';
if (contentEl) contentEl.style.touchAction = 'none';
} catch (_) {}
// Scroll/gesture lock helpers during drag to prevent PDF/canvas scrolling
let _lockedElements = [];
let _prevStyles = new Map();
let _globalScrollBlocker = null;
function lockScrolling() {
try {
const mediaCanvas = document.getElementById('mediaCanvas');
const canvasContent = mediaCanvas ? mediaCanvas.querySelector('.canvas-content') : null;
const elementsToLock = [readingArea, mediaCanvas, canvasContent].filter(Boolean);
_lockedElements = elementsToLock;
elementsToLock.forEach(el => {
const prev = {
touchAction: el.style.touchAction,
overscrollBehavior: el.style.overscrollBehavior,
pointerEvents: el.style.pointerEvents,
userSelect: el.style.userSelect
};
_prevStyles.set(el, prev);
el.style.touchAction = 'none';
el.style.overscrollBehavior = 'contain';
// Prevent underlying content from receiving gestures
el.style.pointerEvents = el === readingArea ? 'auto' : 'none';
el.style.userSelect = 'none';
});
// Global blockers to stop scroll on the page in case events leak
// Prevent scrolling/zooming globally, but DO NOT stop propagation so our drag handlers still run
_globalScrollBlocker = (ev) => { try { ev.preventDefault(); } catch(_) {} };
window.addEventListener('touchmove', _globalScrollBlocker, { passive: false, capture: true });
window.addEventListener('wheel', _globalScrollBlocker, { passive: false, capture: true });
} catch (_) {}
}
function unlockScrolling() {
try {
_lockedElements.forEach(el => {
const prev = _prevStyles.get(el) || {};
if (prev.touchAction !== undefined) el.style.touchAction = prev.touchAction;
if (prev.overscrollBehavior !== undefined) el.style.overscrollBehavior = prev.overscrollBehavior;
if (prev.pointerEvents !== undefined) el.style.pointerEvents = prev.pointerEvents;
if (prev.userSelect !== undefined) el.style.userSelect = prev.userSelect;
});
_lockedElements = [];
_prevStyles.clear();
if (_globalScrollBlocker) {
window.removeEventListener('touchmove', _globalScrollBlocker, { capture: true });
window.removeEventListener('wheel', _globalScrollBlocker, { capture: true });
_globalScrollBlocker = null;
}
} catch (_) {}
}
function startDrag(e) {
if (isDragging) return;
// Don't start drag if clicking on close button
try {
if (e.target && e.target.closest && e.target.closest('.word-info-close')) {
return;
}
} catch(_) {}
if (e.target.classList && e.target.classList.contains('word-info-close')) {
return;
}
// Ensure we don't get blocked by global slide handlers
try { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation && e.stopImmediatePropagation(); } catch (_) {}
isDragging = true;
window._popupDragging = true;
lockScrolling();
// Calculate geometry for both content and viewport
const popupRect = popup.getBoundingClientRect();
const areaRect = readingArea.getBoundingClientRect();
const scrollLeft = readingArea.scrollLeft || 0;
const scrollTop = readingArea.scrollTop || 0;
// Get current popup content position (where it actually is in the content)
const currentContentLeft = parseInt(popup.style.left) || 0;
const currentContentTop = parseInt(popup.style.top) || 0;
// Get mouse position
let clientX, clientY;
if (e.type === 'pointerdown') {
if (e.pointerType === 'mouse') {
activePointerType = 'mouse';
activePointerId = null;
} else {
activePointerType = 'pointer';
activePointerId = e.pointerId;
}
clientX = e.clientX;
clientY = e.clientY;
} else if (e.type === 'touchstart') {
activePointerType = 'touch';
const t = (e.changedTouches && e.changedTouches[0]) || (e.touches && e.touches[0]);
activeTouchId = t ? t.identifier : null;
clientX = t ? t.clientX : 0;
clientY = t ? t.clientY : 0;
} else {
activePointerType = 'mouse';
clientX = e.clientX;
clientY = e.clientY;
}
// Strategy: Use fixed positioning during drag for reliability on Slides/iOS
originalPositionCss = popup.style.position;
originalLeftCss = popup.style.left;
originalTopCss = popup.style.top;
dragUsedFixedPosition = true;
// Compute offset relative to viewport for fixed dragging
offsetX = clientX - popupRect.left;
offsetY = clientY - popupRect.top;
// Switch to fixed coordinates at current screen location
popup.style.position = 'fixed';
popup.style.left = popupRect.left + 'px';
popup.style.top = popupRect.top + 'px';
console.log('Start drag - popup content pos:', currentContentLeft, currentContentTop, 'offset:', offsetX, offsetY);
// Visual feedback
popup.classList.add('dragging');
// Bring to front immediately
popup.style.zIndex = Math.max(...activeWordPopups.map(p => parseInt(p.style.zIndex) || 30000)) + 1;
// Invalidate caches when starting drag
popup._cachedDimensions = null;
readingArea._cachedRect = null;
readingArea._cachedContentDimensions = null;
// Set up global move and end handlers
// Capture at the document level so slide-level blockers in capture phase don't intercept
document.addEventListener('mousemove', handleMove, { passive: false, capture: true });
document.addEventListener('touchmove', handleMove, { passive: false, capture: true });
document.addEventListener('mouseup', endDrag, { passive: true, capture: true });
document.addEventListener('touchend', endDrag, { passive: true, capture: true });
document.addEventListener('mouseleave', endDrag, { passive: true, capture: true });
// Pointer events path (helps on platforms that unify to Pointer Events and may suppress touch events)
document.addEventListener('pointermove', handleMove, { passive: false, capture: true });
document.addEventListener('pointerup', endDrag, { passive: true, capture: true });
document.addEventListener('pointercancel', endDrag, { passive: true, capture: true });
// Also attach listeners directly on the popup to guarantee event delivery on iOS Safari
popup.addEventListener('touchmove', handleMove, { passive: false, capture: true });
popup.addEventListener('pointermove', handleMove, { passive: false, capture: true });
popup.addEventListener('touchend', endDrag, { passive: true, capture: true });
popup.addEventListener('pointerup', endDrag, { passive: true, capture: true });
// For pointer events, capture the pointer on the popup to ensure we keep receiving moves
try {
if (e.type === 'pointerdown' && e.pointerType !== 'mouse' && typeof popup.setPointerCapture === 'function' && e.pointerId != null) {
popup.setPointerCapture(e.pointerId);
}
} catch (_) {}
// Additionally attach window-level listeners to win over any upstream preventions
try {
window.addEventListener('pointermove', handleMove, { passive: false, capture: true });
window.addEventListener('pointerup', endDrag, { passive: true, capture: true });
window.addEventListener('pointercancel', endDrag, { passive: true, capture: true });
window.addEventListener('touchmove', handleMove, { passive: false, capture: true });
window.addEventListener('touchend', endDrag, { passive: true, capture: true });
} catch (_) {}
}
// Throttle drag updates using requestAnimationFrame for better performance
let rafId = null;
let pendingMove = null;
function handleMove(e) {
if (!isDragging) return;
e.preventDefault();
// Get current mouse position
let clientX, clientY;
if (e.type === 'pointermove') {
if (activePointerType === 'pointer') {
if (e.pointerId !== activePointerId) return;
} else if (activePointerType === 'mouse') {
if (e.pointerType && e.pointerType !== 'mouse') return;
} else {
return;
}
clientX = e.clientX;
clientY = e.clientY;
} else if (e.type === 'touchmove') {
if (activePointerType !== 'touch') return;
let t = null;
if (e.touches && typeof activeTouchId === 'number') {
for (let i = 0; i < e.touches.length; i++) {
if (e.touches[i].identifier === activeTouchId) { t = e.touches[i]; break; }
}
}
if (!t) return; // ignore other fingers
clientX = t.clientX;
clientY = t.clientY;
} else if (e.type === 'mousemove') {
if (activePointerType === 'pointer' && activePointerId != null) return;
if (activePointerType !== 'mouse' && activePointerType !== 'pointer') return;
clientX = e.clientX;
clientY = e.clientY;
} else {
return;
}
// Store pending move for requestAnimationFrame throttling
pendingMove = { clientX, clientY };
// Sample the sideways motion for throw-to-close.
const nowT = Date.now();
if (!throwStartT) { throwStartX = clientX; throwStartT = nowT; throwLastX = clientX; throwLastT = nowT; }
throwPrevX = throwLastX; throwPrevT = throwLastT;
throwLastX = clientX; throwLastT = nowT;
// Schedule update via requestAnimationFrame if not already scheduled
if (rafId === null) {
rafId = requestAnimationFrame(() => {
rafId = null;
if (pendingMove && isDragging) {
const { clientX, clientY } = pendingMove;
pendingMove = null;
if (dragUsedFixedPosition) {
// Drag using viewport coordinates
const newFixedLeft = clientX - offsetX;
const newFixedTop = clientY - offsetY;
popup.style.left = newFixedLeft + 'px';
popup.style.top = newFixedTop + 'px';
} else {
// Content-relative dragging - cache getBoundingClientRect
if (!readingArea._cachedRect || readingArea._rectCacheTime !== Date.now()) {
readingArea._cachedRect = readingArea.getBoundingClientRect();
readingArea._rectCacheTime = Date.now();
}
const areaRect = readingArea._cachedRect;
const scrollLeft = readingArea.scrollLeft || 0;
const scrollTop = readingArea.scrollTop || 0;
const newContentX = (clientX - areaRect.left) + scrollLeft - offsetX;
const newContentY = (clientY - areaRect.top) + scrollTop - offsetY;
popup.style.left = `${newContentX}px`;
popup.style.top = `${newContentY}px`;
}
}
});
}
}
function endDrag(e) {
if (!isDragging) return;
// Only end for the initiating pointer
if (e && e.type === 'pointerup' || e && e.type === 'pointercancel') {
if (activePointerType === 'pointer' && e.pointerId !== activePointerId) return;
}
if (e && e.type === 'touchend') {
if (activePointerType === 'touch') {
let endedOurTouch = false;
if (e.changedTouches && typeof activeTouchId === 'number') {
for (let i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === activeTouchId) { endedOurTouch = true; break; }
}
}
if (!endedOurTouch) return; // ignore other fingers lifting
}
}
// Cancel any pending animation frame
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
pendingMove = null;
isDragging = false;
window._popupDragging = false;
// Remove visual feedback
popup.classList.remove('dragging');
// Invalidate caches after drag ends
popup._cachedDimensions = null;
readingArea._cachedRect = null;
readingArea._cachedContentDimensions = null;
// Clean up event listeners
document.removeEventListener('mousemove', handleMove, { capture: true });
document.removeEventListener('touchmove', handleMove, { capture: true });
document.removeEventListener('mouseup', endDrag, { capture: true });
document.removeEventListener('touchend', endDrag, { capture: true });
document.removeEventListener('mouseleave', endDrag, { capture: true });
document.removeEventListener('pointermove', handleMove, { capture: true });
document.removeEventListener('pointerup', endDrag, { capture: true });
document.removeEventListener('pointercancel', endDrag, { capture: true });
popup.removeEventListener('touchmove', handleMove, { capture: true });
popup.removeEventListener('pointermove', handleMove, { capture: true });
popup.removeEventListener('touchend', endDrag, { capture: true });
popup.removeEventListener('pointerup', endDrag, { capture: true });
try {
window.removeEventListener('pointermove', handleMove, { capture: true });
window.removeEventListener('pointerup', endDrag, { capture: true });
window.removeEventListener('pointercancel', endDrag, { capture: true });
window.removeEventListener('touchmove', handleMove, { capture: true });
window.removeEventListener('touchend', endDrag, { capture: true });
} catch (_) {}
/*
* Was the card thrown off the side? Decide BEFORE anything below touches its position or transition.
* Doing this at the end of endDrag produced the flicker the operator saw ("disappears, reappears for
* a split second, then closes"): the fixed->absolute conversion below schedules a
* requestAnimationFrame that restores the previous transition — clobbering the fade mid-flight — and
* closeSpecificWordPopup then removes .show, at which point the base rule's opening transform
* (scale(0.8) translateY(-10px)) replaces the outward slide and animates the card BACK into view.
* A throw now short-circuits all of it: no repositioning, no persistence, one quick fade owned by a
* CSS class that outranks both .show and the base rule.
*/
let thrownDir = 0;
try {
if (throwStartT) {
const dx = throwLastX - throwStartX;
const vx = (throwLastX - throwPrevX) / Math.max(1, throwLastT - throwPrevT); // px per ms
const far = Math.abs(dx) > Math.min(220, window.innerWidth * 0.20);
const fast = Math.abs(vx) > 0.85;
if ((far || fast) && Math.abs(dx) > 40) thrownDir = (fast ? (vx > 0 ? 1 : -1) : (dx > 0 ? 1 : -1));
}
} catch (_) {}
if (thrownDir) {
popup.classList.add('pd-closing', thrownDir > 0 ? 'pd-right' : 'pd-left');
unlockScrolling();
activePointerType = null;
activePointerId = null;
activeTouchId = null;
throwStartT = 0; throwLastT = 0; throwPrevT = 0;
setTimeout(() => { try { closeSpecificWordPopup(popup); } catch (_) {} }, 180);
return;
}
// Convert back to absolute positioning within readingArea content coordinates
if (dragUsedFixedPosition) {
// Temporarily disable transitions to prevent visible snap/jump on drop
const previousTransition = popup.style.transition || '';
popup.style.transition = 'none';
try {
const fixedLeft = parseFloat(popup.style.left) || 0;
const fixedTop = parseFloat(popup.style.top) || 0;
const areaRect = readingArea.getBoundingClientRect();
const scrollLeft = readingArea.scrollLeft || 0;
const scrollTop = readingArea.scrollTop || 0;
// Round to whole pixels to avoid sub-pixel jitter on TVs
const newContentLeft = Math.max(0, Math.round(fixedLeft - areaRect.left + scrollLeft));
const newContentTop = Math.max(0, Math.round(fixedTop - areaRect.top + scrollTop));
popup.style.position = 'absolute';
popup.style.left = newContentLeft + 'px';
popup.style.top = newContentTop + 'px';
} catch (_) {
// Fallback to original positioning if conversion fails
popup.style.position = originalPositionCss || 'absolute';
popup.style.left = originalLeftCss || popup.style.left;
popup.style.top = originalTopCss || popup.style.top;
} finally {
// Restore transitions on the next frame so final position is kept without animation
requestAnimationFrame(() => { popup.style.transition = previousTransition; });
}
dragUsedFixedPosition = false;
}
// Ensure popup stays within readingArea after conversion (tolerant clamp)
adjustPopupPosition(popup, readingArea);
// Mark popup as dragged and save data
popup._hasBeenDragged = true;
savePopupData(popup);
// Restore scrolling/gestures
unlockScrolling();
// Reset active pointer tracking
activePointerType = null;
activePointerId = null;
activeTouchId = null;
throwStartT = 0; throwLastT = 0; throwPrevT = 0;
}
function cleanupEventListeners() {
// Ensure drag state is clean
isDragging = false;
// Remove any remaining listeners
document.removeEventListener('mousemove', handleMove);
document.removeEventListener('touchmove', handleMove);
document.removeEventListener('mouseup', endDrag);
document.removeEventListener('touchend', endDrag);
document.removeEventListener('mouseleave', endDrag);
document.removeEventListener('pointermove', handleMove);
document.removeEventListener('pointerup', endDrag);
document.removeEventListener('pointercancel', endDrag);
unlockScrolling();
// Remove popup event listeners
popup.removeEventListener('mousedown', startDrag);
popup.removeEventListener('touchstart', startDrag);
popup.removeEventListener('pointerdown', startDrag);
activePointerType = null;
activePointerId = null;
activeTouchId = null;
}
// Prefer dragging from the header for reliability on touch TVs
const dragOriginEl = popup.querySelector('.word-info-header') || popup;
// Use capture so we grab the event before any global slide blockers.
dragOriginEl.addEventListener('mousedown', startDrag, { passive: false, capture: true });
dragOriginEl.addEventListener('touchstart', startDrag, { passive: false, capture: true });
dragOriginEl.addEventListener('pointerdown', startDrag, { passive: false, capture: true });
// Store cleanup function on popup for later use
popup._cleanupDragListeners = cleanupEventListeners;
}
// Adjust popup position to stay within the full scrollable content area
function adjustPopupPosition(popup, readingArea) {
// Layout metrics, NOT getBoundingClientRect: the card carries a CSS scale transform while it
// animates in (and .dragging scales it too), and a rect would report the scaled size — which
// under-measures the card and lets the clamp leave it hanging off the edge.
const popupWidth = popup.offsetWidth;
const popupHeight = popup.offsetHeight;
// Get current position in content coordinates
let currentLeft = parseFloat(popup.style.left) || 0;
let currentTop = parseFloat(popup.style.top) || 0;
// Cache reading area dimensions to avoid repeated property access
if (!readingArea._cachedContentDimensions || readingArea._contentDimensionCacheTime !== Date.now()) {
readingArea._cachedContentDimensions = {
scrollWidth: readingArea.scrollWidth,
scrollHeight: readingArea.scrollHeight,
clientWidth: readingArea.clientWidth,
clientHeight: readingArea.clientHeight
};
readingArea._contentDimensionCacheTime = Date.now();
}
const { scrollWidth: contentWidth, scrollHeight: contentHeight } = readingArea._cachedContentDimensions;
let needsAdjustment = false;
let newLeft = currentLeft;
let newTop = currentTop;
// Allow a small tolerance so we don't "snap" when already effectively within bounds
const snapTolerancePx = 8;
/*
* Clamp horizontally to what is ON SCREEN, not to the scrollable content.
* The old right-hand test compared against readingArea.scrollWidth, so whenever the content was wider
* than the view a card could sit well beyond the right edge and still pass — while the left test used
* 0, which happens to coincide with the visible left edge. That asymmetry is exactly why the card
* refused to leave the screen on the left but hung off it on the right.
*/
const viewLeft = readingArea.scrollLeft || 0;
const viewWidth = readingArea._cachedContentDimensions.clientWidth;
const minLeft = viewLeft + 8;
const maxLeft = Math.max(minLeft, viewLeft + viewWidth - popupWidth - 8);
if (currentLeft < minLeft - snapTolerancePx) {
newLeft = minLeft;
needsAdjustment = true;
} else if (currentLeft > maxLeft + snapTolerancePx) {
newLeft = maxLeft;
needsAdjustment = true;
}
// Clamp vertically within the full content height
if (currentTop < -snapTolerancePx) {
newTop = 0;
needsAdjustment = true;
} else if ((currentTop + popupHeight) > (contentHeight + snapTolerancePx)) {
newTop = Math.max(0, contentHeight - popupHeight);
needsAdjustment = true;
}
if (needsAdjustment) {
// Apply rounded values to avoid fractional pixel jitter
popup.style.left = `${Math.round(newLeft)}px`;
popup.style.top = `${Math.round(newTop)}px`;
// Invalidate cache after adjustment
popup._cachedDimensions = null;
}
}
// --- Slides Scripture Reference Popups ---
async function setupSlidesScripturePopups(tab) {
if (!tab || !tab.id) return;
const container = document.getElementById(`mediaText${tab.id}`);
if (!container) return;
// Wrap references in clickable spans
wrapScriptureReferences(container);
// Delegate click handler once
if (!container._scriptureClickHandler) {
const handler = async (e) => {
// Let nested interactive elements work first
if (e.defaultPrevented) return;
const target = e.target && (e.target.closest ? e.target.closest('.slide-scripture-ref') : null);
if (!target) return;
try { e.preventDefault(); e.stopPropagation(); } catch (_) {}
// quick visual tap feedback
try { target.style.outline = '2px solid #4a9eff'; setTimeout(() => { target.style.outline = ''; }, 180); } catch (_) {}
const book = target.getAttribute('data-book');
const chapter = parseInt(target.getAttribute('data-chapter') || '1', 10);
const verse = parseInt(target.getAttribute('data-verse') || '1', 10);
const verseEnd = parseInt(target.getAttribute('data-verseend') || String(verse), 10);
const ref = target.getAttribute('data-ref') || `${book} ${chapter}:${verse}${verseEnd && verseEnd !== verse ? '-' + verseEnd : ''}`;
let data;
try {
data = await getLocalVerseText(book, chapter, verse, verseEnd);
} catch (_) {
try { const text = await getESVVerseText(book, chapter, verse, verseEnd); data = { text, translation: 'ESV' }; } catch (_) {}
}
if (data && (data.text || typeof data === 'string')) {
showScripturePopup(ref, data.text || data, e, tab, data.translation);
} else {
try { showTemporaryToast(`Unable to load ${ref}`, tab); } catch (_) {}
}
};
container.addEventListener('click', handler);
container._scriptureClickHandler = handler;
}
// Touch tap support (clicks may be suppressed by Slides gesture handlers)
if (!container._scriptureTouchHandler) {
const touchHandler = async (e) => {
if (e.defaultPrevented) return;
const t = e.target && (e.target.closest ? e.target.closest('.slide-scripture-ref') : null);
if (!t) return;
try { e.preventDefault(); e.stopPropagation(); } catch (_) {}
// visual feedback
try { t.style.outline = '2px solid #4a9eff'; setTimeout(() => { t.style.outline = ''; }, 180); } catch (_) {}
const book = t.getAttribute('data-book');
const chapter = parseInt(t.getAttribute('data-chapter') || '1', 10);
const verse = parseInt(t.getAttribute('data-verse') || '1', 10);
const verseEnd = parseInt(t.getAttribute('data-verseend') || String(verse), 10);
const ref = t.getAttribute('data-ref') || `${book} ${chapter}:${verse}${verseEnd && verseEnd !== verse ? '-' + verseEnd : ''}`;
let data;
try {
data = await getLocalVerseText(book, chapter, verse, verseEnd);
} catch (_) {
try { const text = await getESVVerseText(book, chapter, verse, verseEnd); data = { text, translation: 'ESV' }; } catch (_) {}
}
// Synthesize an event-like object with the touch target
const synthetic = { target: t };
if (data && (data.text || typeof data === 'string')) {
showScripturePopup(ref, data.text || data, synthetic, tab, data.translation);
} else {
try { showTemporaryToast(`Unable to load ${ref}`, tab); } catch (_) {}
}
};
container.addEventListener('touchend', touchHandler, { passive: false });
container._scriptureTouchHandler = touchHandler;
}
}
function wrapScriptureReferences(container) {
try {
const raw = container.innerHTML;
const books = [...OLD_TESTAMENT_BOOKS, ...NEW_TESTAMENT_BOOKS]
.slice()
.sort((a, b) => b.length - a.length)
.map(b => b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const bookAlternation = books.join('|');
// Patterns like "1 John 3:16" or "Genesis 1:1-3"
const refRegex = new RegExp(`\\b((?:[1-3]\\s*)?(?:${bookAlternation}))\\s+(\\d{1,3}):(\\d{1,3})(?:[\u2013\-](\\d{1,3}))?\\b`, 'gi');
let matchCount = 0;
const replaced = raw.replace(refRegex, (m, book, chap, v1, v2) => {
matchCount++;
const ref = `${book} ${chap}:${v1}${v2 ? '-' + v2 : ''}`;
const attrs = `class=\"slide-scripture-ref\" data-ref=\"${ref}\" data-book=\"${book}\" data-chapter=\"${chap}\" data-verse=\"${v1}\" data-verseend=\"${v2 || v1}\"`;
// Ensure the reference is clickable above any overlays
return `
${ref}`;
});
if (replaced !== raw) container.innerHTML = replaced;
container._scriptureRefCount = matchCount;
} catch (_) {}
}
// Ensure scripture references in Bible reading content are tappable
function setupBibleScripturePopups(tab) {
// Disabled: No scripture popups on Bible tabs. Passages are already visible.
return;
}
async function getNKJVVerseText(book, chapter, verseStart, verseEnd) {
try {
if (!window.__NKJV_CACHE) {
const resp = await fetch('/NKJV_bible.json');
if (!resp.ok) throw new Error('Failed to load NKJV JSON');
window.__NKJV_CACHE = await resp.json();
}
const cache = window.__NKJV_CACHE || {};
const bookKey = resolveBookKey(book, cache);
const chapterObj = cache[bookKey] && cache[bookKey][String(chapter)];
if (!chapterObj) return 'Verse not found in NKJV.';
const start = Math.min(verseStart, verseEnd || verseStart);
const end = Math.max(verseStart, verseEnd || verseStart);
const parts = [];
for (let v = start; v <= end; v++) {
const t = chapterObj[String(v)];
if (t) parts.push(t);
}
return parts.length ? parts.join(' ') : 'Verse not found in NKJV.';
} catch (e) {
console.error('NKJV fetch error', e);
return 'Unable to load verse text.';
}
}
async function getESVVerseText(book, chapter, verseStart, verseEnd) {
try {
if (!window.__ESV_CACHE) {
const resp = await fetch('/ESV_bible.json');
if (!resp.ok) throw new Error('Failed to load ESV JSON');
window.__ESV_CACHE = await resp.json();
}
const cache = window.__ESV_CACHE || {};
const bookKey = resolveBookKey(book, cache);
const chapterObj = cache[bookKey] && cache[bookKey][String(chapter)];
if (!chapterObj) return 'Verse not found in ESV.';
const start = Math.min(verseStart, verseEnd || verseStart);
const end = Math.max(verseStart, verseEnd || verseStart);
const parts = [];
for (let v = start; v <= end; v++) {
const t = chapterObj[String(v)];
if (t) parts.push(t);
}
return parts.length ? parts.join(' ') : 'Verse not found in ESV.';
} catch (e) {
console.error('ESV fetch error', e);
return 'Unable to load verse text.';
}
}
function resolveBookKey(requested, cache) {
if (cache[requested]) return requested;
const variants = [
requested,
// Common capitalization variant
requested.replace(/\bof\b/i, 'Of'),
// Psalms/Psalm variant
requested === 'Psalms' ? 'Psalm' : (requested === 'Psalm' ? 'Psalms' : requested),
// Song of Solomon variants
requested.replace(/Song of Songs/i, 'Song of Solomon'),
requested.replace(/Song of Solomon/i, 'Song Of Solomon')
];
for (const v of variants) {
if (cache[v]) return v;
}
// Final attempt: case-insensitive scan
const lower = requested.toLowerCase();
for (const key in cache) {
if (key.toLowerCase() === lower) return key;
}
// Try Title Case fallback (handles GENESIS -> Genesis, JOHN -> John)
const titleCase = requested.toLowerCase().replace(/(^|\s)([a-z])/g, (_, p1, p2) => p1 + p2.toUpperCase());
if (cache[titleCase]) return titleCase;
return requested;
}
function showScripturePopup(reference, verseText, event, tab, translation) {
try {
const readingArea = document.getElementById(`readingArea${tab.id}`) || document.body;
const target = event.target;
const popup = document.createElement('div');
popup.className = 'word-info-popup';
const popupId = `scripture-popup-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
popup.id = popupId;
popup.innerHTML = `
${String(translation || (tab && tab.translation) || 'ESV').toUpperCase()}
${escapeHtml(verseText)}
`;
// Position near the clicked reference using content coordinates
const areaRect = readingArea.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const leftInContent = Math.max(0, (targetRect.left - areaRect.left) + readingArea.scrollLeft);
const topInContent = Math.max(0, (targetRect.bottom - areaRect.top) + readingArea.scrollTop + 6);
popup.style.position = 'absolute';
popup.style.left = `${leftInContent}px`;
popup.style.top = `${topInContent}px`;
popup.style.zIndex = 100000; // ensure always on top of slides UI
// Allow taps to pass through the popup body so nearby scripture refs remain tappable
// Keep header/close interactive for dragging/closing
popup.style.pointerEvents = 'none';
readingArea.appendChild(popup);
if (window.activeWordPopups) window.activeWordPopups.push(popup);
setTimeout(() => popup.classList.add('show'), 10);
const closeBtn = popup.querySelector('.word-info-close');
const headerEl = popup.querySelector('.word-info-header');
if (headerEl) headerEl.style.pointerEvents = 'auto';
if (closeBtn) closeBtn.style.pointerEvents = 'auto';
const onCloseScripture = (e) => {
if (window._popupDragging) return;
try { e.preventDefault(); e.stopPropagation(); } catch (_) {}
closeSpecificWordPopup ? closeSpecificWordPopup(popup) : popup.remove();
};
closeBtn.addEventListener('click', onCloseScripture);
// Touch tap support for closing in Slides mode where click may be suppressed
closeBtn.addEventListener('touchend', onCloseScripture, { passive: false });
makePopupDraggable && makePopupDraggable(popup, readingArea);
adjustPopupPosition && adjustPopupPosition(popup, readingArea);
// Fallback positioning: if popup renders off-screen or hidden, center it in viewport
setTimeout(() => {
try {
const rect = popup.getBoundingClientRect();
const vw = window.innerWidth, vh = window.innerHeight;
const offscreen = (!rect.width || !rect.height) || rect.right < 0 || rect.bottom < 0 || rect.left > vw || rect.top > vh;
if (offscreen) {
popup.style.position = 'fixed';
popup.style.left = '50%';
popup.style.top = '20%';
popup.style.transform = 'translate(-50%, 0)';
popup.style.zIndex = 100000;
}
} catch (_) {}
}, 30);
} catch (e) {
console.error('Failed to show scripture popup', e);
}
}
// Lightweight toast for fallback feedback
function showTemporaryToast(message, tab) {
try {
const readingArea = document.getElementById(`readingArea${tab.id}`) || document.body;
const toast = document.createElement('div');
toast.textContent = message;
toast.style.position = 'absolute';
toast.style.left = '50%';
toast.style.top = '10%';
toast.style.transform = 'translateX(-50%)';
toast.style.background = 'rgba(0,0,0,0.8)';
toast.style.border = '1px solid #4a9eff';
toast.style.borderRadius = '8px';
toast.style.padding = '10px 14px';
toast.style.color = '#e0e0e0';
toast.style.zIndex = 100001;
toast.style.pointerEvents = 'none';
toast.style.opacity = '0';
readingArea.appendChild(toast);
requestAnimationFrame(() => { toast.style.transition = 'opacity .2s'; toast.style.opacity = '1'; });
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 200);
}, 1200);
} catch (_) {}
}
// Global handler: make any scripture reference element open the popup (works app-wide)
function setupGlobalScriptureReferenceHandlers() {
if (window.__globalScriptureHandlersSetup) return;
window.__globalScriptureHandlersSetup = true;
const activateFromTarget = async (target, originalEvent) => {
try {
if (!target) return;
// Only handle our wrapped/badge elements
const refEl = target.closest && (target.closest('.slide-scripture-ref') || target.closest('.slide-scripture-badge'));
if (!refEl) return;
// Determine mode EARLY and do not interfere on Bible tabs
const activeTabObj = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId);
if (!activeTabObj || activeTabObj.mode !== 'media') {
return; // Do not block/take over clicks on Bible tabs
}
// Prevent duplicate handling if a container delegate already processed it
if (originalEvent && originalEvent.defaultPrevented) return;
try { originalEvent?.preventDefault(); originalEvent?.stopPropagation(); } catch (_) {}
const book = refEl.getAttribute('data-book');
const chapter = parseInt(refEl.getAttribute('data-chapter') || '1', 10);
const verse = parseInt(refEl.getAttribute('data-verse') || '1', 10);
const verseEnd = parseInt(refEl.getAttribute('data-verseend') || String(verse), 10);
const ref = refEl.getAttribute('data-ref') || `${book} ${chapter}:${verse}${verseEnd && verseEnd !== verse ? '-' + verseEnd : ''}`;
// Determine active tab for positioning/translation
const activeTabId = (window.bibleApp && window.bibleApp.activeTabId) || null;
const tab = activeTabObj || { id: activeTabId, translation: (window.bibleApp?.defaultTranslation || 'ESV') };
// Try local JSON first, then NKJV fallback
let data;
try {
if (typeof getLocalVerseText === 'function') {
data = await getLocalVerseText(book, chapter, verse, verseEnd);
}
} catch (_) {}
if (!data) {
try {
const text = await getNKJVVerseText(book, chapter, verse, verseEnd);
data = { text, translation: 'NKJV' };
} catch (_) {}
}
const syntheticEvent = { target: refEl };
if (data && (data.text || typeof data === 'string')) {
showScripturePopup(ref, data.text || data, syntheticEvent, tab, data.translation);
} else {
try { showTemporaryToast(`Unable to load ${ref}`, tab); } catch (_) {}
}
} catch (e) { console.warn('Global scripture handler error', e); }
};
// Click support (desktop)
document.addEventListener('click', (e) => {
// Ignore if already handled or clicking on interactive controls
if (e.defaultPrevented) return;
const t = e.target;
activateFromTarget(t, e);
}, { capture: true });
// Touch support (mobile)
document.addEventListener('touchend', (e) => {
if (e.defaultPrevented) return;
const t = e.target;
activateFromTarget(t, e);
}, { capture: true, passive: false });
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Add scripture badges over PDF slides (non-destructive overlay)
// Removed test badge overlay injection for scripture popups on slides
// Create invisible overlays precisely matching the scripture text bounds on a rendered PDF page
// items: pdfjs textContent.items, scale: viewport scale used for rendering, dpr: devicePixelRatio used for canvas
// canvas offsets/sizes are CSS pixel values where the canvas is placed within holder
function addPreciseScriptureOverlaysFromItems(items, scale, dpr, canvasLeft, canvasTop, canvasCssWidth, canvasCssHeight, holder, tab) {
try {
if (!Array.isArray(items) || !holder || !tab) return;
const books = [...OLD_TESTAMENT_BOOKS, ...NEW_TESTAMENT_BOOKS]
.slice()
.sort((a, b) => b.length - a.length)
.map(b => b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const bookAlternation = books.join('|');
const refRegex = new RegExp(`\\b((?:[1-3]\\s*)?(?:${bookAlternation}))\\s+(\\d{1,3})\\s*:\\s*(\\d{1,3})(?:[\\u2013\\-]\\s*(\\d{1,3}))?\\b`, 'i');
// Helper to compute bbox for an individual item in CSS pixels
const bboxForItem = (it) => {
const t = it.transform || [1,0,0,1,0,0];
const x = (t[4] || 0) * scale / dpr;
const y = (t[5] || 0) * scale / dpr;
const w = ((it.width != null ? it.width : Math.abs(t[0]) * String(it.str||'').length) || 0) * scale / dpr;
const h = Math.abs(t[3] || 0) * scale / dpr;
const left = canvasLeft + x;
const top = canvasTop + (canvasCssHeight - (y + h));
return { left, top, width: Math.max(0, w), height: Math.max(0, h) };
};
const clamp01 = (v) => Math.max(0, Math.min(1, v));
const MAX_WINDOW = 16; // expand window to catch longer refs on one line
const globalSeen = new Set();
// PASS A: Global string scan across all PDF items to catch any split patterns
try {
const sep = '\u0001';
const textSegments = items.map(it => String(it?.str || ''));
const indexStarts = new Array(textSegments.length);
let acc = 0;
for (let idx = 0; idx < textSegments.length; idx++) {
indexStarts[idx] = acc;
acc += textSegments[idx].length + 1; // include separator
}
const big = textSegments.join(sep);
const refRegexGlobal = new RegExp(refRegex.source, 'gi');
let match;
while ((match = refRegexGlobal.exec(big)) !== null) {
const matchStart = match.index;
const matchEnd = matchStart + match[0].length;
const book = match[1];
const chap = parseInt(match[2] || '1', 10);
const v1 = parseInt(match[3] || '1', 10);
const v2 = match[4] ? parseInt(match[4], 10) : v1;
const ref = `${book} ${chap}:${v1}${v2 && v2 !== v1 ? '-' + v2 : ''}`;
// find first segment index covering matchStart
let segStartIdx = 0;
while (segStartIdx < indexStarts.length - 1 && indexStarts[segStartIdx + 1] <= matchStart) segStartIdx++;
// accumulate until matchEnd
const contributing = [];
for (let s = segStartIdx; s < textSegments.length; s++) {
const segStart = indexStarts[s];
const segEnd = segStart + textSegments[s].length;
if (segStart >= matchEnd) break;
if (segEnd <= matchStart) continue;
contributing.push({ idx: s, start: segStart, end: segEnd });
if (segEnd >= matchEnd) break;
}
if (contributing.length === 0) continue;
contributing.forEach((part, idx) => {
const it = items[part.idx];
const b = bboxForItem(it);
if (!isFinite(b.left) || !isFinite(b.top)) return;
const span = Math.max(1, part.end - part.start);
const localStart = clamp01((matchStart - part.start) / span);
const localEnd = clamp01((matchEnd - part.start) / span);
const isFirst = idx === 0;
const isLast = idx === contributing.length - 1;
const fracStart = isFirst ? localStart : 0;
const fracEnd = isLast ? localEnd : 1;
let left = b.left + b.width * fracStart;
let width = b.width * Math.max(0, fracEnd - fracStart);
let top = b.top;
let height = b.height || 0;
const MIN_TOUCH_WIDTH = 6;
if (width < MIN_TOUCH_WIDTH) {
const pad = (MIN_TOUCH_WIDTH - width) / 2;
left = Math.max(0, left - pad);
width = MIN_TOUCH_WIDTH;
}
const H_PAD = 3, V_PAD = 2;
left = Math.max(0, left - H_PAD);
top = Math.max(0, top - V_PAD);
width += H_PAD * 2;
height += V_PAD * 2;
if (canvasCssWidth != null) {
const maxRight = canvasLeft + canvasCssWidth;
const right = Math.min(maxRight, left + width);
left = Math.max(canvasLeft, left);
width = Math.max(0, right - left);
}
if (canvasCssHeight != null) {
const maxBottom = canvasTop + canvasCssHeight;
const bottom = Math.min(maxBottom, top + height);
top = Math.max(canvasTop, top);
height = Math.max(0, bottom - top);
}
const key = `${ref}:${Math.round(left)}:${Math.round(top)}:${Math.round(width)}:${Math.round(height)}`;
if (width > 0 && height > 0 && !globalSeen.has(key)) {
globalSeen.add(key);
const overlay = document.createElement('div');
overlay.className = 'slide-scripture-ref precise-overlay';
overlay.setAttribute('data-ref', ref);
overlay.setAttribute('data-book', book);
overlay.setAttribute('data-chapter', String(chap));
overlay.setAttribute('data-verse', String(v1));
overlay.setAttribute('data-verseend', String(v2));
overlay.style.position = 'absolute';
overlay.style.left = `${Math.max(0, Math.floor(left))}px`;
overlay.style.top = `${Math.max(0, Math.floor(top))}px`;
overlay.style.width = `${Math.max(0, Math.ceil(width))}px`;
overlay.style.height = `${Math.max(0, Math.ceil(height))}px`;
overlay.style.cursor = 'pointer';
overlay.style.background = (window.__DEBUG_REF_OVERLAYS ? 'rgba(0,200,0,0.15)' : 'transparent');
overlay.style.zIndex = '6001';
const onActivate = async (evt) => {
try { evt.preventDefault(); evt.stopPropagation(); } catch (_) {}
let data;
try { data = await getLocalVerseText(book, chap, v1, v2); }
catch(_) { try { const text = await getNKJVVerseText(book, chap, v1, v2); data = { text, translation: 'NKJV' }; } catch(_) {} }
const synthetic = { target: overlay };
if (data && (data.text || typeof data === 'string')) {
showScripturePopup(ref, data.text || data, synthetic, tab, data.translation);
} else {
try { showTemporaryToast(`Unable to load ${ref}`, tab); } catch(_) {}
}
};
overlay.addEventListener('click', onActivate);
overlay.addEventListener('touchend', onActivate, { passive: false });
holder.appendChild(overlay);
}
});
}
} catch (_) {}
for (let i = 0; i < items.length; i++) {
let combined = '';
const parts = []; // mapping of combined indices to items
for (let j = 0; j < MAX_WINDOW && (i + j) < items.length; j++) {
const raw = String(items[i + j]?.str || '');
const text = raw.trim();
if (!text) continue;
if (combined.length > 0) combined += ' ';
const start = combined.length;
combined += text;
const end = combined.length;
parts.push({ item: items[i + j], start, end, text });
const matchIndex = combined.search(refRegex);
if (matchIndex !== -1) {
const m = combined.match(refRegex);
if (!m) break; // safety
const book = m[1];
const chap = parseInt(m[2] || '1', 10);
const v1 = parseInt(m[3] || '1', 10);
const v2 = m[4] ? parseInt(m[4], 10) : v1;
const ref = `${book} ${chap}:${v1}${v2 && v2 !== v1 ? '-' + v2 : ''}`;
const matchStart = matchIndex;
const matchEnd = matchStart + m[0].length;
// Determine which parts contribute to the match
const contributing = parts.filter(p => p.end > matchStart && p.start < matchEnd);
if (contributing.length === 0) break;
contributing.forEach((p, idx) => {
const b = bboxForItem(p.item);
if (!isFinite(b.left) || !isFinite(b.top)) return;
// Compute fractional coverage within this item for tighter hitboxes
const span = Math.max(1, p.end - p.start);
const localStart = clamp01((matchStart - p.start) / span);
const localEnd = clamp01((matchEnd - p.start) / span);
const isFirst = idx === 0;
const isLast = idx === contributing.length - 1;
const fracStart = isFirst ? localStart : 0;
const fracEnd = isLast ? localEnd : 1;
let left = b.left + b.width * fracStart;
let width = b.width * Math.max(0, fracEnd - fracStart);
let top = b.top;
let height = b.height || 0;
// Ensure a minimal touchable width but keep it tight
const MIN_TOUCH_WIDTH = 6; // px
if (width < MIN_TOUCH_WIDTH) {
const pad = (MIN_TOUCH_WIDTH - width) / 2;
left = Math.max(0, left - pad);
width = MIN_TOUCH_WIDTH;
}
// Gentle padding for touch TVs without overlapping neighbors too much
const H_PAD = 3; // px
const V_PAD = 2; // px
left = Math.max(0, left - H_PAD);
top = Math.max(0, top - V_PAD);
width = width + H_PAD * 2;
height = height + V_PAD * 2;
// Clamp within canvas CSS bounds
if (canvasCssWidth != null) {
const maxRight = canvasLeft + canvasCssWidth;
const right = Math.min(maxRight, left + width);
left = Math.max(canvasLeft, left);
width = Math.max(0, right - left);
}
if (canvasCssHeight != null) {
const maxBottom = canvasTop + canvasCssHeight;
const bottom = Math.min(maxBottom, top + height);
top = Math.max(canvasTop, top);
height = Math.max(0, bottom - top);
}
const overlay = document.createElement('div');
overlay.className = 'slide-scripture-ref precise-overlay';
overlay.setAttribute('data-ref', ref);
overlay.setAttribute('data-book', book);
overlay.setAttribute('data-chapter', String(chap));
overlay.setAttribute('data-verse', String(v1));
overlay.setAttribute('data-verseend', String(v2));
overlay.style.position = 'absolute';
overlay.style.left = `${Math.max(0, Math.floor(left))}px`;
overlay.style.top = `${Math.max(0, Math.floor(top))}px`;
overlay.style.width = `${Math.max(0, Math.ceil(width))}px`;
overlay.style.height = `${Math.max(0, Math.ceil(height))}px`;
overlay.style.cursor = 'pointer';
overlay.style.background = (window.__DEBUG_REF_OVERLAYS ? 'rgba(255,0,0,0.15)' : 'transparent');
overlay.style.zIndex = '6001';
const onActivate = async (evt) => {
try { evt.preventDefault(); evt.stopPropagation(); } catch (_) {}
let data;
try {
data = await getLocalVerseText(book, chap, v1, v2);
} catch (_) {
try { const text = await getNKJVVerseText(book, chap, v1, v2); data = { text, translation: 'NKJV' }; } catch (_) {}
}
const synthetic = { target: overlay };
if (data && (data.text || typeof data === 'string')) {
showScripturePopup(ref, data.text || data, synthetic, tab, data.translation);
} else {
try { showTemporaryToast(`Unable to load ${ref}`, tab); } catch (_) {}
}
};
overlay.addEventListener('click', onActivate);
overlay.addEventListener('touchend', onActivate, { passive: false });
holder.appendChild(overlay);
});
// Advance i to skip items we just consumed to reduce overlap
const lastPart = contributing[contributing.length - 1];
const consumedIdx = parts.indexOf(lastPart);
if (consumedIdx >= 0) {
i = i + consumedIdx; // outer loop will i++ next
}
break; // move start to next i after handling this match
}
}
}
// Fallback pass: detect simple [Book] + [3:16(-18)] pairs across adjacent items
try {
const seen = new Set();
const norm = (s) => String(s || '').trim().toLowerCase();
const isBook = (s) => {
if (!s) return false;
// normalize multiple spaces
const n = s.replace(/\s+/g, ' ').trim();
return books.some(b => b.toLowerCase() === n);
};
const verseRe = /^(\d{1,3})\s*:\s*(\d{1,3})(?:[\u2013\-]\s*(\d{1,3}))?$/;
for (let k = 1; k < items.length; k++) {
const cur = norm(items[k]?.str);
const prev = norm(items[k-1]?.str);
// handle split like "1" + "john"
const twoBack = k >= 2 ? norm(items[k-2]?.str) : '';
let bookText = null;
if (isBook(prev)) {
bookText = prev;
} else if ((twoBack === '1' || twoBack === '2' || twoBack === '3') && isBook(('' + prev).replace(/^\d\s*/, '') ? prev : ('1 ' + prev))) {
// Normalize patterns like ["1", "john"] ? "1 john"
bookText = `${twoBack} ${prev}`.trim();
}
// Also handle when book and chapter are in the same item (e.g., "john" and next item is "3:14")
const vm = cur.match(verseRe);
if (bookText && vm) {
const chap = parseInt(vm[1] || '1', 10);
const v1 = parseInt(vm[2] || '1', 10);
const v2 = vm[3] ? parseInt(vm[3], 10) : v1;
const ref = `${bookText.replace(/\b\w/g, c => c.toUpperCase())} ${chap}:${v1}${v2 && v2 !== v1 ? '-' + v2 : ''}`;
// bbox union of contributing items
const b1 = bboxForItem(items[k-1]);
const b2 = bboxForItem(items[k]);
const minL = Math.min(b1.left, b2.left);
const minT = Math.min(b1.top, b2.top);
const maxR = Math.max(b1.left + b1.width, b2.left + b2.width);
const maxB = Math.max(b1.top + b1.height, b2.top + b2.height);
let left = Math.floor(minL) - 3;
let top = Math.floor(minT) - 2;
let width = Math.ceil(maxR - minL) + 6;
let height = Math.ceil(maxB - minT) + 4;
// Clamp within canvas bounds
if (canvasCssWidth != null) {
const maxRight = canvasLeft + canvasCssWidth;
const right = Math.min(maxRight, left + width);
left = Math.max(canvasLeft, left);
width = Math.max(0, right - left);
}
if (canvasCssHeight != null) {
const maxBottom = canvasTop + canvasCssHeight;
const bottom = Math.min(maxBottom, top + height);
top = Math.max(canvasTop, top);
height = Math.max(0, bottom - top);
}
const key = `${ref}:${Math.round(left)}:${Math.round(top)}`;
if (width > 0 && height > 0 && !seen.has(key)) {
seen.add(key);
const overlay = document.createElement('div');
overlay.className = 'slide-scripture-ref precise-overlay';
// Split book back into canonical parts for data-* attributes
const bookParts = bookText.split(/\s+/);
const bookCanonical = bookParts.map((w, idx) => idx === 0 && /^(1|2|3)$/.test(w) ? w : (w.charAt(0).toUpperCase() + w.slice(1))).join(' ');
overlay.setAttribute('data-ref', ref);
overlay.setAttribute('data-book', bookCanonical);
overlay.setAttribute('data-chapter', String(chap));
overlay.setAttribute('data-verse', String(v1));
overlay.setAttribute('data-verseend', String(v2));
overlay.style.position = 'absolute';
overlay.style.left = `${left}px`;
overlay.style.top = `${top}px`;
overlay.style.width = `${width}px`;
overlay.style.height = `${height}px`;
overlay.style.cursor = 'pointer';
overlay.style.background = (window.__DEBUG_REF_OVERLAYS ? 'rgba(0,128,255,0.15)' : 'transparent');
overlay.style.zIndex = '6001';
const onActivate = async (evt) => {
try { evt.preventDefault(); evt.stopPropagation(); } catch (_) {}
let data;
try {
data = await getLocalVerseText(bookCanonical, chap, v1, v2);
} catch (_) {
try { const text = await getNKJVVerseText(bookCanonical, chap, v1, v2); data = { text, translation: 'NKJV' }; } catch (_) {}
}
const synthetic = { target: overlay };
if (data && (data.text || typeof data === 'string')) {
showScripturePopup(ref, data.text || data, synthetic, tab, data.translation);
} else {
try { showTemporaryToast(`Unable to load ${ref}`, tab); } catch (_) {}
}
};
overlay.addEventListener('click', onActivate);
overlay.addEventListener('touchend', onActivate, { passive: false });
holder.appendChild(overlay);
}
}
}
} catch (_) {}
} catch (e) {
console.warn('addPreciseScriptureOverlaysFromItems error', e);
}
}
// Add word click listeners to Bible text
function addWordClickListeners(tabId) {
const bibleText = document.getElementById(`bibleText${tabId}`);
if (!bibleText) {
console.log('?? WORD CLICK - Bible text element not found for tab:', tabId);
return;
}
// Always ensure words are clickable first
makeWordsClickableInTab(tabId);
// Setup click listeners for all clickable words
setupClickListeners(bibleText, tabId);
setupReadingAreaWordDoubleTap(tabId);
// Opportunistically underline more words that have info (NT via WordNet)
try { enhanceWordHintsWithWordNet(tabId); } catch (_) {}
console.log('?? WORD CLICK - Word double-tap listeners added for tab:', tabId);
console.log('?? WORD CLICK - To test Accordance integration, run: testAccordanceIntegration("love")');
}
/*
* How far apart the two taps of a double-tap may land.
*
* A flat 14px was wrong on this screen: at the stage TV's text size a long word like
* "commendation" is over 400px wide (measured), so two honest taps anywhere along it are easily
* 30-80px apart and were rejected — the popup simply never opened. Short words ("by" at 64px)
* happen to force the taps together, which is why only SOME words appeared broken.
*
* Both taps landing on the same word is the thing that actually matters, so the allowance is that
* word's own size (plus a small margin for finger drift past the edge). It stays bounded, so taps
* on two DIFFERENT words still never count as a double-tap.
*/
function wordDoubleTapAllowance(el, fallbackPx) {
const base = fallbackPx || 14;
try {
const r = el && el.getBoundingClientRect ? el.getBoundingClientRect() : null;
if (!r || !r.width || !r.height) return base;
// FULL diagonal: two taps at opposite ends of the same word must still count as a
// double-tap on it. Half the diagonal was not enough — a 282px-wide word rejected taps
// 222px apart, which is exactly the kind of honest double-tap that was failing.
return Math.max(base, Math.hypot(r.width, r.height) + 16);
} catch (_) { return base; }
}
function isWordPopupDoubleTapEnabled() {
// Word popup cards (double-tap/double-click a word). Toggleable in Settings > Word Popups;
// ON by default — the verse-exact Hebrew/Greek card makes this the pastor's teaching tool.
try { return localStorage.getItem('wordPopupsEnabled') !== 'false'; } catch (_) { return true; }
}
// Helper function to setup click listeners on clickable words
function setupClickListeners(bibleText, tabId) {
const clickableWords = bibleText.querySelectorAll('.clickable-word');
const wordPopupEnabled = isWordPopupDoubleTapEnabled();
console.log('?? LISTENERS DEBUG - Found clickable words:', clickableWords.length);
clickableWords.forEach((wordSpan, index) => {
console.log(`?? LISTENERS DEBUG - Setting up word ${index}:`, wordSpan.textContent, 'has-data:', wordSpan.classList.contains('has-data'));
// Remove existing listeners to prevent duplicates
wordSpan.removeEventListener('click', wordSpan._clickHandler);
wordSpan.removeEventListener('dblclick', wordSpan._dblClickHandler);
wordSpan.removeEventListener('pointerdown', wordSpan._pointerDownHandler);
wordSpan.removeEventListener('pointermove', wordSpan._pointerMoveHandler);
wordSpan.removeEventListener('pointerup', wordSpan._pointerUpHandler);
wordSpan.removeEventListener('touchstart', wordSpan._touchStartHandler);
wordSpan.removeEventListener('touchmove', wordSpan._touchMoveHandler);
wordSpan.removeEventListener('touchend', wordSpan._touchEndHandler);
if (!wordPopupEnabled) {
// Keep words styled/underlined by existing logic, but disable popup gesture affordance.
if (wordSpan.classList.contains('has-data')) {
wordSpan.style.cursor = 'default';
wordSpan.title = '';
}
return;
}
// Clear any existing double-tap timer
if (wordSpan._touchTapState && wordSpan._touchTapState.tapTimeout) {
clearTimeout(wordSpan._touchTapState.tapTimeout);
wordSpan._touchTapState.tapTimeout = null;
}
if (wordSpan._pointerTapState && wordSpan._pointerTapState.tapTimeout) {
clearTimeout(wordSpan._pointerTapState.tapTimeout);
wordSpan._pointerTapState.tapTimeout = null;
}
const DOUBLE_TAP_DELAY = 300;
const DOUBLE_TAP_DISTANCE = 14;
// Touch tap state for this word
let touchTapState = {
lastTapTime: 0,
lastTapX: 0,
lastTapY: 0,
startX: 0,
startY: 0,
moved: false,
ignore: false,
tapTimeout: null
};
// Pointer tap state (for touch/pen pointer events)
let pointerTapState = {
lastTapTime: 0,
lastTapX: 0,
lastTapY: 0,
startX: 0,
startY: 0,
moved: false,
ignore: false,
tapTimeout: null
};
// MOUSE DOUBLE-CLICK HANDLER (Desktop)
wordSpan._dblClickHandler = (event) => {
console.log('?? LISTENERS DEBUG - Double-click event fired on:', wordSpan.textContent);
/*
* A real MOUSE double-click must not be blocked just because the glass was touched a
* moment ago. suppressMouseUntil is a 2s lockout set by every touch, which on a
* touchscreen TV meant that alternating between finger and mouse silently killed
* double-click. The synthetic mouse events that follow a real tap arrive within a few
* hundred ms, and the 700ms __lastWordTouchTs guard below already rejects those — this
* only needs to cover that same short window.
*/
if (window.suppressMouseUntil && Date.now() < window.suppressMouseUntil &&
window.__lastWordTouchTs && Date.now() - window.__lastWordTouchTs < 700) {
console.log('?? Double-click suppressed due to a touch moments ago');
return;
}
if (window.__lastWordTouchTs && Date.now() - window.__lastWordTouchTs < 700) {
console.log('?? Double-click suppressed due to recent touch');
return;
}
if (window.__lastWordPopupTs && Date.now() - window.__lastWordPopupTs < 400) {
console.log('?? Double-click suppressed due to recent popup');
return;
}
if (typeof isWordInfoSuppressed === 'function' && isWordInfoSuppressed()) {
console.log('?? Double-click suppressed due to multi-touch/scroll grace period');
return;
}
if (window.globalMultiTouchActive) {
console.log('?? Double-click suppressed because multi-touch is active');
return;
}
const word = wordSpan.getAttribute('data-word') || wordSpan.textContent;
handleWordClick(word, event);
};
// TOUCH START HANDLER (Mobile) - Begin double-tap tracking
wordSpan._touchStartHandler = (event) => {
console.log('?? TOUCH DEBUG - Touch start on:', wordSpan.textContent);
window.__lastWordTouchTs = Date.now();
window.suppressMouseUntil = Date.now() + 2000;
if ((typeof isWordInfoSuppressed === 'function' && isWordInfoSuppressed()) ||
(event.touches && event.touches.length >= 2) ||
window.globalMultiTouchActive) {
console.log('?? Touch suppressed at start');
touchTapState.ignore = true;
return;
}
const touch = event.touches[0];
touchTapState.startX = touch.clientX;
touchTapState.startY = touch.clientY;
touchTapState.moved = false;
touchTapState.ignore = false;
};
// TOUCH MOVE HANDLER (Mobile) - Detect scrolling
wordSpan._touchMoveHandler = (event) => {
if (touchTapState.ignore || globalMultiTouchActive) {
return;
}
const touch = event.touches[0];
const deltaX = Math.abs(touch.clientX - touchTapState.startX);
const deltaY = Math.abs(touch.clientY - touchTapState.startY);
if (deltaX > 10 || deltaY > 10) {
touchTapState.moved = true;
}
};
// TOUCH END HANDLER (Mobile) - Detect double tap
wordSpan._touchEndHandler = (event) => {
console.log('?? TOUCH DEBUG - Touch end on:', wordSpan.textContent);
if (touchTapState.ignore || touchTapState.moved) {
touchTapState.ignore = false;
touchTapState.moved = false;
return;
}
if (typeof isWordInfoSuppressed === 'function' && isWordInfoSuppressed()) {
console.log('?? Double-tap suppressed due to multi-touch/scroll grace period');
return;
}
if (window.globalMultiTouchActive) {
console.log('?? Double-tap suppressed because multi-touch is active');
return;
}
const touch = (event.changedTouches && event.changedTouches[0]) || (event.touches && event.touches[0]);
if (!touch) return;
const now = Date.now();
const timeDelta = now - touchTapState.lastTapTime;
const dist = Math.hypot(touch.clientX - touchTapState.lastTapX, touch.clientY - touchTapState.lastTapY);
if (touchTapState.lastTapTime && timeDelta <= DOUBLE_TAP_DELAY && dist <= wordDoubleTapAllowance(wordSpan, DOUBLE_TAP_DISTANCE)) {
if (touchTapState.tapTimeout) {
clearTimeout(touchTapState.tapTimeout);
touchTapState.tapTimeout = null;
}
touchTapState.lastTapTime = 0;
touchTapState.lastTapX = 0;
touchTapState.lastTapY = 0;
try { event.__wordPopupHandled = true; } catch (_) {}
const syntheticEvent = {
target: wordSpan,
clientX: touch.clientX,
clientY: touch.clientY,
preventDefault: () => {},
stopPropagation: () => {}
};
const word = wordSpan.getAttribute('data-word') || wordSpan.textContent;
handleWordClick(word, syntheticEvent);
window.__lastWordTouchTs = Date.now();
window.__lastWordPopupTs = Date.now();
window.suppressMouseUntil = Date.now() + 2000;
try { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); } catch (_) {}
} else {
touchTapState.lastTapTime = now;
touchTapState.lastTapX = touch.clientX;
touchTapState.lastTapY = touch.clientY;
if (touchTapState.tapTimeout) clearTimeout(touchTapState.tapTimeout);
touchTapState.tapTimeout = setTimeout(() => {
touchTapState.lastTapTime = 0;
touchTapState.tapTimeout = null;
}, DOUBLE_TAP_DELAY + 60);
}
touchTapState.moved = false;
touchTapState.ignore = false;
};
// POINTER DOWN HANDLER (Touch/Pen) - Begin double-tap tracking
wordSpan._pointerDownHandler = (event) => {
if (!event || (event.pointerType !== 'touch' && event.pointerType !== 'pen')) return;
if ((typeof isWordInfoSuppressed === 'function' && isWordInfoSuppressed()) ||
window.globalMultiTouchActive) {
pointerTapState.ignore = true;
return;
}
pointerTapState.startX = event.clientX;
pointerTapState.startY = event.clientY;
pointerTapState.moved = false;
pointerTapState.ignore = false;
};
// POINTER MOVE HANDLER (Touch/Pen) - Detect scrolling
wordSpan._pointerMoveHandler = (event) => {
if (!event || (event.pointerType !== 'touch' && event.pointerType !== 'pen')) return;
if (pointerTapState.ignore || globalMultiTouchActive) return;
const deltaX = Math.abs(event.clientX - pointerTapState.startX);
const deltaY = Math.abs(event.clientY - pointerTapState.startY);
if (deltaX > 10 || deltaY > 10) {
pointerTapState.moved = true;
}
};
// POINTER UP HANDLER (Touch/Pen) - Detect double tap
wordSpan._pointerUpHandler = (event) => {
if (!event || (event.pointerType !== 'touch' && event.pointerType !== 'pen')) return;
if (pointerTapState.ignore || pointerTapState.moved) {
pointerTapState.ignore = false;
pointerTapState.moved = false;
return;
}
if (typeof isWordInfoSuppressed === 'function' && isWordInfoSuppressed()) {
return;
}
if (window.globalMultiTouchActive) {
return;
}
const now = Date.now();
const timeDelta = now - pointerTapState.lastTapTime;
const dist = Math.hypot(event.clientX - pointerTapState.lastTapX, event.clientY - pointerTapState.lastTapY);
if (pointerTapState.lastTapTime && timeDelta <= DOUBLE_TAP_DELAY && dist <= wordDoubleTapAllowance(wordSpan, DOUBLE_TAP_DISTANCE)) {
if (pointerTapState.tapTimeout) {
clearTimeout(pointerTapState.tapTimeout);
pointerTapState.tapTimeout = null;
}
pointerTapState.lastTapTime = 0;
pointerTapState.lastTapX = 0;
pointerTapState.lastTapY = 0;
try { event.__wordPopupHandled = true; } catch (_) {}
const syntheticEvent = {
target: wordSpan,
clientX: event.clientX,
clientY: event.clientY,
preventDefault: () => {},
stopPropagation: () => {}
};
const word = wordSpan.getAttribute('data-word') || wordSpan.textContent;
handleWordClick(word, syntheticEvent);
window.__lastWordTouchTs = Date.now();
window.__lastWordPopupTs = Date.now();
window.suppressMouseUntil = Date.now() + 2000;
try { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); } catch (_) {}
} else {
pointerTapState.lastTapTime = now;
pointerTapState.lastTapX = event.clientX;
pointerTapState.lastTapY = event.clientY;
if (pointerTapState.tapTimeout) clearTimeout(pointerTapState.tapTimeout);
pointerTapState.tapTimeout = setTimeout(() => {
pointerTapState.lastTapTime = 0;
pointerTapState.tapTimeout = null;
}, DOUBLE_TAP_DELAY + 60);
}
pointerTapState.moved = false;
pointerTapState.ignore = false;
};
// Add event listeners
wordSpan.addEventListener('dblclick', wordSpan._dblClickHandler);
wordSpan.addEventListener('touchstart', wordSpan._touchStartHandler, { passive: false });
wordSpan.addEventListener('touchmove', wordSpan._touchMoveHandler, { passive: true });
wordSpan.addEventListener('touchend', wordSpan._touchEndHandler, { passive: false });
wordSpan.addEventListener('pointerdown', wordSpan._pointerDownHandler, { passive: false });
wordSpan.addEventListener('pointermove', wordSpan._pointerMoveHandler, { passive: true });
wordSpan.addEventListener('pointerup', wordSpan._pointerUpHandler, { passive: false });
// Store touch state for cleanup
wordSpan._touchTapState = touchTapState;
wordSpan._pointerTapState = pointerTapState;
// Style clickable words with data
if (wordSpan.classList.contains('has-data')) {
wordSpan.style.cursor = 'pointer';
// Different instructions for touch vs mouse devices
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
wordSpan.title = isTouchDevice
? 'Double-tap to see Greek/Hebrew translation'
: 'Double-click to see Greek/Hebrew translation';
console.log('?? LISTENERS DEBUG - Styled word with data:', wordSpan.textContent);
}
});
console.log('?? LISTENERS DEBUG - Word click listeners added for tab:', tabId, 'Total words:', clickableWords.length);
}
// Fallback: handle double-tap on the reading area when canvases intercept touch
function setupReadingAreaWordDoubleTap(tabId) {
if (!isWordPopupDoubleTapEnabled()) return;
const readingArea = document.getElementById(`readingArea${tabId}`);
if (!readingArea || readingArea._wordDoubleTapDelegate) return;
readingArea._wordDoubleTapDelegate = true;
const DOUBLE_TAP_DELAY = 300;
const DOUBLE_TAP_DISTANCE = 14;
let lastTapTime = 0;
let lastTapX = 0;
let lastTapY = 0;
let lastTapWord = null; // word the first tap landed on — sets how far the second may drift
let tapTimeout = null;
let startX = 0;
let startY = 0;
let moved = false;
let ignore = false;
const findWordAtPoint = (x, y) => {
try {
const overlays = readingArea.querySelectorAll('.annotation-canvas, .annotation-overlay-canvas, .media-drawing-canvas');
const previous = [];
overlays.forEach(el => {
previous.push([el, el.style.pointerEvents]);
el.style.pointerEvents = 'none';
});
const el = document.elementFromPoint(x, y);
previous.forEach(([node, pe]) => { node.style.pointerEvents = pe || ''; });
return el && el.closest ? el.closest('.clickable-word') : null;
} catch (_) {
return null;
}
};
const triggerWordPopup = (x, y, originalEvent) => {
if (window.__lastWordPopupTs && Date.now() - window.__lastWordPopupTs < 350) {
return;
}
const wordSpan = findWordAtPoint(x, y);
if (!wordSpan) return;
const word = wordSpan.getAttribute('data-word') || wordSpan.textContent;
const syntheticEvent = {
target: wordSpan,
clientX: x,
clientY: y,
preventDefault: () => {},
stopPropagation: () => {}
};
handleWordClick(word, syntheticEvent);
try { originalEvent.preventDefault(); originalEvent.stopPropagation(); } catch (_) {}
};
const onDblClick = (event) => {
if (event && event.__wordPopupHandled) return;
if (window.__lastWordTouchTs && Date.now() - window.__lastWordTouchTs < 700) return;
if (window.__lastWordPopupTs && Date.now() - window.__lastWordPopupTs < 350) return;
// Same reasoning as the per-word handler: the 700ms touch guard above is what stops a real
// double-TAP double-firing; the 2s lockout only blocked genuine mouse use.
if (event.target && event.target.closest && event.target.closest('.clickable-word')) return;
if (typeof isWordInfoSuppressed === 'function' && isWordInfoSuppressed()) return;
if (window.globalMultiTouchActive) return;
triggerWordPopup(event.clientX, event.clientY, event);
};
const onTouchStart = (event) => {
if (event && event.__wordPopupHandled) return;
window.__lastWordTouchTs = Date.now();
window.suppressMouseUntil = Date.now() + 2000;
if (event.target && event.target.closest && event.target.closest('.clickable-word')) return;
if ((typeof isWordInfoSuppressed === 'function' && isWordInfoSuppressed()) ||
(event.touches && event.touches.length >= 2) ||
window.globalMultiTouchActive) {
ignore = true;
return;
}
const touch = event.touches[0];
startX = touch.clientX;
startY = touch.clientY;
moved = false;
ignore = false;
};
const onTouchMove = (event) => {
if (ignore || window.globalMultiTouchActive) return;
const touch = event.touches[0];
const deltaX = Math.abs(touch.clientX - startX);
const deltaY = Math.abs(touch.clientY - startY);
if (deltaX > 10 || deltaY > 10) moved = true;
};
const onTouchEnd = (event) => {
if (event && event.__wordPopupHandled) return;
if (window.__lastWordPopupTs && Date.now() - window.__lastWordPopupTs < 350) return;
if (event.target && event.target.closest && event.target.closest('.clickable-word')) return;
if (ignore || moved) {
ignore = false;
moved = false;
return;
}
if (typeof isWordInfoSuppressed === 'function' && isWordInfoSuppressed()) return;
if (window.globalMultiTouchActive) return;
const touch = (event.changedTouches && event.changedTouches[0]) || (event.touches && event.touches[0]);
if (!touch) return;
const now = Date.now();
const timeDelta = now - lastTapTime;
const dist = Math.hypot(touch.clientX - lastTapX, touch.clientY - lastTapY);
// Same word = a double-tap however far apart the taps landed on it (a long word is wide);
// different words never count, no matter how close. Distance only backs this up when the
// word under a tap could not be identified.
const wordNow = findWordAtPoint(touch.clientX, touch.clientY);
const sameWord = !!(wordNow && lastTapWord && wordNow === lastTapWord);
const nearEnough = sameWord || (!wordNow && !lastTapWord && dist <= DOUBLE_TAP_DISTANCE);
if (lastTapTime && timeDelta <= DOUBLE_TAP_DELAY && nearEnough) {
if (tapTimeout) {
clearTimeout(tapTimeout);
tapTimeout = null;
}
lastTapTime = 0;
lastTapX = 0;
lastTapY = 0;
lastTapWord = null;
triggerWordPopup(touch.clientX, touch.clientY, event);
} else {
lastTapTime = now;
lastTapX = touch.clientX;
lastTapY = touch.clientY;
lastTapWord = wordNow;
if (tapTimeout) clearTimeout(tapTimeout);
tapTimeout = setTimeout(() => {
lastTapTime = 0;
tapTimeout = null;
}, DOUBLE_TAP_DELAY + 60);
}
moved = false;
ignore = false;
};
readingArea.addEventListener('dblclick', onDblClick);
readingArea.addEventListener('touchstart', onTouchStart, { passive: false });
readingArea.addEventListener('touchmove', onTouchMove, { passive: true });
readingArea.addEventListener('touchend', onTouchEnd, { passive: false });
// Pointer-event fallback for touch/pen devices
let pointerTapState = {
lastTapTime: 0,
lastTapX: 0,
lastTapY: 0,
startX: 0,
startY: 0,
moved: false,
ignore: false,
tapTimeout: null
};
const onPointerDown = (event) => {
if (!event || (event.pointerType !== 'touch' && event.pointerType !== 'pen')) return;
if (event && event.__wordPopupHandled) return;
if (event.target && event.target.closest && event.target.closest('.clickable-word')) return;
window.__lastWordTouchTs = Date.now();
window.suppressMouseUntil = Date.now() + 2000;
if (typeof isWordInfoSuppressed === 'function' && isWordInfoSuppressed()) {
pointerTapState.ignore = true;
return;
}
pointerTapState.startX = event.clientX;
pointerTapState.startY = event.clientY;
pointerTapState.moved = false;
pointerTapState.ignore = false;
};
const onPointerMove = (event) => {
if (!event || (event.pointerType !== 'touch' && event.pointerType !== 'pen')) return;
if (pointerTapState.ignore) return;
const deltaX = Math.abs(event.clientX - pointerTapState.startX);
const deltaY = Math.abs(event.clientY - pointerTapState.startY);
if (deltaX > 10 || deltaY > 10) {
pointerTapState.moved = true;
}
};
const onPointerUp = (event) => {
if (!event || (event.pointerType !== 'touch' && event.pointerType !== 'pen')) return;
if (event && event.__wordPopupHandled) return;
if (event.target && event.target.closest && event.target.closest('.clickable-word')) return;
if (pointerTapState.ignore || pointerTapState.moved) {
pointerTapState.ignore = false;
pointerTapState.moved = false;
return;
}
if (window.__lastWordPopupTs && Date.now() - window.__lastWordPopupTs < 350) return;
if (typeof isWordInfoSuppressed === 'function' && isWordInfoSuppressed()) return;
const now = Date.now();
const timeDelta = now - pointerTapState.lastTapTime;
const dist = Math.hypot(event.clientX - pointerTapState.lastTapX, event.clientY - pointerTapState.lastTapY);
const pWordNow = findWordAtPoint(event.clientX, event.clientY);
const pSameWord = !!(pWordNow && pointerTapState.lastTapWord && pWordNow === pointerTapState.lastTapWord);
const pNearEnough = pSameWord || (!pWordNow && !pointerTapState.lastTapWord && dist <= DOUBLE_TAP_DISTANCE);
if (pointerTapState.lastTapTime && timeDelta <= DOUBLE_TAP_DELAY && pNearEnough) {
if (pointerTapState.tapTimeout) {
clearTimeout(pointerTapState.tapTimeout);
pointerTapState.tapTimeout = null;
}
pointerTapState.lastTapTime = 0;
pointerTapState.lastTapX = 0;
pointerTapState.lastTapY = 0;
pointerTapState.lastTapWord = null;
triggerWordPopup(event.clientX, event.clientY, event);
window.__lastWordTouchTs = Date.now();
window.__lastWordPopupTs = Date.now();
window.suppressMouseUntil = Date.now() + 2000;
} else {
pointerTapState.lastTapTime = now;
pointerTapState.lastTapX = event.clientX;
pointerTapState.lastTapY = event.clientY;
pointerTapState.lastTapWord = pWordNow;
if (pointerTapState.tapTimeout) clearTimeout(pointerTapState.tapTimeout);
pointerTapState.tapTimeout = setTimeout(() => {
pointerTapState.lastTapTime = 0;
pointerTapState.tapTimeout = null;
}, DOUBLE_TAP_DELAY + 60);
}
pointerTapState.moved = false;
pointerTapState.ignore = false;
};
readingArea.addEventListener('pointerdown', onPointerDown, { passive: false });
readingArea.addEventListener('pointermove', onPointerMove, { passive: true });
readingArea.addEventListener('pointerup', onPointerUp, { passive: false });
}
// Opportunistically mark more words as having info using Greek WordNet (NT only)
async function enhanceWordHintsWithWordNet(tabId) {
try {
const api = window.greekWordNet;
if (!api || typeof api.searchSynsets !== 'function') return;
const tab = window.bibleApp?.tabs?.find(t => t.id === tabId);
if (!tab || !tab.book) return;
// Only augment in NT where Greek WordNet applies
const ntBooks = new Set(['Matthew','Mark','Luke','John','Acts','Romans','1 Corinthians','2 Corinthians','Galatians','Ephesians','Philippians','Colossians','1 Thessalonians','2 Thessalonians','1 Timothy','2 Timothy','Titus','Philemon','Hebrews','James','1 Peter','2 Peter','1 John','2 John','3 John','Jude','Revelation']);
if (!ntBooks.has(tab.book)) return;
const bibleText = document.getElementById(`bibleText${tabId}`);
if (!bibleText) return;
const STOP_WORDS = new Set(['the','and','of','to','in','a','an','is','are','was','were','his','her','their','with','for','on','at','as','by','or','from','that','this','it','its','be','have','has','had','but','so','not','no','if','then','he','she','they','you','we','i','me','my','our','your','them','us','him','who','whom','which','what','when','where','why','how','into','out','up','down','over','under','all','any','every','each','both','neither','either','shall','will','would','should','could','may','might','can','do','did','does','unto','thereof','therein','there','here','let','thus','than','because','also','even','ever','yet']);
const spans = Array.from(bibleText.querySelectorAll('.clickable-word'));
const unique = new Map(); // lower -> [elements]
for (const el of spans) {
if (el.classList.contains('has-data')) continue;
const w = (el.getAttribute('data-word-lc') || el.textContent || '').toLowerCase().replace(/[^\p{L}\p{N}']/gu, '');
if (!w || w.length <= 2) continue;
if (STOP_WORDS.has(w)) continue;
if (!unique.has(w)) unique.set(w, []);
unique.get(w).push(el);
}
// Limit checks to avoid flooding the API
/*
* IMPORTANT: searchSynsets goes over the INTERNET (the server proxies /api/wordnet to
* greekwordnet.chs.harvard.edu) with a 15s client timeout. This loop used to fire up to 40 of those
* back to back after every passage load, which is why word popups "took forever or would not open":
* the popup's own local file reads queued behind a stream of transatlantic requests, and offline —
* i.e. most Sundays if the wifi drops — each one sat there until it timed out.
*
* All this buys is a hint (marking a few more words as tappable), so it is now strictly last in
* line: skipped entirely when offline, answered from a persistent cache whenever possible, given a
* 2s ceiling, abandoned after two failures, and paused whenever a popup is on screen or opening.
*/
if (typeof navigator !== 'undefined' && navigator.onLine === false) return;
if (window.__wordnetOff) return;
const CACHE_KEY = 'wordnet-hint-cache';
let cache = {};
try { cache = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}') || {}; } catch (_) { cache = {}; }
const popupBusy = () => {
try {
if (document.querySelector('.word-info-popup')) return true;
if (window.__wordPopupGateTs && (Date.now() - window.__wordPopupGateTs) < 1500) return true;
} catch (_) {}
return false;
};
const idle = () => new Promise(r => setTimeout(r, 120));
let failures = 0;
const candidates = Array.from(unique.keys()).slice(0, 40);
for (let i = 0; i < candidates.length; i++) {
const word = candidates[i];
// Never hold up a popup: wait for the operator to finish before asking for another hint.
let guard = 0;
while (popupBusy() && guard++ < 100) await new Promise(r => setTimeout(r, 250));
let ok = false;
if (Object.prototype.hasOwnProperty.call(cache, word)) {
ok = !!cache[word];
} else {
try {
const res = await api.searchSynsets(word, { timeoutMs: 2000 });
ok = Array.isArray(res) && res.length > 0;
cache[word] = ok;
failures = 0;
} catch (_) {
ok = false;
if (++failures >= 2) { window.__wordnetOff = true; break; } // no usable connection: stop
}
await idle(); // leave the wire free
}
if (ok) {
const els = unique.get(word) || [];
els.forEach(el => {
try {
el.classList.add('has-data');
el.style.cursor = 'pointer';
const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
el.title = isTouch ? 'Double-tap to see Greek info' : 'Double-click to see Greek info';
} catch (_) {}
});
}
// Gentle yielding
if ((i % 4) === 3) {
await new Promise(r => setTimeout(r, 30));
}
}
try { localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); } catch (_) {}
} catch (_) {}
}
// Remove word click listeners
function removeWordClickListeners(tabId) {
const bibleText = document.getElementById(`bibleText${tabId}`);
if (!bibleText) return;
// Remove click listeners and unwrap clickable words
const clickableWords = bibleText.querySelectorAll('.clickable-word');
clickableWords.forEach(wordSpan => {
// Clean up event listeners
wordSpan.removeEventListener('click', wordSpan._clickHandler);
wordSpan.removeEventListener('dblclick', wordSpan._dblClickHandler);
wordSpan.removeEventListener('touchstart', wordSpan._touchStartHandler);
wordSpan.removeEventListener('touchmove', wordSpan._touchMoveHandler);
wordSpan.removeEventListener('touchend', wordSpan._touchEndHandler);
// Clean up any active double-tap timers
if (wordSpan._touchTapState && wordSpan._touchTapState.tapTimeout) {
clearTimeout(wordSpan._touchTapState.tapTimeout);
wordSpan._touchTapState.tapTimeout = null;
}
// Remove visual feedback
wordSpan.style.backgroundColor = '';
// Replace with text node
const textNode = document.createTextNode(wordSpan.textContent);
wordSpan.parentNode.insertBefore(textNode, wordSpan);
wordSpan.parentNode.removeChild(wordSpan);
});
console.log('Word click listeners removed for tab:', tabId);
}
// Make select tool functions globally accessible (after function declarations)
window.enableWordSelection = enableWordSelection;
window.disableWordSelection = disableWordSelection;
window.setupAnnotationForActiveTab = setupAnnotationForActiveTab;
// SIMPLE SELECT TOOL DEBUG - Run this in browser console if select tool isn't working
window.debugSelectTool = function() {
console.log('?? SELECT TOOL DEBUG...');
const activeTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId);
console.log('?? Current tab mode:', activeTab?.mode);
console.log('?? Current tool:', window.currentTool);
console.log('?? Select mode active:', window.selectModeActive);
if (activeTab?.mode === 'media') {
const mediaItems = document.querySelectorAll('.canvas-media-item');
console.log('?? Found', mediaItems.length, 'media items');
mediaItems.forEach((item, index) => {
const styles = getComputedStyle(item);
console.log(`?? Media item ${index}:`, {
pointerEvents: styles.pointerEvents,
cursor: styles.cursor,
zIndex: styles.zIndex
});
});
}
console.log('?? SELECT TOOL DEBUG COMPLETE');
};
// Make words in text clickable
function makeWordsClickable(text) {
// Split text into words while preserving whitespace
return text.replace(/\b\w+\b/g, (word) => {
const cleanWord = word.replace(/[^\w]/g, '').toLowerCase();
// Check if word has data - use try/catch to handle any errors
let hasData = false;
try {
// Make sure the hasWordData function is available
if (typeof hasWordData === 'function') {
hasData = hasWordData(cleanWord);
} else if (typeof window.hasWordData === 'function') {
hasData = window.hasWordData(cleanWord);
}
} catch (error) {
console.warn('?? WORD DATA ERROR - Could not check word data for:', cleanWord, error);
hasData = false;
}
const className = hasData ? 'clickable-word has-data' : 'clickable-word';
return `
${word}`;
});
}
// --- ANNOTATION LOGIC ---
// Store annotation canvases per tab
const annotationCanvases = {}; // visible canvases (viewport size)
const annotationOffscreen = {}; // offscreen canvases (full scrollable area)
// Resize observers to keep canvases in sync with long content
const contentResizeObservers = {};
const contentResizeRaf = {};
const contentResizeTargets = {};
function ensureContentResizeObserver(tabId) {
const bibleText = document.getElementById(`bibleText${tabId}`);
if (!bibleText) return;
try {
const existingObserver = contentResizeObservers[tabId];
const existingTarget = contentResizeTargets[tabId];
if (existingObserver && existingTarget === bibleText) return;
if (existingObserver) {
try { existingObserver.disconnect(); } catch (_) {}
}
const ro = new ResizeObserver((entries) => {
if (contentResizeRaf[tabId] == null) {
contentResizeRaf[tabId] = requestAnimationFrame(() => {
contentResizeRaf[tabId] = null;
if (!window.SILENCE_DEBUG) console.log('[DEBUG] Content resized - recalculating canvas dimensions for tabId:', tabId);
// Force a complete recalculation of canvas dimensions
const currentBibleText = document.getElementById(`bibleText${tabId}`);
const currentReadingArea = document.getElementById(`readingArea${tabId}`);
if (currentBibleText && currentReadingArea) {
// Force layout reflow before measuring
void currentBibleText.offsetHeight;
const newScrollHeight = currentBibleText.scrollHeight;
if (!window.SILENCE_DEBUG) console.log('[DEBUG] New content scrollHeight:', newScrollHeight);
// CRITICAL FIX: Don't force canvas recreation during/immediately after drawing
// This was the main cause of annotation disappearance in long chapters
if (drawing && drawingTabId === tabId) {
if (!window.SILENCE_DEBUG) console.log('[DEBUG] *** PREVENTED ResizeObserver canvas recreation during active drawing');
return;
}
if (window.lastDrawingEndTime && Date.now() - window.lastDrawingEndTime < 2000) {
if (!window.SILENCE_DEBUG) console.log('[DEBUG] *** PREVENTED ResizeObserver canvas recreation - too soon after drawing ended');
return;
}
// PRESERVED APPROACH: Only recreate if absolutely necessary and preserve content
const existingCanvas = annotationCanvases[tabId];
const existingOffscreen = annotationOffscreen[tabId];
// Check if we actually need to resize based on content growth
const currentCanvasHeight = existingOffscreen ? existingOffscreen.height / (window.devicePixelRatio || 1) : 0;
if (newScrollHeight <= currentCanvasHeight * 1.4) {
if (!window.SILENCE_DEBUG) console.log('[DEBUG] Content height within acceptable range - no recreation needed');
return;
}
if (!window.SILENCE_DEBUG) console.log('[DEBUG] *** ResizeObserver forcing canvas recreation - preserving existing annotations');
// CRITICAL: Preserve stroke data before forcing recreation
const passageKey = getCurrentPassageKey();
const strokeDataBackup = strokeHistory[passageKey] ? [...strokeHistory[passageKey]] : [];
if (!window.SILENCE_DEBUG) console.log('[DEBUG] *** Backing up', strokeDataBackup.length, 'strokes before ResizeObserver recreation');
if (existingCanvas) {
existingCanvas._lastWidth = 0; // Force resize
existingCanvas._lastHeight = 0;
}
// Recreate/resize canvases to match updated content height and refresh visible
getOrCreateAnnotationCanvas(tabId);
// CRITICAL: Restore stroke data if it was lost during recreation
if (strokeDataBackup.length > 0 && (!strokeHistory[passageKey] || strokeHistory[passageKey].length < strokeDataBackup.length)) {
console.log('[DEBUG] *** RESTORING STROKE DATA after ResizeObserver recreation');
strokeHistory[passageKey] = strokeDataBackup;
}
updateVisibleCanvas(tabId);
}
});
}
});
ro.observe(bibleText);
contentResizeObservers[tabId] = ro;
contentResizeTargets[tabId] = bibleText;
} catch (e) {
if (!window.SILENCE_DEBUG) console.log('[DEBUG] ResizeObserver unavailable:', e);
}
}
// Global variable to track setup state
let setupInProgress = false;
let lastSetupTabId = null;
// Add overlay canvas management
const annotationOverlayCanvases = {}; // overlay canvases for real-time feedback
// (Reverted) Always use full-content canvases for visible/overlay
// Utility: clear all annotation canvases for a tab immediately
function forceClearAnnotationCanvases(tabId) {
const canvas = annotationCanvases[tabId];
const overlay = annotationOverlayCanvases[tabId];
const offscreen = annotationOffscreen[tabId];
try {
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
} catch (_) {}
try {
if (overlay) {
const ctx = overlay.getContext('2d');
ctx.clearRect(0, 0, overlay.width, overlay.height);
}
} catch (_) {}
try {
if (offscreen) {
const ctx = offscreen.getContext('2d');
ctx.clearRect(0, 0, offscreen.width, offscreen.height);
offscreen._noPreserve = true; // ensure future resizes don't copy old pixels
}
} catch (_) {}
try { updateVisibleCanvas(tabId); } catch (_) {}
}
// Utility: fully remove and reset annotation canvases for a tab
function resetAnnotationCanvases(tabId) {
try {
const ra = document.getElementById(`readingArea${tabId}`);
const canvas = annotationCanvases[tabId];
const overlay = annotationOverlayCanvases[tabId];
if (canvas && ra && canvas.parentNode === ra) ra.removeChild(canvas);
if (overlay && ra && overlay.parentNode === ra) ra.removeChild(overlay);
} catch (_) {}
delete annotationCanvases[tabId];
delete annotationOverlayCanvases[tabId];
delete annotationOffscreen[tabId];
}
// Utility: clear stroke history in memory for a specific passage key (keeps localStorage intact)
function clearStrokeHistoryForKey(passageKey) {
if (!passageKey) return;
try { strokeHistory[passageKey] = []; } catch (_) {}
try { strokeRedo[passageKey] = []; } catch (_) {}
}
// Utility: aggressively refresh annotation rendering after state changes
function scheduleAnnotationRefresh(tabId, attempt = 0) {
const MAX = 5;
const delay = attempt === 0 ? 40 : 120;
setTimeout(() => {
try { redrawAllStrokes(tabId); } catch (_) {}
try { updateVisibleCanvas(tabId); } catch (_) {}
// Repeat a few times to catch late layout/resize
if (attempt + 1 < MAX) {
scheduleAnnotationRefresh(tabId, attempt + 1);
}
}, delay);
}
// Utility: wait for layout then render annotations for a tab
function renderAnnotationsWhenReady(tabId, attempt = 0, runSetup = false) {
const MAX = 8;
const delay = attempt === 0 ? 50 : 120;
const ra = document.getElementById(`readingArea${tabId}`);
if (!ra || ra.clientHeight === 0 || ra.clientWidth === 0) {
if (attempt < MAX) {
setTimeout(() => renderAnnotationsWhenReady(tabId, attempt + 1, runSetup), delay);
}
return;
}
try { ra.style.display = 'block'; } catch (_) {}
if (runSetup) {
try { setupAnnotationForActiveTab(); } catch (_) {}
}
try { getOrCreateAnnotationCanvas(tabId); } catch (_) {}
try { ensureNonZeroCanvas(tabId); } catch (_) {}
try { ensureCanvasAndPenReady(tabId); } catch (_) {}
try { loadAnnotationsInstant(tabId); } catch (_) {}
try { redrawAllStrokes(tabId); } catch (_) {}
try { updateVisibleCanvas(tabId); } catch (_) {}
scheduleAnnotationRefresh(tabId);
}
// Utility: hard refresh annotations for a tab (used after translation change)
function forceAnnotationSwap(tabId) {
try { forceClearAnnotationCanvases(tabId); } catch (_) {}
try { resetAnnotationCanvases(tabId); } catch (_) {}
try { getOrCreateAnnotationCanvas(tabId); } catch (_) {}
try { loadAnnotations(tabId); } catch (_) {}
try { redrawAllStrokes(tabId); } catch (_) {}
try { updateVisibleCanvas(tabId); } catch (_) {}
try {
requestAnimationFrame(() => {
try { redrawAllStrokes(tabId); } catch (_) {}
try { updateVisibleCanvas(tabId); } catch (_) {}
});
} catch (_) {}
}
// Utility: full reset of annotation state for a tab and optional old passage key
function hardResetAnnotations(tabId, oldPassageKey = null) {
try { clearStrokeHistoryForKey(oldPassageKey); } catch (_) {}
try { clearStrokeHistoryForKey(getCurrentPassageKey()); } catch (_) {}
// CRITICAL: Clear canvas synchronously BEFORE removing it to ensure immediate visual update
try { forceClearAnnotationCanvases(tabId); } catch (_) {}
// Force immediate visual update to ensure canvas is cleared before removal
try {
const canvas = annotationCanvases[tabId];
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
const overlay = annotationOverlayCanvases[tabId];
if (overlay) {
const ctx = overlay.getContext('2d');
ctx.clearRect(0, 0, overlay.width, overlay.height);
}
const offscreen = annotationOffscreen[tabId];
if (offscreen) {
const ctx = offscreen.getContext('2d');
ctx.clearRect(0, 0, offscreen.width, offscreen.height);
}
} catch (_) {}
try { resetAnnotationCanvases(tabId); } catch (_) {}
try {
const ra = document.getElementById(`readingArea${tabId}`);
const canvas = ra ? ra.querySelector('.annotation-canvas') : null;
const overlay = ra ? ra.querySelector('.annotation-overlay-canvas') : null;
if (ra && canvas && canvas.parentNode === ra) ra.removeChild(canvas);
if (ra && overlay && overlay.parentNode === ra) ra.removeChild(overlay);
} catch (_) {}
}
function getOrCreateAnnotationCanvas(tabId) {
let readingArea = document.getElementById(`readingArea${tabId}`);
if (!readingArea) return null;
// Check if this is a media, announcements, prayer, or ask tab - if so, don't create annotation canvases
const tab = window.bibleApp?.tabs?.find(t => t.id === tabId);
if (tab && (tab.mode === 'media' || tab.mode === 'announcements' || tab.mode === 'graphics' || tab.mode === 'maps' || tab.mode === 'prayer' || tab.mode === 'ask')) {
console.log('[DEBUG] Skipping annotation canvas creation for', tab.mode, 'tab:', tabId);
return null;
}
let canvas = readingArea.querySelector('.annotation-canvas');
// Use device DPR (we'll adjust performance elsewhere)
const dpr = window.devicePixelRatio || 1;
const bibleText = readingArea.querySelector('.bible-text');
// Ensure stacking context for canvases
try { if (readingArea && getComputedStyle(readingArea).position === 'static') readingArea.style.position = 'relative'; } catch(_) {}
// Calculate full content height for offscreen buffer
// Force a layout reflow to ensure accurate scrollHeight measurement
if (bibleText) {
bibleText.style.display = 'block';
void bibleText.offsetHeight; // Force reflow
}
const baseContentHeight = bibleText ? bibleText.scrollHeight : readingArea.scrollHeight;
// Guarantee at least 3 viewport heights of scroll range, even for single-verse passages
// ENHANCED FIX: Add much larger buffer for very long chapters like Psalms 119
const minContentHeight = Math.max(baseContentHeight, readingArea.clientHeight * 3);
// Calculate adaptive buffer based on content size - larger buffer for longer content
const contentRatio = baseContentHeight / readingArea.clientHeight;
const adaptiveBuffer = contentRatio > 10 ?
Math.max(readingArea.clientHeight * 4, 4000) : // Large buffer for very long chapters
Math.max(readingArea.clientHeight * 2, 2000); // Standard buffer
const contentHeight = minContentHeight + adaptiveBuffer;
console.log('[DEBUG] ?? Adaptive canvas sizing - base:', baseContentHeight, 'ratio:', contentRatio.toFixed(1), 'buffer:', adaptiveBuffer, 'final:', contentHeight);
// Viewport-sized visible/overlay canvases
const displayWidth = readingArea.clientWidth;
const displayHeight = readingArea.clientHeight;
// PORTRAIT MODE FIX: Ensure stable dimensions by forcing layout reflow
void readingArea.offsetHeight;
const stableWidth = readingArea.clientWidth;
const stableHeight = readingArea.clientHeight;
const isPortrait = window.innerHeight > window.innerWidth;
console.log('[DEBUG] ?? Canvas setup (viewport) - viewport:', stableWidth, 'x', stableHeight, 'contentHeight:', contentHeight, 'isPortrait:', isPortrait);
let needsResize = false;
if (!canvas) {
canvas = document.createElement('canvas');
canvas.className = 'annotation-canvas';
// Keep the canvas aligned with the visible viewport inside the scrollable area
canvas.style.position = 'absolute';
canvas.style.top = '0px';
canvas.style.left = '0';
canvas.style.width = '100%';
canvas.style.height = stableHeight + 'px';
canvas.style.pointerEvents = selectModeActive ? 'none' : 'auto';
canvas.style.background = 'transparent';
canvas.style.cursor = 'crosshair';
canvas.style.zIndex = '5000';
// PORTRAIT MODE FIX: Use 'none' for more reliable touch handling in portrait
canvas.style.touchAction = isPortrait ? 'none' : 'pan-x pan-y';
// Align the visible canvas to the current viewport position within the scrollable area
canvas.style.top = `${readingArea.scrollTop}px`;
readingArea.appendChild(canvas);
needsResize = true;
} else {
const widthDiff = Math.abs(canvas._lastWidth - stableWidth);
const heightDiff = Math.abs(canvas._lastHeight - stableHeight);
needsResize = widthDiff > 1 || heightDiff > 1 || canvas._lastDpr !== dpr;
}
if (needsResize) {
// Keep the visible canvas viewport-sized
canvas.width = Math.max(1, Math.floor(stableWidth * dpr));
canvas.height = Math.max(1, Math.floor(stableHeight * dpr));
canvas.style.width = stableWidth + 'px';
canvas.style.height = stableHeight + 'px';
const ctx = canvas.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
canvas._lastWidth = stableWidth;
canvas._lastHeight = stableHeight;
canvas._lastDpr = dpr; // store effective DPR actually used for drawing
}
annotationCanvases[tabId] = canvas;
// Create overlay canvas with viewport dimensions
let overlay = readingArea.querySelector('.annotation-overlay-canvas');
if (!overlay) {
overlay = document.createElement('canvas');
overlay.className = 'annotation-overlay-canvas';
// Overlay aligned with visible viewport
overlay.style.position = 'absolute';
overlay.style.top = '0px';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = stableHeight + 'px';
overlay.style.pointerEvents = 'none';
overlay.style.background = 'transparent';
overlay.style.zIndex = '5001';
// Align the overlay canvas to the current viewport position within the scrollable area
overlay.style.top = `${readingArea.scrollTop}px`;
readingArea.appendChild(overlay);
needsResize = true;
}
if (needsResize) {
overlay.width = Math.max(1, Math.floor(stableWidth * dpr));
overlay.height = Math.max(1, Math.floor(stableHeight * dpr));
overlay.style.width = stableWidth + 'px';
overlay.style.height = stableHeight + 'px';
const ctx = overlay.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
}
annotationOverlayCanvases[tabId] = overlay;
// Offscreen canvas matches full content height
let offscreen = annotationOffscreen[tabId];
if (!offscreen) {
offscreen = document.createElement('canvas');
offscreen.width = Math.max(1, Math.floor(stableWidth * dpr));
offscreen.height = Math.max(1, Math.floor(contentHeight * dpr));
const ctx = offscreen.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
annotationOffscreen[tabId] = offscreen;
} else if (needsResize) {
// CRITICAL FIX: Preserve existing canvas content when resizing for long chapters
const oldOffscreen = offscreen;
const oldWidth = oldOffscreen.width;
const oldHeight = oldOffscreen.height;
// Create new canvas with larger dimensions
const newOffscreen = document.createElement('canvas');
newOffscreen.width = Math.max(1, Math.floor(stableWidth * dpr));
newOffscreen.height = Math.max(1, Math.floor(contentHeight * dpr));
const newCtx = newOffscreen.getContext('2d');
newCtx.setTransform(1, 0, 0, 1, 0, 0);
newCtx.scale(dpr, dpr);
// Copy existing content to the new canvas to preserve annotations
if (!oldOffscreen._noPreserve && oldWidth > 0 && oldHeight > 0) {
console.log('[DEBUG] ?? PRESERVING ANNOTATIONS - Copying from old canvas:', oldWidth, 'x', oldHeight, 'to new:', newOffscreen.width, 'x', newOffscreen.height);
newCtx.drawImage(oldOffscreen, 0, 0);
} else if (oldOffscreen._noPreserve) {
console.log('[DEBUG] ?? PRESERVE SKIPPED - force-clear flag detected');
}
// Replace the old canvas
annotationOffscreen[tabId] = newOffscreen;
newOffscreen._noPreserve = false;
offscreen = newOffscreen;
// ADDITIONAL SAFETY: Redraw all strokes to ensure they're properly rendered on the new canvas
// This ensures stroke data and visual display stay in sync after canvas recreation
setTimeout(() => {
console.log('[DEBUG] ?? SAFETY REDRAW - Ensuring strokes are properly rendered after canvas recreation');
redrawAllStrokes(tabId);
}, 50);
}
// Ensure a bottom spacer exists to allow smooth scroll even for short passages
try {
const wrapper = document.getElementById(`contentWrapper${tabId}`) || readingArea;
let spacer = document.getElementById(`annotationSpacer${tabId}`);
const extraBottomSpacePx = Math.max(readingArea.clientHeight, 1200);
if (!spacer) {
spacer = document.createElement('div');
spacer.id = `annotationSpacer${tabId}`;
spacer.style.width = '1px';
spacer.style.height = extraBottomSpacePx + 'px';
spacer.style.pointerEvents = 'none';
spacer.style.opacity = '0';
spacer.style.background = '#000000';
spacer.style.backgroundColor = '#000000';
wrapper.appendChild(spacer);
} else {
spacer.style.height = extraBottomSpacePx + 'px';
spacer.style.background = '#000000';
spacer.style.backgroundColor = '#000000';
}
} catch (_) {}
return canvas;
}
// Enhanced debug function for diagnosing undo/redo issues
window.diagnoseSingleClickIssue = function() {
console.log('=== DIAGNOSING SINGLE-CLICK UNDO/REDO ISSUE ===');
console.log('?? Current unified history (most recent first):');
if (unifiedHistory.length === 0) {
console.log(' No operations in history');
return;
}
// Show last 10 operations with detail
const recentOps = unifiedHistory.slice(-10);
recentOps.reverse().forEach((op, i) => {
const index = unifiedHistory.length - i;
const typeLabel = op.isStroke ? `${op.type} (STROKE)` : `${op.type} (ACTION)`;
const timestamp = new Date(op.timestamp).toLocaleTimeString();
console.log(` ${index}. [${timestamp}] ${typeLabel} - TabId: ${op.tabId} - Passage: ${op.passageKey?.split('-').slice(-2).join('-') || 'none'}`);
if (op.isStroke && op.strokeData) {
console.log(` Stroke: ${op.strokeData.type} with ${op.strokeData.points?.length || 0} points`);
}
if (op.type === ACTION_TYPES.CLEAR && op.data) {
console.log(` Clear: ${op.data.clearedStrokes?.length || 0} strokes cleared`);
}
if (op.type === ACTION_TYPES.TAB_CLOSE && op.data) {
console.log(` Tab Close: ${op.data.tabData?.book || 'unknown'} ${op.data.tabData?.chapter || '?'}:${op.data.tabData?.verse || '?'}`);
}
});
console.log('');
console.log('?? Next undo will affect:', unifiedHistory[unifiedHistory.length - 1]?.type || 'nothing');
console.log('?? Total operations available to undo:', unifiedHistory.length);
console.log('');
console.log('?? If first undo clears annotations temporarily:');
console.log(' - Check if most recent operation is a STROKE that shouldnt be there');
console.log(' - This suggests automatic stroke operations are being recorded');
console.log(' - Use this info to track down the source');
console.log('===============================================');
};
// Enhanced test function for complete undo/redo verification
window.testCompleteUndoRedo = function() {
console.log('=== FIXED: SINGLE-CLICK UNDO/REDO TEST ===');
console.log('?? Testing single-click undo/redo functionality...');
console.log('?? FIXED: No more double-clicking required!');
const initialCount = unifiedHistory.length;
console.log('?? Initial unified history count:', initialCount);
// Test 1: Tool change
console.log('\n1?? Testing TOOL CHANGE...');
const originalTool = currentTool;
const toolAction = createAction(ACTION_TYPES.TOOL_CHANGE, { from: currentTool, to: 'highlight' });
pushAction(toolAction);
console.log(' ? Tool change operation added (should be 1 operation only)');
// Test 2: Clear current tab
console.log('2?? Testing CLEAR CURRENT TAB...');
if (window.bibleApp && window.bibleApp.tabs.length > 0) {
const currentTabId = getActiveTabId();
clearAllAnnotations(currentTabId);
console.log(' ? Clear current tab operation added (should be 1 operation only)');
} else {
console.log(' ?? No active tab found for testing');
}
// Test 3: Clear all
console.log('3?? Testing CLEAR ALL ANNOTATIONS...');
clearAllAnnotationsAcrossApp();
console.log(' ? Clear all annotations operation added (should be 1 operation only)');
// Test 4: Tab close (the problematic one that was creating 2 operations)
console.log('4?? Testing TAB CLOSE (Previously Required Double-Click)...');
if (window.bibleApp) {
const operationsBefore = unifiedHistory.length;
window.bibleApp.createNewTab();
const operationsAfterCreate = unifiedHistory.length;
console.log(` ?? Tab creation added ${operationsAfterCreate - operationsBefore} operations (should be 1)`);
if (window.bibleApp.tabs.length > 1) {
const operationsBeforeClose = unifiedHistory.length;
const newTabId = window.bibleApp.tabs[window.bibleApp.tabs.length - 1].id;
window.bibleApp.closeTab(newTabId);
const operationsAfterClose = unifiedHistory.length;
console.log(` ??? Tab close added ${operationsAfterClose - operationsBeforeClose} operations (should be 1, was 2 before fix)`);
}
}
const finalCount = unifiedHistory.length;
const operationsAdded = finalCount - initialCount;
console.log('\n?? FIXED RESULTS:');
console.log(' Operations added:', operationsAdded, '(should be 4, not 5-6)');
console.log(' Total operations available to undo:', unifiedHistory.length);
console.log(' Undo button enabled:', !document.getElementById('undoBtn')?.disabled);
console.log('\n?? TESTING SINGLE-CLICK UNDO...');
console.log('?? Each operation should undo with SINGLE click now!');
// Test single-click undo for each operation
for (let i = 0; i < Math.min(operationsAdded, 4); i++) {
const beforeUndo = unifiedHistory.length;
console.log(`\n Single-Click Undo Test ${i + 1}:`);
console.log(` Operations before undo: ${beforeUndo}`);
const undoResult = undoUnifiedOperation();
const afterUndo = unifiedHistory.length;
console.log(` Operations after undo: ${afterUndo}`);
console.log(` Difference: ${beforeUndo - afterUndo} (should be exactly 1)`);
if (undoResult && (beforeUndo - afterUndo) === 1) {
console.log(' ? SINGLE-CLICK UNDO SUCCESS!');
} else {
console.log(' ? Single-click undo failed');
console.log(' ?? This means the fix did not work completely');
}
}
console.log('\n?? SINGLE-CLICK UNDO/REDO TEST COMPLETE!');
console.log('? Tab closing, clear operations, and all other actions now work with single clicks!');
console.log('?? Use window.debugUndoRedo() to see current state');
console.log('?? Automatic operations (like creating new tab when last tab is closed) are no longer recorded separately');
console.log('===============================================');
return {
operationsAdded,
expectedOperations: 4,
singleClickWorking: operationsAdded <= 4,
finalHistoryCount: unifiedHistory.length,
finalRedoCount: unifiedRedo.length
};
};
// Global test function specifically for clear and tab operations
window.testClearAndTabUndo = function() {
console.log('=== TESTING CLEAR & TAB UNDO/REDO INTEGRATION ===');
console.log('');
const initialCount = unifiedHistory.length;
console.log('?? Initial unified history count:', initialCount);
// Test clear current tab
console.log('1?? Testing CLEAR CURRENT TAB...');
if (window.bibleApp && window.bibleApp.tabs.length > 0) {
const currentTabId = getActiveTabId();
clearAllAnnotations(currentTabId);
console.log(' ? Clear current tab operation added to unified history');
} else {
console.log(' ?? No active tab found for testing');
}
// Test clear all
console.log('2?? Testing CLEAR ALL ANNOTATIONS...');
clearAllAnnotationsAcrossApp();
console.log(' ? Clear all annotations operation added to unified history');
// Test tab creation and closing
console.log('3?? Testing TAB CLOSE...');
if (window.bibleApp) {
const tabCountBefore = window.bibleApp.tabs.length;
window.bibleApp.createNewTab();
console.log(' ?? Created new tab');
if (window.bibleApp.tabs.length > 1) {
const newTabId = window.bibleApp.tabs[window.bibleApp.tabs.length - 1].id;
window.bibleApp.closeTab(newTabId);
console.log(' ? Tab close operation added to unified history');
} else {
console.log(' ?? Could not create test tab for closing');
}
} else {
console.log(' ?? Bible app not found for testing');
}
const finalCount = unifiedHistory.length;
const operationsAdded = finalCount - initialCount;
console.log('');
console.log('?? RESULTS:');
console.log(' Operations added to unified history:', operationsAdded);
console.log(' Total unified history count:', finalCount);
console.log(' Undo button enabled:', !document.getElementById('undoBtn')?.disabled);
if (operationsAdded >= 2) {
console.log(' ?? SUCCESS: Clear and tab operations are integrated!');
console.log('');
console.log('?? Try pressing Ctrl+Z or clicking undo button to test:');
console.log(' ? Operations should undo in reverse chronological order');
console.log(' ? Each undo should restore the previous state perfectly');
} else {
console.log(' ? ISSUE: Not all operations were added to unified history');
console.log(' ?? Check if pushAction() calls are working correctly');
}
console.log('');
console.log('?? View current unified history:');
console.log(' window.debugUndoRedo()');
console.log('===============================================');
return {
operationsAdded,
totalHistory: finalCount,
undoEnabled: !document.getElementById('undoBtn')?.disabled
};
};
// Helper to get offscreen context
function getOffscreenContext(tabId) {
const offscreen = annotationOffscreen[tabId];
if (!offscreen) return null;
const dpr = window.devicePixelRatio || 1;
const ctx = offscreen.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
return ctx;
}
// Helper function to check if content height needs adjustment
function checkAndAdjustContentHeight(tabId) {
const readingArea = document.getElementById(`readingArea${tabId}`);
const bibleText = document.getElementById(`bibleText${tabId}`);
const offscreen = annotationOffscreen[tabId];
if (!readingArea || !bibleText || !offscreen) return false;
// CRITICAL FIX: Never recreate canvas during active drawing to prevent annotation loss
if (drawing && drawingTabId === tabId) {
console.log('[DEBUG] Skipping content height check during active drawing to preserve annotations');
return false;
}
// ADDITIONAL FIX: Prevent recreation for a short period after drawing ends
// This prevents the canvas from being recreated immediately after endDrawing() calls updateVisibleCanvas()
if (window.lastDrawingEndTime && Date.now() - window.lastDrawingEndTime < 1800) {
console.log('[DEBUG] Skipping content height check - too soon after drawing ended');
return false;
}
// Force layout reflow to get accurate measurements
void bibleText.offsetHeight;
const actualContentHeight = bibleText.scrollHeight;
const currentCanvasHeight = offscreen.height / (window.devicePixelRatio || 1);
/*
* Width / device-pixel-ratio drift. A canvas built for one width or scaling and shown at another
* draws every stroke scaled toward the top-left — the "ink lands smaller and up-left" symptom seen
* on the TV after a display change and on the follower screen. Cheap to detect (two integers), so
* check it here on the same schedule as the height, and repair through the same recreate path.
*/
try {
const dprNow = window.devicePixelRatio || 1;
const visibleCanvas = annotationCanvases[tabId];
if (visibleCanvas && readingArea.clientWidth) {
const wantW = Math.max(1, Math.floor(readingArea.clientWidth * dprNow));
const cssW = Math.round(visibleCanvas.getBoundingClientRect().width);
if (Math.abs(visibleCanvas.width - wantW) > 1 || (cssW && Math.abs(cssW - readingArea.clientWidth) > 1)) {
console.log('[DEBUG] Canvas width/DPR drift - recreating (canvas', visibleCanvas.width, 'css', cssW, 'want', wantW, ')');
resetAnnotationCanvases(tabId);
getOrCreateAnnotationCanvas(tabId);
return true;
}
}
} catch (_) {}
console.log('[DEBUG] Content height check - actual:', actualContentHeight, 'canvas:', currentCanvasHeight);
// If actual content is significantly larger than what we allocated, recreate canvas
// CRITICAL FIX: Only recreate if content is MUCH larger to avoid unnecessary recreation
// and use a higher threshold to prevent recreation during normal drawing operations
if (actualContentHeight > currentCanvasHeight * 1.4) {
console.log('[DEBUG] Content height significantly exceeded canvas - recreating with preserved content');
console.log('[DEBUG] Actual content:', actualContentHeight, 'Canvas height:', currentCanvasHeight, 'Threshold exceeded');
// Force canvas recreation (the getOrCreateAnnotationCanvas function now preserves content)
const existingCanvas = annotationCanvases[tabId];
if (existingCanvas) {
existingCanvas._lastWidth = 0;
existingCanvas._lastHeight = 0;
}
getOrCreateAnnotationCanvas(tabId);
return true;
}
return false;
}
// Update visible canvas from offscreen
function updateVisibleCanvas(tabId, disableScroll = false) {
const readingArea = document.getElementById(`readingArea${tabId}`);
const canvas = annotationCanvases[tabId];
if (!readingArea || !canvas) return;
// Check if this is a media tab - if so, don't update the canvas
const tab = window.bibleApp?.tabs?.find(t => t.id === tabId);
if (tab && tab.mode === 'media') {
console.log('[DEBUG] Skipping updateVisibleCanvas for media tab:', tabId);
return;
}
// Avoid canvas recreation churn immediately after drawing
if (checkAndAdjustContentHeight(tabId)) {
// If a resize was needed, proceed with drawing to the new visible canvas
}
const passageKey = getPassageKeyForTabId(tabId) || getCurrentPassageKey();
const strokes = strokeHistory[passageKey] || [];
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
// Prepare visible canvas
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.scale(dpr, dpr);
// Determine visible vertical range in stroke coordinate space (which is in device pixels)
const viewportStartY = (readingArea.scrollTop || 0) * dpr;
const viewportEndY = viewportStartY + canvas.height; // canvas.height is already in device pixels
const margin = 4 * dpr; // include a small margin for thick strokes
// Draw only strokes intersecting the visible viewport
for (let i = 0; i < strokes.length; i++) {
const stroke = strokes[i];
const points = stroke.points || [];
if (points.length < 2) continue;
// Quick visibility check using stroke vertical bounds
let minY = Infinity, maxY = -Infinity;
for (let j = 0; j < points.length; j++) {
const y = points[j].y;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
if (maxY < viewportStartY - margin || minY > viewportEndY + margin) {
continue;
}
// Configure compositing for this stroke
ctx.save();
if (stroke.type === 'highlight') {
ctx.globalAlpha = 0.3;
ctx.globalCompositeOperation = 'source-over';
} else if (stroke.type === 'erase') {
ctx.globalAlpha = 1.0;
ctx.globalCompositeOperation = 'destination-out';
} else {
ctx.globalAlpha = 1.0;
ctx.globalCompositeOperation = 'source-over';
}
ctx.strokeStyle = stroke.color || '#ffff00';
ctx.lineWidth = Math.max(1, stroke.size || 4);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Draw the stroke in CSS pixel space
ctx.beginPath();
ctx.moveTo(points[0].x / dpr, points[0].y / dpr - viewportStartY / dpr);
const isTouch = stroke.inputType === 'touch';
if (isTouch) {
for (let k = 1; k < points.length - 1; k++) {
const midX = (points[k].x + points[k + 1].x) / 2 / dpr;
const midY = (points[k].y + points[k + 1].y) / 2 / dpr - viewportStartY / dpr;
ctx.quadraticCurveTo(points[k].x / dpr, points[k].y / dpr - viewportStartY / dpr, midX, midY);
}
ctx.lineTo(points[points.length - 1].x / dpr, points[points.length - 1].y / dpr - viewportStartY / dpr);
} else {
for (let k = 1; k < points.length; k++) {
ctx.lineTo(points[k].x / dpr, points[k].y / dpr - viewportStartY / dpr);
}
}
ctx.stroke();
ctx.restore();
}
// Live real-time eraser preview: apply in-progress erase path to visible canvas
if (typeof drawing !== 'undefined' && drawing &&
typeof drawingTabId !== 'undefined' && drawingTabId === tabId &&
typeof currentTool !== 'undefined' && currentTool === 'erase' &&
Array.isArray(currentStrokePoints) && currentStrokePoints.length > 1) {
ctx.save();
ctx.globalAlpha = 1.0;
ctx.globalCompositeOperation = 'destination-out';
const settings = (typeof getCurrentDrawingSettings === 'function') ? getCurrentDrawingSettings() : null;
ctx.strokeStyle = '#000000';
ctx.lineWidth = Math.max(1, settings && settings.size ? settings.size : 24);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(currentStrokePoints[0].x / dpr, currentStrokePoints[0].y / dpr);
const wasTouch = (window.currentDrawingInputType === 'touch');
if (wasTouch) {
for (let k = 1; k < currentStrokePoints.length - 1; k++) {
const midX = (currentStrokePoints[k].x + currentStrokePoints[k + 1].x) / 2 / dpr;
const midY = (currentStrokePoints[k].y + currentStrokePoints[k + 1].y) / 2 / dpr;
ctx.quadraticCurveTo(
currentStrokePoints[k].x / dpr,
currentStrokePoints[k].y / dpr,
midX,
midY
);
}
ctx.lineTo(
currentStrokePoints[currentStrokePoints.length - 1].x / dpr,
currentStrokePoints[currentStrokePoints.length - 1].y / dpr
);
} else {
for (let k = 1; k < currentStrokePoints.length; k++) {
ctx.lineTo(
currentStrokePoints[k].x / dpr,
currentStrokePoints[k].y / dpr
);
}
}
ctx.stroke();
ctx.restore();
}
}
// RAF-throttled visible canvas updater to avoid flooding during erase
let _visibleUpdateRafId = null;
function requestVisibleUpdate(tabId) {
if (_visibleUpdateRafId != null) return;
_visibleUpdateRafId = requestAnimationFrame(() => {
_visibleUpdateRafId = null;
updateVisibleCanvas(tabId);
// Keep overlay aligned to scroll
try {
const readingArea = document.getElementById(`readingArea${tabId}`);
const overlay = annotationOverlayCanvases[tabId];
const canvas = annotationCanvases[tabId];
if (readingArea && overlay) overlay.style.top = `${readingArea.scrollTop}px`;
if (readingArea && canvas) canvas.style.top = `${readingArea.scrollTop}px`;
} catch(_) {}
});
}
// (Reverted) No special scroll sync needed when visible canvas spans full content
function attachScrollSync(tabId) {
const readingArea = document.getElementById(`readingArea${tabId}`);
if (!readingArea) return;
if (readingArea._annotationScrollBound) return;
const onScroll = () => {
// Copy the correct slice from offscreen and align canvases using top position
const ra = document.getElementById(`readingArea${tabId}`);
const currentTop = ra ? ra.scrollTop : 0;
try {
const overlay = annotationOverlayCanvases[tabId];
const canvas = annotationCanvases[tabId];
if (overlay) overlay.style.top = `${currentTop}px`;
if (canvas) canvas.style.top = `${currentTop}px`;
} catch (_) {}
updateVisibleCanvas(tabId);
};
readingArea.addEventListener('scroll', onScroll, { passive: true });
readingArea._annotationScrollBound = true;
}
// Robust fallback: continuously monitor scrollTop to keep canvases synced
// Some environments/devices occasionally drop scroll events on long content.
function ensureScrollMonitor(tabId) {
const readingArea = document.getElementById(`readingArea${tabId}`);
if (!readingArea) return;
if (readingArea._annotationScrollMonitor) return;
let lastScrollTop = readingArea.scrollTop;
const tick = () => {
// If scrollTop changed without a scroll event, resync canvases
const currentTop = readingArea.scrollTop;
if (currentTop !== lastScrollTop) {
lastScrollTop = currentTop;
try {
const overlay = annotationOverlayCanvases[tabId];
const canvas = annotationCanvases[tabId];
if (overlay) overlay.style.top = `${currentTop}px`;
if (canvas) canvas.style.top = `${currentTop}px`;
} catch (_) {}
updateVisibleCanvas(tabId);
}
readingArea._annotationScrollMonitorRaf = requestAnimationFrame(tick);
};
readingArea._annotationScrollMonitor = true;
readingArea._annotationScrollMonitorRaf = requestAnimationFrame(tick);
}
// Update on resize with orientation-specific handling
window.addEventListener('resize', () => {
const tabId = getActiveTabId();
if (!tabId) return;
// PORTRAIT MODE FIX: Don't resize canvas during active drawing
if (drawing && drawingTabId === tabId) {
if (!window.SILENCE_DEBUG) console.log('[DEBUG] Skipping canvas resize during active drawing');
return;
}
// PORTRAIT MODE FIX: Delay canvas recreation to ensure stable dimensions
setTimeout(() => {
if (!window.SILENCE_DEBUG) console.log('[DEBUG] Handling resize/orientation change for tabId:', tabId);
const passageKey = getCurrentPassageKey();
const strokeBackup = strokeHistory[passageKey] ? [...strokeHistory[passageKey]] : [];
getOrCreateAnnotationCanvas(tabId);
// Ensure stroke data is preserved after resize
if (strokeBackup.length > 0 && (!strokeHistory[passageKey] || strokeHistory[passageKey].length < strokeBackup.length)) {
if (!window.SILENCE_DEBUG) console.log('[DEBUG] Restoring strokes after orientation change');
strokeHistory[passageKey] = strokeBackup;
}
updateVisibleCanvas(tabId);
}, 100);
});
// PORTRAIT MODE FIX: Add orientation change handler
window.addEventListener('orientationchange', () => {
if (!window.SILENCE_DEBUG) console.log('[DEBUG] Orientation changed - handling canvas updates');
// Wait for orientation change to complete
setTimeout(() => {
const tabId = getActiveTabId();
if (!tabId) return;
// Don't handle during drawing
if (drawing && drawingTabId === tabId) {
if (!window.SILENCE_DEBUG) console.log('[DEBUG] Skipping orientation change handling during drawing');
return;
}
// Preserve stroke data
const passageKey = getCurrentPassageKey();
const strokeBackup = strokeHistory[passageKey] ? [...strokeHistory[passageKey]] : [];
if (!window.SILENCE_DEBUG) console.log('[DEBUG] Handling orientation change - preserving', strokeBackup.length, 'strokes');
// Force canvas recreation with new orientation
const canvas = annotationCanvases[tabId];
if (canvas) {
canvas._lastWidth = 0;
canvas._lastHeight = 0;
}
getOrCreateAnnotationCanvas(tabId);
// Restore stroke data
if (strokeBackup.length > 0) {
strokeHistory[passageKey] = strokeBackup;
redrawAllStrokes(tabId);
}
updateVisibleCanvas(tabId);
}, 300); // Wait for orientation to stabilize
});
// Update on tab switch
function setupAnnotationForActiveTab() {
const tabId = getActiveTabId();
console.log('[DEBUG] *** setupAnnotationForActiveTab called, tabId:', tabId, 'drawing state:', drawing);
if (!tabId) return;
const tab = getTabById(tabId);
if (tab && (tab.mode === 'announcements' || tab.mode === 'graphics' || tab.mode === 'maps' || tab.mode === 'prayer' || tab.mode === 'ask')) {
console.log('[DEBUG] Skipping annotation setup for', tab.mode, 'tab');
return;
}
// Allow annotations for both Bible and Notes (media) tabs
// Previously skipped for media; now enabled so tools work the same
// CRITICAL: Don't setup annotations while actively drawing - it clears the canvas!
if (drawing) {
console.log('[DEBUG] Skipping setupAnnotationForActiveTab - currently drawing');
return;
}
// Prevent multiple rapid calls for the same tab
if (setupInProgress && lastSetupTabId === tabId) {
console.log('[DEBUG] Setup already in progress for tabId:', tabId);
return;
}
setupInProgress = true;
lastSetupTabId = tabId;
// Create canvas first
getOrCreateAnnotationCanvas(tabId);
// Attach listeners
attachAnnotationListeners(tabId);
attachScrollSync(tabId);
ensureScrollMonitor(tabId);
// Reset two-finger state on tab switch to avoid stale handlers affecting gestures
try {
if (typeof twoFingerScroll !== 'undefined') {
if (twoFingerScroll._boundMoveHandler && twoFingerScroll.scrollTarget) {
twoFingerScroll.scrollTarget.removeEventListener('touchmove', twoFingerScroll._boundMoveHandler, { passive: false });
}
twoFingerScroll._boundMoveHandler = null;
twoFingerScroll.active = false;
twoFingerScroll.scrollTarget = null;
globalMultiTouchActive = false;
}
} catch (_) {}
// No offset: canvases are pinned at top of reading area
// Always enable word double-tap listeners for loaded chapters
setTimeout(() => {
addWordClickListeners(tabId);
}, 50);
// Load annotations with instant visual feedback
loadAnnotationsInstant(tabId);
updateUndoRedoUI();
// Reset setup state after a short delay
setTimeout(() => {
setupInProgress = false;
lastSetupTabId = null;
}, 100);
}
// Helper to guarantee annotation canvases are rendered once layout stabilizes
const ANNOTATION_RENDER_MAX_RETRIES = 8;
function ensureAnnotationRenderReady(tabId, attempt = 0) {
const readingArea = document.getElementById(`readingArea${tabId}`);
const canvas = annotationCanvases[tabId];
const offscreen = annotationOffscreen[tabId];
// If DOM pieces are still missing, retry a few times
if (!readingArea || !canvas || !offscreen) {
if (attempt < ANNOTATION_RENDER_MAX_RETRIES) {
const delay = 100 + attempt * 80;
if (!window.SILENCE_DEBUG) console.log('[DEBUG] ensureAnnotationRenderReady waiting for DOM, attempt', attempt, 'delay', delay);
setTimeout(() => ensureAnnotationRenderReady(tabId, attempt + 1), delay);
}
return;
}
const hasSizes = (canvas.width > 0 && canvas.height > 0 && offscreen.width > 0 && offscreen.height > 0 && readingArea.clientHeight > 0);
if (!hasSizes && attempt < ANNOTATION_RENDER_MAX_RETRIES) {
const delay = 120 + attempt * 60;
if (!window.SILENCE_DEBUG) console.log('[DEBUG] ensureAnnotationRenderReady waiting for sizes, attempt', attempt, 'delay', delay, 'canvas', canvas.width, canvas.height, 'offscreen', offscreen.width, offscreen.height, 'ra', readingArea.clientHeight);
setTimeout(() => ensureAnnotationRenderReady(tabId, attempt + 1), delay);
return;
}
// Force redraw and visible sync once everything is ready
try { redrawAllStrokes(tabId); } catch (_) {}
try { updateVisibleCanvas(tabId); } catch (_) {}
}
// Instant annotation loading function
const ANNOTATION_LOAD_MAX_RETRIES = 6;
function loadAnnotationsInstant(tabId, attempt = 0) {
console.log('[DEBUG] loadAnnotationsInstant called for tabId:', tabId, 'attempt:', attempt);
// Ensure canvases exist; if not, retry once the DOM has settled
try { getOrCreateAnnotationCanvas(tabId); } catch (_) {}
const offscreen = annotationOffscreen[tabId];
if (!offscreen) {
if (attempt < ANNOTATION_LOAD_MAX_RETRIES) {
const delay = 120 + attempt * 80;
console.log('[DEBUG] No offscreen canvas for tabId:', tabId, '- retrying in', delay, 'ms');
setTimeout(() => loadAnnotationsInstant(tabId, attempt + 1), delay);
} else {
console.log('[DEBUG] Giving up annotation load retries for tabId:', tabId);
}
return;
}
// Wait for reading area to exist so redraw can target the correct scroll container
const readingArea = document.getElementById(`readingArea${tabId}`);
if (!readingArea) {
if (attempt < ANNOTATION_LOAD_MAX_RETRIES) {
const delay = 120 + attempt * 80;
console.log('[DEBUG] Reading area missing for tabId:', tabId, '- retrying in', delay, 'ms');
setTimeout(() => loadAnnotationsInstant(tabId, attempt + 1), delay);
}
return;
}
// CRITICAL FIX: Check if tab has a valid chapter before loading annotations
const tab = getTabById(tabId);
if (!tab || !tab.book || !tab.chapter) {
if (attempt < ANNOTATION_LOAD_MAX_RETRIES) {
const delay = 120 + attempt * 80;
console.log('[DEBUG] Tab does not have chapter loaded yet for tabId:', tabId, '- retrying in', delay, 'ms');
setTimeout(() => loadAnnotationsInstant(tabId, attempt + 1), delay);
} else {
console.log('[DEBUG] Tab still has no chapter after retries - annotations will load when chapter is loaded');
}
return;
}
const passageKey = getCurrentPassageKey();
// CRITICAL FIX: Validate passage key before loading
if (!passageKey || passageKey.includes('-null-') || passageKey.includes('--')) {
console.log('[DEBUG] Invalid passage key for tabId:', tabId, 'key:', passageKey, '- waiting for valid chapter');
if (attempt < ANNOTATION_LOAD_MAX_RETRIES) {
const delay = 120 + attempt * 80;
setTimeout(() => loadAnnotationsInstant(tabId, attempt + 1), delay);
}
return;
}
console.log('[DEBUG] Loading annotations instantly for passage:', passageKey);
// Load stroke-based annotations
loadAnnotations(tabId);
// Load and restore popups for this passage
loadPopupData();
setTimeout(() => {
restorePopups();
// After DOM/layout settle, force a visible redraw so annotations appear without scrolling
ensureAnnotationRenderReady(tabId);
}, 120); // Slightly longer delay to allow text render and canvas sizing
}
// Drawing logic
/*
* ── FOLLOWER BRIDGE ────────────────────────────────────────────────────────────────────────────
* The booth screen (index.html?follower=1, fed by follower-link.js) shows this app's picture in
* ProPresenter. It re-renders the page natively rather than receiving video, so the pen strokes have
* to travel as vectors and be drawn there with EXACTLY the maths used here — which is why this lives
* in app.js beside updateVisibleCanvas/draw/endDrawing instead of in the follower's own file. One
* implementation, so a future change to how a line is smoothed cannot silently desync the two screens.
*
* THE WIRE FORMAT IS CSS PIXELS. Internally a point is device pixels (css * devicePixelRatio) and the
* two machines have different DPRs — the stage TV is 2, a booth display is often 1 or 1.5. Publishing
* device pixels would put every stroke in the wrong place by that ratio. Converting once here, at the
* boundary, means neither side has to know the other's display.
*
* Y is DOCUMENT space for committed strokes (scroll-independent, matching strokeHistory) and VIEWPORT
* space for the in-progress stroke (matching the overlay canvas it is painted on).
*/
function __followerRound(n) { return Math.round(n * 10) / 10; } // 0.1px on screen — invisible, and ~40% smaller payloads
window.__followerBridge = {
// ---------------------------------------------------------------- stage side (read)
passageKey(tabId) {
try { return (typeof getPassageKeyForTabId === 'function' && getPassageKeyForTabId(tabId)) || getCurrentPassageKey(); }
catch (_) { return null; }
},
// Committed strokes for a passage, in CSS px.
strokes(passageKey) {
const dpr = window.devicePixelRatio || 1;
const list = (passageKey && strokeHistory[passageKey]) || [];
const out = [];
for (let i = 0; i < list.length; i++) {
const s = list[i], pts = s && s.points;
if (!pts || pts.length < 2) continue;
const p = new Array(pts.length);
for (let j = 0; j < pts.length; j++) p[j] = { x: __followerRound(pts[j].x / dpr), y: __followerRound(pts[j].y / dpr) };
// ts = the stroke's own timestamp: loadAnnotations merges by it, so the follower's copy must
// carry the SAME identity as the stage's or a merge would count one stroke twice.
out.push({ type: s.type || 'pen', color: s.color || '#ffff00', size: s.size || 4, inputType: s.inputType || 'mouse', ts: s.timestamp || 0, points: p });
}
return out;
},
// The in-progress stroke, in CSS px, viewport-relative. Read live by the publisher each frame.
live(tabId) {
try {
/*
* String(): drawingTabId can arrive as a STRING (it is read from a dataset attribute on some
* paths) while tab ids are numbers. A strict !== silently reported "not drawing" for every
* frame of every stroke, so live ink never streamed and only the committed stroke appeared.
*/
if (!drawing || String(drawingTabId) !== String(tabId)) return null;
const pts = currentStrokePoints;
if (!Array.isArray(pts) || pts.length < 2) return null;
const dpr = window.devicePixelRatio || 1;
const settings = (typeof getCurrentDrawingSettings === 'function') ? getCurrentDrawingSettings() : null;
const p = new Array(pts.length);
for (let j = 0; j < pts.length; j++) p[j] = { x: __followerRound(pts[j].x / dpr), y: __followerRound(pts[j].y / dpr) };
return {
tool: (typeof currentTool !== 'undefined') ? currentTool : 'pen',
color: settings ? settings.color : '#ffff00',
size: settings ? settings.size : 4,
alpha: settings ? settings.alpha : 1,
composite: settings ? settings.composite : 'source-over',
inputType: (window.currentDrawingInputType === 'touch') ? 'touch' : 'mouse',
points: p
};
} catch (_) { return null; }
},
// ---------------------------------------------------------------- follower side (write)
/*
* Replace a passage's committed strokes and repaint through the app's own renderer.
*
* The CSS-pixel original is KEPT (`_css`), not just the converted copy, because devicePixelRatio is
* not a constant: dragging the window to a display with different scaling, or an OS scaling change,
* alters it — and strokes converted at the old ratio would then sit at the wrong scale, with every
* underline drifting off its word. reapply() re-derives the device-pixel copy from the original, so
* a display change costs one repaint instead of silently misplacing every stroke on the program feed.
*/
_css: Object.create(null),
_dpr: 0,
/*
* NOTE THE KEY IS RE-DERIVED LOCALLY, and the stage's key is ignored.
*
* Bible passages key by book/chapter/translation, which is the same string on both machines — but
* every other tab keys by TAB ID (`graphics-annotations-
`, `whiteboard-annotations-`, …) and
* ids are timestamps, so they never match between two machines. Storing under the stage's key would
* put the strokes somewhere updateVisibleCanvas never looks, and ink would silently vanish on the
* graphics, maps, announcements, notes and whiteboard tabs while working fine on Bible tabs.
*/
setStrokes(tabId, _remoteKeyIgnored, cssStrokes) {
try {
const key = this.passageKey(tabId);
if (!key) return false;
this._css[key] = Array.isArray(cssStrokes) ? cssStrokes : [];
return this._convert(tabId, key);
} catch (_) { return false; }
},
_convert(tabId, passageKey) {
const dpr = window.devicePixelRatio || 1;
const list = this._css[passageKey] || [];
const out = new Array(list.length);
for (let i = 0; i < list.length; i++) {
const s = list[i], pts = s.points || [];
const p = new Array(pts.length);
for (let j = 0; j < pts.length; j++) p[j] = { x: pts[j].x * dpr, y: pts[j].y * dpr };
out[i] = { type: s.type, color: s.color, size: s.size, inputType: s.inputType, timestamp: s.ts || undefined, points: p };
}
strokeHistory[passageKey] = out;
this._dpr = dpr;
updateVisibleCanvas(tabId);
return true;
},
/*
* The follower's replacement for loadAnnotations. On the booth machine the ONLY truth about ink is
* what the stage published; its own localStorage must never be consulted or written. Otherwise a
* stroke the pastor undid on stage would be resurrected from the follower's storage on the next
* passage load, and a merge of "storage copy + freshly published copy" would draw every highlighter
* line twice (double alpha). Returns true when it handled the load.
*/
_followerLoad(tabId) {
try {
const key = this.passageKey(tabId);
if (!key) return true;
if (this._css[key]) return this._convert(tabId, key);
strokeHistory[key] = [];
updateVisibleCanvas(tabId);
return true;
} catch (_) { return true; }
},
/*
* Follower: if the canvas ever holds ink that is not the stage's published list (nothing should be
* able to create any, but this is the backstop), put the stage's list back. Cheap: two lengths.
*/
reassert(tabId) {
try {
const key = this.passageKey(tabId);
if (!key || !this._css[key]) return false;
const have = strokeHistory[key] ? strokeHistory[key].length : 0;
if (have === this._css[key].length) return false;
return this._convert(tabId, key);
} catch (_) { return false; }
},
/*
* Follower: the stage wiped EVERY passage (trash held). A passage that is not on screen never gets a
* snapshot, so its cached list would have come back — highlights the pastor had wiped — the next
* time the stage showed that passage. Drop the cache and clear every canvas; the stage's next
* snapshot (which now always carries the list on a passage change) is the only truth afterwards.
*/
clearAllInk(tabId) {
try {
this._css = Object.create(null);
Object.keys(strokeHistory).forEach(k => { strokeHistory[k] = []; });
Object.keys(strokeRedo).forEach(k => { strokeRedo[k] = []; });
const wipe = (c) => { try { const x = c.getContext('2d'); x.setTransform(1, 0, 0, 1, 0, 0); x.clearRect(0, 0, c.width, c.height); } catch (_) {} };
Object.keys(annotationOffscreen).forEach(id => { if (annotationOffscreen[id]) wipe(annotationOffscreen[id]); });
Object.keys(annotationCanvases).forEach(id => { if (annotationCanvases[id]) wipe(annotationCanvases[id]); });
Object.keys(annotationOverlayCanvases).forEach(id => { if (annotationOverlayCanvases[id]) wipe(annotationOverlayCanvases[id]); });
if (tabId != null) updateVisibleCanvas(tabId);
return true;
} catch (_) { return false; }
},
// Called by the follower when the display may have changed. No-op unless it actually did.
reapply(tabId) {
try {
const dpr = window.devicePixelRatio || 1;
const key = this.passageKey(tabId);
if (!key || !this._css[key] || dpr === this._dpr) return false;
return this._convert(tabId, key);
} catch (_) { return false; }
},
/*
* The image carousel (Graphics / Maps / Announcements). currentIndex and goTo live inside the
* carousel's own closure, so each one publishes this handle on its DOM element — see the
* `__followerCarousel` hook in renderTabContent. Read by the publisher, driven by the follower.
*/
carousel(tabId) {
try {
const ra = document.getElementById('readingArea' + tabId);
const el = ra && ra.querySelector('.announcements-carousel, .graphics-carousel');
return (el && el.__followerCarousel) || null;
} catch (_) { return null; }
},
// ---------------------------------------------------------------- the remaining tab bodies
// Prayer is a contenteditable div; the DOM is the truth (the tab field can lag a keystroke).
prayerHtml(tabId) {
try { const el = document.getElementById('prayerText' + tabId); return el ? el.innerHTML : null; } catch (_) { return null; }
},
setPrayerHtml(tabId, html) {
try {
const el = document.getElementById('prayerText' + tabId);
if (!el || typeof html !== 'string' || el.innerHTML === html) return false;
el.innerHTML = html;
const tab = window.bibleApp && window.bibleApp.tabs.find(t => t.id == tabId);
if (tab) tab.prayerContent = html;
return true;
} catch (_) { return false; }
},
// Ask: the transcript, re-rendered through the app's own renderer so it looks identical.
askMessages(tabId) {
try { const tab = window.bibleApp && window.bibleApp.tabs.find(t => t.id == tabId); return (tab && tab.askMessages) || []; } catch (_) { return []; }
},
setAskMessages(tabId, messages) {
try {
const tab = window.bibleApp && window.bibleApp.tabs.find(t => t.id == tabId);
if (!tab || !Array.isArray(messages)) return false;
tab.askMessages = messages;
renderAskHistory(tabId, messages);
return true;
} catch (_) { return false; }
},
/*
* Paint the in-progress stroke on the overlay canvas — the same canvas, compositing and curve maths
* draw() uses on the stage, so a line looks identical on both screens while it is being drawn.
* Erase is deliberately not previewed here: it composites destination-out against committed ink,
* and the committed snapshot that follows the stroke shows the true result a frame later.
*/
paintLive(tabId, live) {
try {
const overlay = annotationOverlayCanvases[tabId];
if (!overlay) return false;
const ctx = overlay.getContext('2d');
const dpr = window.devicePixelRatio || 1;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, overlay.width / dpr, overlay.height / dpr);
if (!live || !live.points || live.points.length < 2 || live.tool === 'erase') return true;
const pts = live.points;
ctx.save();
ctx.globalAlpha = live.alpha;
ctx.globalCompositeOperation = live.composite || 'source-over';
ctx.strokeStyle = live.color;
ctx.lineWidth = live.size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.shadowBlur = 0;
ctx.shadowColor = 'transparent';
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
if (live.inputType === 'touch') {
for (let i = 1; i < pts.length - 1; i++) {
ctx.quadraticCurveTo(pts[i].x, pts[i].y, (pts[i].x + pts[i + 1].x) / 2, (pts[i].y + pts[i + 1].y) / 2);
}
ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y);
} else {
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
}
ctx.stroke();
ctx.restore();
return true;
} catch (_) { return false; }
},
repaint(tabId) { try { updateVisibleCanvas(tabId); return true; } catch (_) { return false; } },
/*
* Canvas geometry self-check (the follower runs this every few hundred ms and before live ink).
*
* A canvas is only correct when its backing store is exactly its on-screen size × devicePixelRatio
* AND its on-screen size matches the reading area. Both were violated on the booth screen: the app
* boots inside the shell's frame at the booth window's size, the shell then resizes the frame to
* the stage's 1080×1920, and the canvases created before that resize kept their old backing store
* (and the overlay's CSS width is "100%", so its backing/on-screen ratio changed). Every stroke was
* then drawn scaled toward the top-left — live ink small and up-left, committed ink at yet another
* wrong place because the two canvases had drifted differently. The same thing happens on the TV
* itself when the window is resized or the display's scaling changes after start-up ("we have had
* this problem on the main app before"). Rebuilding from strokeHistory is cheap and exact, so on
* any mismatch the canvases are simply recreated at the current geometry and repainted.
*/
ensureCanvasGeometry(tabId) {
try {
const ra = document.getElementById('readingArea' + tabId);
if (!ra || !ra.clientWidth) return false;
const dpr = window.devicePixelRatio || 1;
const wantW = Math.max(1, Math.floor(ra.clientWidth * dpr));
const canvas = annotationCanvases[tabId], overlay = annotationOverlayCanvases[tabId];
if (!canvas || !overlay) return false;
const off = (c) => {
const cssW = Math.round(c.getBoundingClientRect().width);
return Math.abs(c.width - wantW) > 1 || Math.abs(cssW - ra.clientWidth) > 1 || Math.abs(c.width / Math.max(1, cssW) - dpr) > 0.02;
};
if (!off(canvas) && !off(overlay)) return false;
resetAnnotationCanvases(tabId);
getOrCreateAnnotationCanvas(tabId);
updateVisibleCanvas(tabId);
return true;
} catch (_) { return false; }
}
};
function startDrawing(e) {
// The follower screen only displays ink published by the stage; it never creates any of its own.
if (window.__followerMode) return;
console.log(`[DEBUG] ?? startDrawing called - globalMultiTouchActive: ${globalMultiTouchActive}, twoFingerScroll.active: ${twoFingerScroll.active}, currentTool: ${currentTool}`);
// Prevent drawing if 2-finger scroll is active
if (globalMultiTouchActive || (typeof twoFingerScroll !== 'undefined' && twoFingerScroll.active)) {
console.log('[DEBUG] ? PREVENTING DRAWING - multi-touch or 2-finger scroll is active');
return;
}
// Prevent accidental strokes immediately after a two-finger gesture ends,
// but allow override after a very short grace to reduce perceived delay
try {
const now = Date.now();
const sinceEnd = now - (lastMultiTouchEndMs || 0);
const suppressed = (typeof isDrawSuppressed === 'function' && isDrawSuppressed());
if (suppressed && sinceEnd < DRAW_SUPPRESS_OVERRIDE_MS) {
console.log('[DEBUG] ? PREVENTING DRAWING - suppression window active');
return;
}
} catch (_) {}
// Removed aggressive touch-action forcing here; scroll lock is managed dynamically
const tabId = getActiveTabId();
const activeTab = getTabById(tabId);
if (activeTab && (activeTab.mode === 'announcements' || activeTab.mode === 'graphics' || activeTab.mode === 'maps')) {
console.log('[DEBUG] Drawing blocked on', activeTab.mode, 'tab');
return;
}
const readingArea = document.getElementById(`readingArea${tabId}`);
const canvas = getOrCreateAnnotationCanvas(tabId);
console.log(`[DEBUG] ?? Drawing setup - tabId: ${tabId}, canvas: ${!!canvas}, readingArea: ${!!readingArea}`);
if (!canvas || !readingArea) {
console.log(`[DEBUG] ? DRAWING FAILED - missing canvas (${!!canvas}) or readingArea (${!!readingArea})`);
return;
}
// Default drawing logic for pen, highlight, and erase
if (currentTool !== 'pen' && currentTool !== 'highlight' && currentTool !== 'erase') {
console.log(`[DEBUG] ? DRAWING FAILED - invalid tool: ${currentTool}`);
return;
}
// Prevent text selection
e.preventDefault();
e.stopPropagation();
// Mark multi-touch suppression window to avoid accidental tool breaks
try { if (e.touches && e.touches.length > 1) return; } catch(_) {}
// Ensure eraser visual hides if user starts scroll with another finger
// Keep the eraser visible continuously while erasing (no auto-hide on scroll)
try { if (canvas) canvas.style.cursor = 'crosshair'; } catch(_) {}
// Identify input type and initialize per-stroke guards/metrics for scroll-cancel
try {
const inferredType = (e && typeof e.pointerType === 'string') ? e.pointerType : (e && e.touches ? 'touch' : 'mouse');
window.currentDrawingInputType = (inferredType === 'pen' || inferredType === 'touch') ? inferredType : 'mouse';
} catch(_) { window.currentDrawingInputType = 'mouse'; }
window._multiTouchDuringCurrentStroke = false;
window._cancelCurrentStroke = false;
window.strokeStartTimeMs = Date.now();
window.strokeTotalDistance = 0;
drawing = true;
drawingTabId = tabId;
isUserDrawing = true;
// Delay scroll lock slightly to let two-finger gestures begin smoothly
try { if (window._drawLockTimeoutId) clearTimeout(window._drawLockTimeoutId); } catch (_) {}
window._drawLockTimeoutId = setTimeout(() => {
try {
if (drawing && drawingTabId === tabId && !globalMultiTouchActive) {
lockScrollForDrawing(tabId);
}
} catch (_) {}
}, 24);
// Get initial point
const point = getPointerPosRelative(e, canvas, readingArea);
currentStrokePoints = [point];
lastPoint = point;
console.log(`[DEBUG] ? DRAWING STARTED - point: (${point.x}, ${point.y}), tool: ${currentTool}, canvas: ${canvas.width}x${canvas.height}, canvasStyle: ${canvas.style.width}x${canvas.style.height}`);
// Clear any legacy variables
highlighterPoints = [];
highlighterSnapshot = null;
// Removed start-dot to avoid stray dual-dot artifacts on very short strokes.
}
// (reverted) no per-stroke dirty rect and no RAF draw queue
// Helper: Draw a smooth stroke using quadratic B�zier curves
function drawSmoothStroke(ctx, points, dpr, scrollOffset = 0) {
if (!points || points.length < 2) return;
const scaledOffset = scrollOffset * dpr;
ctx.beginPath();
ctx.moveTo(points[0].x / dpr, (points[0].y + scaledOffset) / dpr);
for (let i = 1; i < points.length - 1; i++) {
const midX = (points[i].x + points[i + 1].x) / 2 / dpr;
const midY = (points[i].y + points[i + 1].y + 2 * scaledOffset) / 2 / dpr;
ctx.quadraticCurveTo(
points[i].x / dpr,
(points[i].y + scaledOffset) / dpr,
midX,
midY
);
}
// For the last segment, just line to the last point
ctx.lineTo(points[points.length - 1].x / dpr, (points[points.length - 1].y + scaledOffset) / dpr);
ctx.stroke();
}
// Helper: Interpolate points between two points if distance > maxDist
function interpolatePoints(p1, p2, maxDist = 3) {
const points = [];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= maxDist) return [];
const steps = Math.floor(dist / maxDist);
for (let i = 1; i < steps; i++) {
const t = i / steps;
points.push({
x: p1.x + dx * t,
y: p1.y + dy * t
});
}
return points;
}
// Helper: Resample a stroke to evenly spaced points
function resampleStroke(points, spacing = 2) {
if (!points || points.length < 2) return points;
const resampled = [points[0]];
let prev = points[0];
let distAccum = 0;
for (let i = 1; i < points.length; i++) {
let curr = points[i];
let dx = curr.x - prev.x;
let dy = curr.y - prev.y;
let dist = Math.sqrt(dx * dx + dy * dy);
if (distAccum + dist < spacing) {
distAccum += dist;
prev = curr;
continue;
}
let t = (spacing - distAccum) / dist;
while (distAccum + dist >= spacing) {
const nx = prev.x + t * dx;
const ny = prev.y + t * dy;
resampled.push({ x: nx, y: ny });
prev = { x: nx, y: ny };
dx = curr.x - prev.x;
dy = curr.y - prev.y;
dist = Math.sqrt(dx * dx + dy * dy);
t = spacing / (dist || 1);
distAccum = 0;
}
distAccum += dist;
prev = curr;
}
if (resampled.length < points.length) resampled.push(points[points.length - 1]);
return resampled;
}
// Simple, fast draw function that works smoothly on all devices
function draw(e) {
// During active draw, indicate crosshair to the primary pointer only
try {
if (currentTool === 'erase' || currentTool === 'pen' || currentTool === 'highlight') {
const c = annotationCanvases[drawingTabId];
if (c) c.style.cursor = 'crosshair';
}
} catch(_) {}
// If multiple touches occur mid-stroke, ignore updates to avoid corrupting tool state
try { if (e && e.touches && e.touches.length > 1) return; } catch(_) {}
if (!drawing || !drawingTabId) return;
const tabId = drawingTabId;
const readingArea = document.getElementById(`readingArea${tabId}`);
const canvas = annotationCanvases[tabId];
const offscreen = annotationOffscreen[tabId];
const overlayCanvas = annotationOverlayCanvases[tabId];
if (!canvas || !offscreen || !readingArea) return;
const dpr = window.devicePixelRatio || 1;
const settings = getCurrentDrawingSettings();
if (!settings) return;
// If two-finger scrolling is active or recently ended, do not draw
try {
if (globalMultiTouchActive || (typeof isDrawSuppressed === 'function' && isDrawSuppressed())) {
return;
}
} catch(_) {}
// Get pointer position
const point = getPointerPosRelative(e, canvas, readingArea);
// Accumulate total path distance for this stroke (used to cancel accidental marks)
try {
if (window.strokeTotalDistance != null && lastPoint) {
const dx = (point.x - lastPoint.x);
const dy = (point.y - lastPoint.y);
const inc = Math.sqrt(dx * dx + dy * dy);
if (isFinite(inc)) window.strokeTotalDistance += inc;
}
} catch(_) {}
// Only add point if it's different from the last point
if (!lastPoint || point.x !== lastPoint.x || point.y !== lastPoint.y) {
// If this is the very first move and there's only the initial point,
// push an additional identical point so the line renderer produces a
// visible segment without leaving two separate dots.
if (currentStrokePoints.length === 1) {
currentStrokePoints.push({ x: currentStrokePoints[0].x, y: currentStrokePoints[0].y });
}
currentStrokePoints.push(point);
if (currentTool === 'erase') {
// For eraser, draw directly on the offscreen canvas
const offscreenCtx = offscreen.getContext('2d');
offscreenCtx.save();
offscreenCtx.globalCompositeOperation = 'destination-out';
offscreenCtx.strokeStyle = '#000000';
offscreenCtx.lineWidth = settings.size;
offscreenCtx.lineCap = 'round';
offscreenCtx.lineJoin = 'round';
// Draw eraser stroke
offscreenCtx.beginPath();
const scrollOffsetY = (readingArea.scrollTop || 0) * dpr;
if (lastPoint) {
offscreenCtx.moveTo(lastPoint.x / dpr, (lastPoint.y + scrollOffsetY) / dpr);
} else {
offscreenCtx.moveTo(point.x / dpr, (point.y + scrollOffsetY) / dpr);
}
offscreenCtx.lineTo(point.x / dpr, (point.y + scrollOffsetY) / dpr);
offscreenCtx.stroke();
offscreenCtx.restore();
// Update visible canvas (throttled) to avoid heavy full-canvas copies each move
requestVisibleUpdate(tabId);
} else {
// For pen and highlighter, use overlay canvas for real-time feedback (original behavior)
const overlayCtx = overlayCanvas.getContext('2d');
overlayCtx.setTransform(1, 0, 0, 1, 0, 0);
overlayCtx.scale(dpr, dpr);
overlayCtx.clearRect(0, 0, overlayCanvas.width / dpr, overlayCanvas.height / dpr);
// Draw the current stroke on overlay
if (currentStrokePoints.length >= 2) {
overlayCtx.save();
overlayCtx.globalAlpha = settings.alpha;
overlayCtx.globalCompositeOperation = settings.composite;
overlayCtx.strokeStyle = settings.color;
overlayCtx.lineWidth = settings.size;
overlayCtx.lineCap = 'round';
overlayCtx.lineJoin = 'round';
// Remove shadow effects for all tools
overlayCtx.shadowBlur = 0;
overlayCtx.shadowColor = 'transparent';
overlayCtx.beginPath();
const scrollOffsetY = (readingArea.scrollTop || 0) * dpr;
// Guard: defer overlay until minimum movement to avoid visual micro-marks during scroll tests
try {
// Use total stroke distance instead of net displacement from start, so
// returning near the first point (closing a loop) does not suppress rendering.
const totalCssPx = (window.strokeTotalDistance || 0) / dpr;
if (totalCssPx < OVERLAY_MIN_DISTANCE_CSS) {
overlayCtx.restore();
lastPoint = point;
return; // don't render overlay yet
}
} catch (_) {}
overlayCtx.moveTo(
currentStrokePoints[0].x / dpr,
(currentStrokePoints[0].y) / dpr
);
// Use the input type detected at drawing start for consistency
const wasTouch = e.pointerType === 'touch';
if (wasTouch) {
// Touch: smooth quadratic curves
for (let i = 1; i < currentStrokePoints.length - 1; i++) {
const midX = (currentStrokePoints[i].x + currentStrokePoints[i + 1].x) / 2 / dpr;
const midY = (currentStrokePoints[i].y + currentStrokePoints[i + 1].y) / 2 / dpr;
overlayCtx.quadraticCurveTo(
currentStrokePoints[i].x / dpr,
(currentStrokePoints[i].y) / dpr,
midX,
midY
);
}
overlayCtx.lineTo(
currentStrokePoints[currentStrokePoints.length - 1].x / dpr,
(currentStrokePoints[currentStrokePoints.length - 1].y) / dpr
);
} else {
// Mouse: precise line segments
for (let i = 1; i < currentStrokePoints.length; i++) {
overlayCtx.lineTo(
currentStrokePoints[i].x / dpr,
(currentStrokePoints[i].y) / dpr
);
}
}
overlayCtx.stroke();
overlayCtx.restore();
}
}
lastPoint = point;
}
e.preventDefault();
}
// --- END DRAWING: Commit stroke to persistent canvas, clear overlay ---
function endDrawing(tabId) {
if (drawing && drawingTabId === tabId) {
console.log('[DEBUG] endDrawing called with', currentStrokePoints.length, 'points');
const dpr = window.devicePixelRatio || 1;
const readingArea = document.getElementById(`readingArea${tabId}`);
const offscreen = annotationOffscreen[tabId];
const settings = getCurrentDrawingSettings();
const overlayCanvas = annotationOverlayCanvases[tabId];
if (!offscreen || !readingArea) {
console.error('[ERROR] Missing required elements in endDrawing');
return;
}
// Check stroke count before adding
const passageKey = getCurrentPassageKey();
const beforeCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('[DEBUG] Before adding stroke - passage:', passageKey, 'stroke count:', beforeCount);
// Determine if this stroke should be committed (avoid accidental marks during scrolling)
let shouldCommit = currentStrokePoints.length >= 2;
try {
if ((window.currentDrawingInputType === 'touch')) {
const multiTouchCancel = (window._cancelCurrentStroke && window._multiTouchDuringCurrentStroke);
// Only cancel when a second finger was involved; do NOT require any hold or distance thresholds
if (multiTouchCancel) {
shouldCommit = false;
console.log('[DRAW GUARD] Cancelled touch stroke (multi-touch scroll detected)');
}
// Distance-based guard to prevent stray dots from tap/scroll jitter
try {
const totalDist = window.strokeTotalDistance || 0;
const dpr = window.devicePixelRatio || 1;
const totalCssPx = totalDist / dpr;
if (shouldCommit && totalCssPx < MIN_TOUCH_STROKE_DISTANCE_PX) {
shouldCommit = false;
console.log('[DRAW GUARD] Skipping short touch stroke (', totalCssPx.toFixed(2), 'px)');
}
} catch(_) {}
}
} catch(_) {}
// Commit the stroke to offscreen canvas
if (shouldCommit) {
const offscreenCtx = offscreen.getContext('2d');
offscreenCtx.setTransform(1, 0, 0, 1, 0, 0);
offscreenCtx.scale(dpr, dpr);
offscreenCtx.save();
offscreenCtx.globalAlpha = settings.alpha;
offscreenCtx.globalCompositeOperation = settings.composite;
offscreenCtx.strokeStyle = settings.color;
offscreenCtx.lineWidth = settings.size;
offscreenCtx.lineCap = 'round';
offscreenCtx.lineJoin = 'round';
// Remove shadow effects for all tools - use clean rendering
offscreenCtx.shadowBlur = 0;
offscreenCtx.shadowColor = 'transparent';
// Draw stroke to offscreen
offscreenCtx.beginPath();
const scrollOffsetY = (readingArea.scrollTop || 0) * dpr;
offscreenCtx.moveTo(currentStrokePoints[0].x / dpr, (currentStrokePoints[0].y + scrollOffsetY) / dpr);
// Use the input type detected at drawing start for consistency
const wasTouch = window.currentDrawingInputType === 'touch';
if (wasTouch) {
// Touch: smooth quadratic curves
for (let i = 1; i < currentStrokePoints.length - 1; i++) {
const midX = (currentStrokePoints[i].x + currentStrokePoints[i + 1].x) / 2 / dpr;
const midY = ((currentStrokePoints[i].y + scrollOffsetY) + (currentStrokePoints[i + 1].y + scrollOffsetY)) / 2 / dpr;
offscreenCtx.quadraticCurveTo(
currentStrokePoints[i].x / dpr,
(currentStrokePoints[i].y + scrollOffsetY) / dpr,
midX,
midY
);
}
offscreenCtx.lineTo(
currentStrokePoints[currentStrokePoints.length - 1].x / dpr,
(currentStrokePoints[currentStrokePoints.length - 1].y + scrollOffsetY) / dpr
);
} else {
// Mouse: precise line segments
for (let i = 1; i < currentStrokePoints.length; i++) {
offscreenCtx.lineTo(
currentStrokePoints[i].x / dpr,
(currentStrokePoints[i].y + scrollOffsetY) / dpr
);
}
}
offscreenCtx.stroke();
offscreenCtx.restore();
// Add stroke to history for undo/redo
if (passageKey) {
// Store points in offscreen-coordinate space (include scroll offset at draw time)
const scrollOffsetY = (readingArea.scrollTop || 0) * dpr;
const storedPoints = currentStrokePoints.map(p => ({ x: p.x, y: p.y + scrollOffsetY }));
addStroke(passageKey, {
type: currentTool,
color: settings.color,
size: settings.size,
points: storedPoints,
timestamp: Date.now(),
inputType: window.currentDrawingInputType || 'mouse'
});
// CRITICAL FIX: Explicitly save annotations immediately after adding stroke
// This ensures strokes persist even if translation switch happens
try {
saveAnnotations(tabId);
console.log('[DEBUG] *** Explicitly saved annotations after adding stroke, total strokes:', strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0);
} catch (e) {
console.error('[DEBUG] *** Error explicitly saving annotations:', e);
}
}
} else {
// Skipped commit; ensure nothing is persisted from this stroke
console.log('[DRAW GUARD] Stroke not committed to offscreen/history');
}
// Clear overlay canvas
if (overlayCanvas) {
const overlayCtx = overlayCanvas.getContext('2d');
overlayCtx.setTransform(1, 0, 0, 1, 0, 0);
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
}
// Reset drawing state
drawing = false;
drawingTabId = null;
lastPoint = null;
currentStrokePoints = [];
highlighterPoints = [];
window.currentDrawingInputType = null;
isUserDrawing = false;
window._multiTouchDuringCurrentStroke = false;
window._cancelCurrentStroke = false;
// Cancel any pending delayed scroll lock
try { if (window._drawLockTimeoutId) { clearTimeout(window._drawLockTimeoutId); window._drawLockTimeoutId = null; } } catch (_) {}
// Re-enable scrolling after drawing ends
unlockScrollForDrawing();
// Mark the time when drawing ended to prevent immediate canvas recreation
window.lastDrawingEndTime = Date.now();
console.log('[DEBUG] *** Drawing ended - set protection timestamp to prevent immediate canvas recreation');
// Force a full redraw from stroke history to guarantee persistence on long chapters
try {
if (!drawing) {
redrawAllStrokes(tabId);
}
} catch (e) {
console.warn('[DEBUG] redrawAllStrokes error after endDrawing:', e);
}
// Update visible canvas after full redraw (do it a few times to survive late layout/scroll events)
updateVisibleCanvas(tabId);
/*
* "Ink settled" counter for the phone remote's mirror.
*
* Between clearing the live overlay and repainting the committed canvas there is a moment when
* the stroke is on NEITHER — mirror that moment to the phone and the line blinks out and back at
* the end of every stroke. The phone therefore refuses to swap its local preview for a captured
* frame until this counter has moved past what it was when the stroke began.
*
* Bumped SYNCHRONOUSLY here, immediately after the visible canvas has been repainted with the
* committed stroke: everything above runs in one task, so the compositor never gets to publish
* the in-between state, and the first captured frame after this point is guaranteed to show the
* line. (A deferred bump would be wrong twice over: the compositor may produce no further frame
* to carry it, and the counter could then outrun the pixels.) Bumped whether or not the stroke
* was committed — a rejected stroke is equally "settled" and the phone must drop its preview.
*/
window.__inkVersion = (window.__inkVersion || 0) + 1;
try {
requestAnimationFrame(() => updateVisibleCanvas(tabId));
setTimeout(() => updateVisibleCanvas(tabId), 30);
setTimeout(() => updateVisibleCanvas(tabId), 90);
} catch (_) {}
}
}
// Global event listener to prevent text selection when drawing
document.addEventListener('selectstart', function(e) {
if (drawing) {
e.preventDefault();
return false;
}
});
document.addEventListener('mousedown', function(e) {
if (drawing) {
e.preventDefault();
return false;
}
});
// Safety net: ensure Bible drawing stays ready after global mousedown in case focus changed
try { window.addEventListener('mousedown', () => { try { ensureBibleDrawingReady(); } catch (_) {} }, { capture: true }); } catch (_) {}
// Helper to get pointer position relative to a canvas, accounting for scroll
function getPointerPosRelative(e, canvas, readingArea) {
let x, y;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
// Use current bounding rect without forcing layout reflow to reduce jank
const freshRect = rect;
if (e.touches && e.touches.length > 0) {
x = (e.touches[0].clientX - freshRect.left) * dpr;
y = (e.touches[0].clientY - freshRect.top) * dpr;
} else {
x = ((e.clientX !== undefined ? e.clientX : e.pageX) - freshRect.left) * dpr;
y = ((e.clientY !== undefined ? e.clientY : e.pageY) - freshRect.top) * dpr;
}
// PORTRAIT MODE FIX: Clamp coordinates to canvas bounds to prevent out-of-bounds drawing
const maxX = canvas.width;
const maxY = canvas.height;
x = Math.max(0, Math.min(x, maxX));
y = Math.max(0, Math.min(y, maxY));
return { x, y };
}
// Stroke-based save/load annotations
function saveAnnotations(tabId) {
if (window.__followerMode) return; // the follower is a display; it never persists ink of its own
console.log('[DEBUG] saveAnnotations called for tabId:', tabId);
const passageKey = getCurrentPassageKey();
if (!passageKey) {
console.log('[DEBUG] No passage key for tabId:', tabId);
return;
}
try {
const data = JSON.stringify(strokeHistory[passageKey] || []);
localStorage.setItem(passageKey + '-strokes', data);
console.log('[DEBUG] Successfully saved stroke annotations for passage:', passageKey);
} catch (e) {
console.log('[DEBUG] Failed to save stroke annotations:', e);
}
}
function loadAnnotations(tabId) {
// Follower screen: ink comes from the stage's feed, never from this machine's storage (see the bridge).
if (window.__followerMode && window.__followerBridge && window.__followerBridge._followerLoad(tabId)) return;
console.log('[DEBUG] *** LOADING ANNOTATIONS for tabId:', tabId);
const passageKey = getPassageKeyForTabId(tabId) || getCurrentPassageKey();
// CRITICAL FIX: Don't try to load annotations if passage key is invalid (e.g., null chapter)
if (!passageKey || passageKey.includes('-null-') || passageKey.includes('--')) {
console.log('[DEBUG] Invalid passage key for tabId:', tabId, 'key:', passageKey, '- skipping annotation load');
// Still initialize empty strokeHistory for this key if it doesn't exist
if (passageKey && !strokeHistory[passageKey]) {
strokeHistory[passageKey] = [];
}
return;
}
console.log('[DEBUG] *** LOADING stroke annotations for passage:', passageKey);
try {
const raw = localStorage.getItem(passageKey + '-strokes');
const currentStrokes = Array.isArray(strokeHistory[passageKey]) ? strokeHistory[passageKey] : [];
const currentCount = currentStrokes.length;
if (raw) {
const parsed = JSON.parse(raw);
const loadedStrokes = Array.isArray(parsed) ? parsed : [];
const loadedCount = loadedStrokes.length;
console.log('[DEBUG] *** FOUND', loadedCount, 'saved strokes in localStorage for passage:', passageKey, 'memory has:', currentCount);
// CRITICAL FIX: Merge strokes using timestamp as the unique identifier
// Timestamp is unique per stroke, so this prevents false deduplication
const merged = [];
const seenTimestamps = new Set();
const addStroke = (stroke) => {
if (!stroke) return;
// Use timestamp as the unique key - each stroke gets a unique timestamp when created
const timestamp = stroke.timestamp;
if (!timestamp) {
// If stroke has no timestamp, generate one and add it
stroke.timestamp = Date.now();
}
// CRITICAL: Only deduplicate if timestamp already exists
// This ensures we never lose strokes - each stroke has a unique timestamp
if (seenTimestamps.has(timestamp)) {
console.log('[DEBUG] *** Skipping duplicate stroke (same timestamp):', timestamp);
return;
}
seenTimestamps.add(timestamp);
merged.push(stroke);
};
// CRITICAL FIX: Add current in-memory strokes first (they're more recent and may include unsaved strokes)
// Then add loaded strokes (they're from storage and may have strokes not yet in memory)
currentStrokes.forEach(addStroke);
loadedStrokes.forEach(addStroke);
// CRITICAL: Always update strokeHistory with merged result
strokeHistory[passageKey] = merged;
// Always save merged result to ensure persistence
try {
localStorage.setItem(passageKey + '-strokes', JSON.stringify(merged));
console.log('[DEBUG] *** Saved merged strokes to localStorage');
} catch (e) {
console.error('[DEBUG] *** Failed to save merged strokes:', e);
}
console.log('[DEBUG] *** MERGED strokes - memory:', currentCount, 'storage:', loadedCount, 'merged:', merged.length);
redrawAllStrokes(tabId);
updateVisibleCanvas(tabId);
} else {
console.log('[DEBUG] *** NO SAVED STROKES found for passage:', passageKey);
// CRITICAL: Never clear existing strokes, always preserve in-memory strokes
if (!strokeHistory[passageKey]) {
strokeHistory[passageKey] = [];
} else if (strokeHistory[passageKey].length > 0) {
// Preserve in-memory strokes and save them
try {
localStorage.setItem(passageKey + '-strokes', JSON.stringify(strokeHistory[passageKey]));
console.log('[DEBUG] *** Saved in-memory strokes to localStorage (no existing storage)');
} catch (e) {
console.error('[DEBUG] *** Failed to save in-memory strokes:', e);
}
}
redrawAllStrokes(tabId);
updateVisibleCanvas(tabId);
}
} catch (e) {
console.error('[DEBUG] Failed to load stroke annotations:', e);
// CRITICAL: Never clear strokeHistory on error, preserve what we have
if (!strokeHistory[passageKey]) {
strokeHistory[passageKey] = [];
}
// Still try to redraw what we have
try {
redrawAllStrokes(tabId);
updateVisibleCanvas(tabId);
} catch (redrawError) {
console.error('[DEBUG] Failed to redraw strokes:', redrawError);
}
}
}
// --- ERASER & UNDO/REDO LOGIC ---
function getCurrentPassageKey() {
const tabId = getActiveTabId();
if (!tabId) {
console.log('[DEBUG] getCurrentPassageKey: No active tab ID');
return null;
}
const tab = window.bibleApp.tabs.find(t => t.id == tabId);
if (!tab) {
console.log('[DEBUG] getCurrentPassageKey: No tab found for ID:', tabId);
return null;
}
// Use mode to determine the appropriate storage key
if (tab.mode === 'media') {
// Drawings must be isolated per point
const pointIndex = (typeof tab.notesCurrentIndex === 'number' && tab.notesCurrentIndex >= 0)
? tab.notesCurrentIndex
: 0;
const key = `media-annotations-${tabId}-point-${pointIndex}`;
console.log('[DEBUG] getCurrentPassageKey (MEDIA):', key, 'tab:', tabId, 'pointIndex:', pointIndex);
return key;
}
if (tab.mode === 'whiteboard') {
const key = `whiteboard-annotations-${tabId}`;
console.log('[DEBUG] getCurrentPassageKey (WHITEBOARD):', key, 'tab:', tabId);
return key;
}
if (tab.mode === 'announcements') {
const key = `announcements-annotations-${tabId}`;
console.log('[DEBUG] getCurrentPassageKey (ANNOUNCEMENTS):', key, 'tab:', tabId);
return key;
}
if (tab.mode === 'graphics') {
const key = `graphics-annotations-${tabId}`;
console.log('[DEBUG] getCurrentPassageKey (GRAPHICS):', key, 'tab:', tabId);
return key;
}
const passageKey = getPassageKey(tab.book, tab.chapter, tab.verse, tab.verseEnd, tab.translation, tab.mode, !!tab.ppsync);
console.log('[DEBUG] getCurrentPassageKey:', passageKey, 'for tab:', tabId, 'mode:', tab.mode);
return passageKey;
}
// Helper: compute passage key for a specific tab ID (not just active tab)
function getPassageKeyForTabId(tabId) {
if (!tabId) return null;
const tab = window.bibleApp?.tabs?.find(t => t.id == tabId);
if (!tab) return null;
if (tab.mode === 'media') {
const pointIndex = (typeof tab.notesCurrentIndex === 'number' && tab.notesCurrentIndex >= 0)
? tab.notesCurrentIndex
: 0;
return `media-annotations-${tabId}-point-${pointIndex}`;
}
if (tab.mode === 'whiteboard') return `whiteboard-annotations-${tabId}`;
if (tab.mode === 'announcements') return `announcements-annotations-${tabId}`;
if (tab.mode === 'graphics') return `graphics-annotations-${tabId}`;
if (tab.mode === 'maps') return `maps-annotations-${tabId}`;
if (tab.mode === 'notes') return `notes-annotations-${tabId}`;
return getPassageKey(tab.book, tab.chapter, tab.verse, tab.verseEnd, tab.translation, tab.mode, !!tab.ppsync);
}
// Legacy function for backward compatibility - now uses new action system
function pushHistory(tabId) {
const action = createAction(ACTION_TYPES.DRAW, { tool: currentTool });
pushAction(action);
}
// Legacy function for backward compatibility - now uses new action system
function undoAnnotation(tabId) {
undoAction();
}
// Legacy function for backward compatibility - now uses new action system
function redoAnnotation(tabId) {
redoAction();
}
// Clear button initialization - will be called after DOM is ready
// Flag to prevent multiple initializations
let clearButtonInitialized = false;
function initializeClearButton() {
// Prevent multiple initializations that create duplicate event listeners
if (clearButtonInitialized) {
console.log('Clear button already initialized, skipping duplicate initialization');
return;
}
const resetBtn = document.getElementById('resetBtn');
if (!resetBtn) {
console.warn('Reset button not found in DOM');
return;
}
// Mark as initialized to prevent duplicates
clearButtonInitialized = true;
let clearHoldTimeout = null;
let isHoldingClear = false;
let progressStartTime = 0;
let progressAnimationId = null;
let isProcessingClear = false; // Prevent duplicate execution
let lastClearEventTime = 0; // Track last clear event to prevent rapid duplicates
const HOLD_DURATION = 1500; // 1.5 seconds as originally intended
const CLEAR_DEBOUNCE_MS = 1000; // Minimum time between clear operations
// Create progress indicator if it doesn't exist - using existing CSS design
let progressIndicator = document.getElementById('clearProgressIndicator');
if (!progressIndicator) {
progressIndicator = document.createElement('div');
progressIndicator.id = 'clearProgressIndicator';
// CRITICAL: Set explicit styles to prevent CSS conflicts
progressIndicator.style.cssText = `
position: absolute;
top: -2px;
left: 0;
height: 3px;
width: 0%;
background: #ff0000;
border-radius: 3px 3px 0 0;
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
transition: none;
pointer-events: none;
z-index: 1;
`;
resetBtn.style.position = 'relative';
resetBtn.style.overflow = 'visible'; // Allow thin bar to show above button
resetBtn.appendChild(progressIndicator);
console.log('Progress indicator created for clear button - classic thin bar design');
}
// Function to trigger the red flash effect
function triggerRedFlash() {
console.log('triggerRedFlash called');
// Force immediate style reset first
resetBtn.style.cssText = `
transition: none !important;
background: #2a2a2a !important;
color: #e0e0e0 !important;
border: 2px solid #404040 !important;
box-shadow: none !important;
`;
// Force a reflow
resetBtn.offsetHeight;
// Apply red flash effect
requestAnimationFrame(() => {
resetBtn.style.cssText = `
transition: none !important;
background: #ff4444 !important;
color: #ffffff !important;
border: 2px solid #ff6666 !important;
box-shadow: 0 0 8px #222, 0 0 15px rgba(255, 68, 68, 0.8) !important;
`;
console.log('Red flash applied');
// Reset to original styles after brief flash
setTimeout(() => {
resetBtn.style.cssText = `
transition: none !important;
background: #2a2a2a !important;
color: #e0e0e0 !important;
border: 2px solid #404040 !important;
box-shadow: none !important;
`;
console.log('Flash reset complete');
}, 150); // Brief 150ms red flash
});
}
// Unified hold start logic for both pointer and touch events
function startHoldLogic(eventType) {
// Prevent duplicate starts from multiple event types
if (clearHoldTimeout || isProcessingClear) {
console.log(`[${eventType}] Clear button hold start BLOCKED - already in progress`);
return;
}
console.log(`[${eventType}] Clear button hold started`);
// Set initial visual state for hold
resetBtn.style.cssText = `
transition: none !important;
background: #444 !important;
color: #fff !important;
box-shadow: 0 0 8px #222, 0 0 15px rgba(74, 158, 255, 0.6) !important;
transform: translateY(0) !important;
border-color: #4a9eff !important;
`;
progressStartTime = Date.now();
// FIXED: Only start progress animation after a delay to prevent it from showing on quick taps
const PROGRESS_DELAY = 300; // Don't show progress bar for first 300ms
clearHoldTimeout = setTimeout(() => {
isHoldingClear = true;
console.log(`[${eventType}] Triggering app-wide clear after 1.5s hold`);
}, HOLD_DURATION);
// Start progress animation after delay - now with better cross-platform support
setTimeout(() => {
if (!clearHoldTimeout) return; // Hold was cancelled
let lastProgress = 0;
const progressAnimation = () => {
if (!clearHoldTimeout) {
progressAnimationId = null;
return; // Animation stopped
}
const elapsed = Date.now() - progressStartTime;
const progress = Math.min((elapsed / HOLD_DURATION) * 100, 100);
// Only update if progress has changed significantly
if (progress - lastProgress >= 1) {
progressIndicator.style.width = progress + '%';
lastProgress = progress;
console.log(`[${eventType}] Progress updated to: ${progress.toFixed(1)}%`);
}
if (progress >= 100) {
progressAnimationId = null;
console.log(`[${eventType}] Progress reached 100% - triggering red flash`);
// Hide progress bar immediately
progressIndicator.style.width = '0%';
progressIndicator.style.opacity = '0';
progressIndicator.style.transition = 'opacity 0.15s ease';
// Force a reflow to ensure styles are applied
progressIndicator.offsetHeight;
// Trigger the red flash effect
console.log(`[${eventType}] Calling triggerRedFlash()`);
triggerRedFlash();
// Ensure the flash is visible
resetBtn.style.display = 'block';
resetBtn.style.visibility = 'visible';
resetBtn.style.opacity = '1';
} else {
progressAnimationId = requestAnimationFrame(progressAnimation);
}
};
progressAnimationId = requestAnimationFrame(progressAnimation);
}, PROGRESS_DELAY);
}
// Comprehensive button reset function
function resetButtonStyling() {
// Don't reset if we're in the middle of a hold
if (clearHoldTimeout) {
return;
}
// AGGRESSIVE reset of all possible button styling
resetBtn.style.cssText = `
background: #2a2a2a !important;
backgroundColor: #2a2a2a !important;
color: #e0e0e0 !important;
transform: none !important;
scale: none !important;
filter: none !important;
opacity: 1 !important;
boxShadow: none !important;
border: 2px solid #404040 !important;
borderColor: #404040 !important;
outline: none !important;
transition: all 0.15s ease !important;
`;
// Remove all classes that might cause stuck states
resetBtn.classList.remove('active', 'pressed', 'clicked', 'hover', 'focus', 'focus-visible', 'touched');
// Reset progress indicator
if (progressIndicator) {
progressIndicator.style.width = '0%';
progressIndicator.style.opacity = '';
progressIndicator.style.display = '';
}
// Force blur to remove focus
resetBtn.blur();
if (document.activeElement === resetBtn) {
document.body.focus();
}
// Force browser to recalculate styles
resetBtn.offsetHeight; // Trigger reflow
console.log('Button styling reset completed');
}
// Unified hold end logic for both pointer and touch events
function endHoldLogic(eventType) {
// FIXED: Aggressive debouncing to prevent ALL duplicate executions
const currentTime = Date.now();
if (isProcessingClear || (currentTime - lastClearEventTime) < CLEAR_DEBOUNCE_MS) {
console.log(`[${eventType}] Clear button hold end BLOCKED - too recent (${currentTime - lastClearEventTime}ms ago)`);
return;
}
console.log(`[${eventType}] Clear button hold ended, isHoldingClear:`, isHoldingClear);
// Set processing flag and timestamp immediately
isProcessingClear = true;
lastClearEventTime = currentTime;
// Clear the timeout - this is important to prevent late execution
clearTimeout(clearHoldTimeout);
clearHoldTimeout = null;
if (progressAnimationId) {
cancelAnimationFrame(progressAnimationId);
progressAnimationId = null;
}
// CRITICAL: Only execute clear operations if the full hold duration was completed
if (isHoldingClear) {
// Full hold duration was completed - clear all annotations across the entire app
pulseCompanionActionFeedback('clear_all');
clearAllAnnotationsAcrossApp();
console.log(`[${eventType}] Executed app-wide clear after full hold duration`);
isHoldingClear = false;
// Keep the active state until after the red flash
setTimeout(() => {
resetButtonStyling();
isProcessingClear = false;
}, 350); // Wait for red flash (150ms) + transition (200ms)
} else {
// Hold was not completed - check if we should do a quick tap clear
const tabId = getActiveTabId() || (window.bibleApp && window.bibleApp.activeTabId);
const activeTab = window.bibleApp?.tabs?.find(t => String(t.id) === String(tabId));
let progressPercent = 0;
// For prayer tabs, always clear on tap (not a hold) - explicitly clear prayer content
if (activeTab && activeTab.mode === 'prayer') {
pulseCompanionActionFeedback('clear');
clearAllAnnotations(tabId, true); // Pass true to explicitly clear prayer content
console.log(`[${eventType}] Executed prayer tab clear on tap`);
} else {
// For other tabs, only do quick tap clear if the progress was minimal (less than 20% of hold duration)
const elapsed = currentTime - progressStartTime;
progressPercent = (elapsed / HOLD_DURATION) * 100;
if (progressPercent < 20) {
// This was a quick tap - clear only current tab
pulseCompanionActionFeedback('clear');
clearAllAnnotations(tabId);
console.log(`[${eventType}] Executed current tab clear after quick tap (${progressPercent.toFixed(1)}% progress)`);
} else {
// Hold was started but not completed - don't clear anything
console.log(`[${eventType}] Hold cancelled at ${progressPercent.toFixed(1)}% - no clear action taken`);
}
}
// Reset progress bar only if hold wasn't completed (progress bar wasn't already hidden)
if (progressIndicator && progressPercent < 100) {
progressIndicator.style.width = '0%';
progressIndicator.style.opacity = '';
progressIndicator.style.transition = '';
}
// Reset immediately for incomplete holds
resetButtonStyling();
isProcessingClear = false;
}
}
// Unified hold cancel logic for both pointer and touch events
function cancelHoldLogic(eventType) {
console.log(`[${eventType}] Clear button hold cancelled`);
clearTimeout(clearHoldTimeout);
clearHoldTimeout = null;
isHoldingClear = false;
isProcessingClear = false; // Reset processing flag on cancel
if (progressAnimationId) {
cancelAnimationFrame(progressAnimationId);
progressAnimationId = null;
}
// Reset progress bar when hold is cancelled
if (progressIndicator) {
progressIndicator.style.width = '0%';
progressIndicator.style.opacity = '';
progressIndicator.style.transition = '';
}
// Only reset visual state if not already reset by touch events
if (eventType !== 'TOUCH') {
resetBtn.style.background = '';
resetBtn.style.color = '';
resetBtn.style.boxShadow = '';
resetBtn.style.transform = '';
resetBtn.style.borderColor = '';
resetBtn.style.transition = 'all 0.15s ease';
}
}
// Mouse/pointer events for desktop and modern devices with enhanced visual feedback
resetBtn.addEventListener('pointerdown', (e) => {
e.preventDefault();
// Add immediate blue glow visual feedback for pointer down - NO TRANSITIONS
resetBtn.style.cssText = `
transition: none !important;
background: #444 !important;
color: #fff !important;
box-shadow: 0 0 8px #222, 0 0 15px rgba(74, 158, 255, 0.6) !important;
transform: translateY(0) !important;
border-color: #4a9eff !important;
`;
startHoldLogic('POINTER');
});
resetBtn.addEventListener('pointerup', (e) => {
// For quick taps, reset visual state immediately
const currentTime = Date.now();
const elapsed = currentTime - progressStartTime;
const progressPercent = (elapsed / HOLD_DURATION) * 100;
if (progressPercent < 20) {
// Quick tap - reset immediately
resetBtn.style.cssText = `
transition: all 0.15s ease !important;
background: #2a2a2a !important;
color: #e0e0e0 !important;
border-color: #404040 !important;
box-shadow: none !important;
`;
}
// For hold completion, let the red flash handle it
endHoldLogic('POINTER');
});
resetBtn.addEventListener('pointerleave', (e) => {
if (!isHoldingClear) {
// Reset visual state if we haven't completed the hold
resetBtn.style.cssText = `
transition: all 0.15s ease !important;
background: #2a2a2a !important;
color: #e0e0e0 !important;
border-color: #404040 !important;
box-shadow: none !important;
`;
cancelHoldLogic('POINTER');
}
});
resetBtn.addEventListener('pointercancel', (e) => {
if (!isHoldingClear) {
// Reset visual state if we haven't completed the hold
resetBtn.style.cssText = `
transition: all 0.15s ease !important;
background: #2a2a2a !important;
color: #e0e0e0 !important;
border-color: #404040 !important;
box-shadow: none !important;
`;
cancelHoldLogic('POINTER');
}
});
// Touch events for tablets and mobile - with enhanced visual feedback
resetBtn.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
// Add immediate blue glow visual feedback for touch start - NO TRANSITIONS
resetBtn.style.cssText = `
transition: none !important;
background: #444 !important;
color: #fff !important;
box-shadow: 0 0 8px #222, 0 0 15px rgba(74, 158, 255, 0.6) !important;
transform: translateY(0) !important;
border-color: #4a9eff !important;
touch-action: manipulation !important;
`;
// Start hold logic for touch events
startHoldLogic('TOUCH');
});
resetBtn.addEventListener('touchend', (e) => {
e.preventDefault();
e.stopPropagation();
// Only reset visual state if we didn't complete the hold
if (!isHoldingClear) {
resetBtn.style.cssText = `
transition: all 0.15s ease !important;
background: #2a2a2a !important;
color: #e0e0e0 !important;
border-color: #404040 !important;
box-shadow: none !important;
`;
}
// Execute hold logic
endHoldLogic('TOUCH');
});
resetBtn.addEventListener('touchcancel', (e) => {
e.preventDefault();
e.stopPropagation();
// Only reset visual state if we didn't complete the hold
if (!isHoldingClear) {
resetBtn.style.cssText = `
transition: all 0.15s ease !important;
background: #2a2a2a !important;
color: #e0e0e0 !important;
border-color: #404040 !important;
box-shadow: none !important;
`;
}
// Cancel hold logic
cancelHoldLogic('TOUCH');
});
// Mouse events for immediate visual feedback (same as undo/redo buttons)
resetBtn.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
// Add immediate blue glow visual feedback for mouse down - NO TRANSITIONS
resetBtn.style.transition = 'none';
resetBtn.style.background = '#444';
resetBtn.style.color = '#fff';
resetBtn.style.boxShadow = '0 0 8px #222, 0 0 15px rgba(74, 158, 255, 0.6)';
resetBtn.style.transform = 'translateY(0)';
resetBtn.style.borderColor = '#4a9eff';
// Start hold logic for mouse events (fallback for older browsers)
if (!window.PointerEvent) {
startHoldLogic('MOUSE');
}
});
resetBtn.addEventListener('mouseup', (e) => {
if (!window.PointerEvent) {
endHoldLogic('MOUSE');
} else {
// Reset visual state immediately for mouse up (same as undo/redo buttons)
resetBtn.style.background = '';
resetBtn.style.color = '';
resetBtn.style.boxShadow = '';
resetBtn.style.transform = '';
resetBtn.style.borderColor = '';
resetBtn.style.transition = 'all 0.15s ease';
}
});
resetBtn.addEventListener('mouseleave', (e) => {
if (!window.PointerEvent) {
cancelHoldLogic('MOUSE');
}
});
console.log('Clear button initialized with 1.5s hold functionality and cross-platform support');
}
// Expose the clear functions so the optional quick-dock clear button can run the exact same logic.
try {
window.clearAllAnnotations = clearAllAnnotations;
window.clearAllAnnotationsAcrossApp = clearAllAnnotationsAcrossApp;
} catch (_) {}
// Function to clear all annotations across the entire app
function clearAllAnnotationsAcrossApp() {
// Store all cleared data for undo functionality
const clearedData = {
strokeHistory: {},
localStorage: {}
};
// Store stroke history
Object.keys(strokeHistory).forEach(key => {
if (strokeHistory[key] && strokeHistory[key].length > 0) {
clearedData.strokeHistory[key] = [...strokeHistory[key]];
}
});
// Store localStorage data
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.endsWith('-strokes')) {
clearedData.localStorage[key] = localStorage.getItem(key);
}
}
// Create action for clear all
const action = createAction(ACTION_TYPES.CLEAR_ALL, {
clearedData: clearedData
});
// FIXED: Execute the clear operation FIRST, then add to history
// This prevents the double-execution issue
// Clear all canvases
Object.keys(annotationCanvases).forEach(tabId => {
const canvas = annotationCanvases[tabId];
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
});
// Clear all offscreen canvases
Object.keys(annotationOffscreen).forEach(tabId => {
const offscreen = annotationOffscreen[tabId];
if (offscreen) {
const ctx = offscreen.getContext('2d');
ctx.clearRect(0, 0, offscreen.width, offscreen.height);
}
});
// Clear all localStorage stroke data (but preserve prayer content)
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.endsWith('-strokes')) {
keysToRemove.push(key);
}
// DO NOT remove prayer-content keys - preserve prayer text
}
keysToRemove.forEach(key => localStorage.removeItem(key));
// Clear all stroke history and redo stacks
Object.keys(strokeHistory).forEach(key => {
strokeHistory[key] = [];
});
Object.keys(strokeRedo).forEach(key => {
strokeRedo[key] = [];
});
// Clear all popup data across the app
const popupKeysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.endsWith('-popups')) {
popupKeysToRemove.push(key);
}
}
popupKeysToRemove.forEach(key => localStorage.removeItem(key));
// Clear popup history object
Object.keys(popupHistory).forEach(key => {
popupHistory[key] = [];
});
// Close all active popups
closeWordPopup();
// NOW add the action to history after the operation is complete
pushAction(action);
// Passages not on screen never get a snapshot: tell the follower and the relay to drop every list now.
try { if (window.__followerPublish) window.__followerPublish.clearAll(); } catch (_) {}
console.log('Cleared all annotations across the entire app');
}
// When page loads, update undo/redo UI
window.addEventListener('DOMContentLoaded', updateUndoRedoUI);
// Clear all annotations for the current tab
// clearPrayerContent: if true and tab is prayer mode, clears prayer content. Otherwise preserves it.
function clearAllAnnotations(tabId, clearPrayerContent = false) {
const activeTab = window.bibleApp?.tabs?.find(t => String(t.id) === String(tabId));
if (activeTab && activeTab.mode === 'media') {
// MEDIA MODE: clear media strokes for this tab
try {
activeTab.mediaStrokes = [];
if (window.localMediaHandler && typeof window.localMediaHandler.restoreMediaStrokes === 'function') {
window.localMediaHandler.restoreMediaStrokes(tabId);
}
console.log('[MEDIA] Cleared media strokes for tab:', tabId);
updateUndoRedoUI();
return;
} catch (e) { console.warn('[MEDIA] Clear media strokes failed:', e); }
}
/*
* A trash TAP clears this tab — and that has to include the word-popup cards sitting on it. Until now
* the only ways to remove a mark from a card were the eraser or HOLDING the trash (which wipes every
* tab), so a tap left the card annotated while the text behind it came clean.
* Only for the tab on screen: the popup store is keyed off the active passage.
*/
try {
if (window.popupDraw && typeof window.popupDraw.clearAllForPassage === 'function' &&
String(tabId) === String(window.bibleApp && window.bibleApp.activeTabId)) {
window.popupDraw.clearAllForPassage();
}
} catch (_) {}
// PRAYER MODE: clear prayer content ONLY when explicitly requested (trash button tap on prayer tab)
if (activeTab && activeTab.mode === 'prayer') {
if (clearPrayerContent) {
// Only clear prayer content when explicitly requested from trash button
try {
// Cancel any pending save to prevent stale content from reappearing
try {
if (activeTab._prayerSaveTimeout) {
clearTimeout(activeTab._prayerSaveTimeout);
activeTab._prayerSaveTimeout = null;
}
} catch (_) {}
// Clear all stored prayer content (tab-specific + primary) first
try {
clearPrayerContentStorage(tabId);
} catch (e) {
console.warn('[PRAYER] Failed to clear localStorage:', e);
}
// Clear prayer content from tab object - CRITICAL: must be empty string, not undefined
activeTab.prayerContent = '';
// Clear the prayer text element - try multiple ways to find it
const prayerTextEl = document.getElementById(`prayerText${tabId}`);
if (prayerTextEl) {
// CRITICAL: Remove input event listeners temporarily to prevent them from saving content back
// We'll manually update everything without triggering the input handler
// Remove all child nodes to ensure complete clearing
while (prayerTextEl.firstChild) {
prayerTextEl.removeChild(prayerTextEl.firstChild);
}
// Also clear innerHTML and textContent as backup
prayerTextEl.innerHTML = '';
prayerTextEl.textContent = '';
// Clear any text nodes that might remain
if (prayerTextEl.childNodes.length > 0) {
prayerTextEl.childNodes.forEach(node => node.remove());
}
// CRITICAL: Update tab.prayerContent BEFORE any potential restoration
activeTab.prayerContent = '';
// CRITICAL: Save empty state to localStorage immediately
persistPrayerContent(tabId, '', true);
// Force a reflow to ensure the UI updates
prayerTextEl.offsetHeight;
// Now manually trigger the input handler to sync state, but only after we've cleared everything
// Get the current innerHTML (should be empty) and manually update tab and localStorage
const currentContent = prayerTextEl.innerHTML || '';
activeTab.prayerContent = currentContent;
persistPrayerContent(tabId, currentContent, true);
// Verify it's actually cleared
const verifyContent = prayerTextEl.innerHTML.trim();
const verifyStorage = localStorage.getItem(`prayer-content-${tabId}`);
const verifyPrimary = localStorage.getItem(PRAYER_CONTENT_PRIMARY_KEY);
const verifyTabContent = activeTab.prayerContent;
console.log('[PRAYER] Cleared prayer content for tab:', tabId,
'Element found:', !!prayerTextEl,
'Element content after clear:', verifyContent,
'Storage after clear:', verifyStorage,
'Primary storage after clear:', verifyPrimary,
'Tab.prayerContent after clear:', verifyTabContent);
if (verifyContent || (verifyStorage && verifyStorage.trim()) || (verifyPrimary && verifyPrimary.trim()) || (verifyTabContent && verifyTabContent.trim())) {
console.error('[PRAYER] WARNING: Content was not fully cleared! Element:', verifyContent, 'Storage:', verifyStorage, 'Primary:', verifyPrimary, 'Tab:', verifyTabContent);
// Force clear again if verification failed
prayerTextEl.innerHTML = '';
activeTab.prayerContent = '';
clearPrayerContentStorage(tabId);
}
} else {
console.warn('[PRAYER] Prayer text element not found for tab:', tabId, 'Available tabs:', window.bibleApp?.tabs?.map(t => ({ id: t.id, mode: t.mode })));
}
updateUndoRedoUI();
return;
} catch (e) {
console.warn('[PRAYER] Clear prayer content failed:', e);
// Fall through to normal clear if prayer clear fails
}
} else {
// Don't clear prayer content - just return without doing anything
console.log('[PRAYER] Preserving prayer content for tab:', tabId);
return;
}
}
const passageKey = getCurrentPassageKey();
// Store cleared strokes for undo functionality
const clearedStrokes = passageKey && strokeHistory[passageKey] ? [...strokeHistory[passageKey]] : [];
// Create action for clear
const action = createAction(ACTION_TYPES.CLEAR, {
tabId: tabId,
passageKey: passageKey,
clearedStrokes: clearedStrokes
});
// FIXED: Execute the clear operation FIRST, then add to history
// This prevents the double-execution issue
const canvas = annotationCanvases[tabId];
const offscreen = annotationOffscreen[tabId];
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
if (offscreen) {
const ctx = offscreen.getContext('2d');
ctx.clearRect(0, 0, offscreen.width, offscreen.height);
}
// Remove from localStorage using passage key
if (passageKey) {
localStorage.removeItem(passageKey + '-strokes');
// Clear the stroke history for this passage
if (strokeHistory[passageKey]) {
strokeHistory[passageKey] = [];
}
if (strokeRedo[passageKey]) {
strokeRedo[passageKey] = [];
}
// Clear popup data for this passage
clearPopupData();
}
// NOW add the action to history after the operation is complete
pushAction(action);
console.log('Cleared annotations for tab:', tabId);
}
// --- BEGIN: Enhanced 2-Finger Scrolling System ---
// Global scroll configuration (default: enforce two-finger scrolling everywhere)
window.scrollConfig = window.scrollConfig || { enforceTwoFingerEverywhere: true };
// Global toggle for passage motion blur during scroll (disable to reduce jank)
window.motionBlurEnabled = false;
// Kinetic scrolling tuning: friction-based momentum for Apple-like feel
const KINETIC_MIN_VELOCITY = 0.10; // px/ms (~100 px/s) required to start momentum
const KINETIC_MIN_TRAVEL_PX = 20; // minimal predicted travel to start
const KINETIC_TAU_MS = 520; // time constant for exponential velocity decay (ms)
const KINETIC_STOP_VELOCITY = 0.02; // px/ms threshold to stop momentum
// Flick gating: only start momentum on deliberate flicks, not normal scrolls
const FLICK_MIN_DISTANCE_PX = 55; // minimum net gesture travel (center point space)
const FLICK_MIN_VELOCITY = 0.18; // px/ms threshold near lift to qualify as a flick
const FLICK_MAX_DURATION_MS = 420; // max gesture time to qualify as a flick
// Boost factor for initial kinetic velocity to increase throw distance
const KINETIC_VELOCITY_BOOST = 1.25;
// Minimum movement before showing any touch stroke overlay (CSS px)
const OVERLAY_MIN_DISTANCE_CSS = 3;
// Two-finger manual pan speed multiplier (2x requested)
const TWO_FINGER_MANUAL_SPEED_MULTIPLIER = 1.5;
// Minimum movement before a touch stroke is considered valid (in CSS pixels)
const MIN_TOUCH_STROKE_DISTANCE_PX = 6;
let twoFingerScroll = {
active: false,
startY: 0,
startScrollTop: 0,
scrollTarget: null,
animationId: null,
// Momentum state
lastY: 0,
lastTime: 0,
velocityY: 0, // px/ms
kineticId: null,
kineticActive: false,
activeTabId: null,
// Smoothed blur value for nicer fade-in while moving
blurSmoothed: 0,
// Flick detection
gestureStartTimeMs: 0,
totalAbsDistance: 0,
netDistance: 0,
lastMotionTs: 0,
// Fractional visual offset applied via CSS transform for sub-pixel smoothness
visualResidual: 0,
// Pending scroll position to sync on lift-off to avoid last-frame jumps
_pendingScrollTop: null,
_pendingTarget: null
};
// Global multi-touch blocker - set IMMEDIATELY on any multi-touch
let globalMultiTouchActive = false;
// Suppress word info popups during multi-touch scrolling and for a short grace
// period after, to avoid accidental openings while scrolling.
let wordInfoSuppressUntil = 0;
function isWordInfoSuppressed() {
return globalMultiTouchActive || Date.now() < wordInfoSuppressUntil;
}
/*
* Gesture guards for tool-wheel.js (the two-finger tap that opens the radial tool selector). Two fingers
* landing together are neither a word tap nor the start of a stroke; the wheel asks this module to say
* so, the same way the two-finger scroll below protects itself.
*/
window.__gestureGuards = {
suppressWordInfo(ms) { wordInfoSuppressUntil = Math.max(wordInfoSuppressUntil, Date.now() + (ms > 0 ? ms : 600)); },
// The first finger may already have begun a stroke (one point under the fingertip): discard it, as the
// two-finger scroll does when a second finger moves. endDrawing skips the commit when both flags are set.
cancelStroke() {
try {
if (!drawing) return false;
window._multiTouchDuringCurrentStroke = true;
window._cancelCurrentStroke = true;
endDrawing(drawingTabId);
return true;
} catch (_) { return false; }
},
get drawing() { return !!drawing; },
get multiTouch() { return !!globalMultiTouchActive || !!(twoFingerScroll && twoFingerScroll.active); }
};
// Drawing suppression window to avoid stray marks immediately after multi-touch
let drawSuppressUntil = 0;
// Timestamp when the last multi-touch gesture ended
let lastMultiTouchEndMs = 0;
// Allow drawing after this many ms from last multi-touch even if suppression window remains
const DRAW_SUPPRESS_OVERRIDE_MS = 60;
function isDrawSuppressed() {
try { return Date.now() < drawSuppressUntil; } catch (_) { return false; }
}
// --- Three-finger tool swipe helper ---
function changeToolByStep(step) {
try {
const order = ['pen', 'highlight', 'erase', 'select'];
const current = window.currentTool || currentTool || 'pen';
const idx = Math.max(0, order.indexOf(String(current)));
const nextIdx = (idx + step + order.length) % order.length;
const next = order[nextIdx];
if (!next || next === current) return;
if (window.forceSetDrawingTool) { window.forceSetDrawingTool(next); return; }
} catch (_) {}
}
// Restricted tool change for three-finger swipe: only pen <-> highlight
function changeToolPenHighlighter(step) {
try {
const order = ['pen', 'highlight'];
const current = window.currentTool || currentTool || 'pen';
let next;
if (!order.includes(current)) {
// Enter the pair domain deterministically
next = 'pen';
} else {
const idx = order.indexOf(current);
const nextIdx = (idx + (step >= 0 ? 1 : -1) + order.length) % order.length;
next = order[nextIdx];
}
if (!next || next === current) return;
if (window.forceSetDrawingTool) { window.forceSetDrawingTool(next); return; }
} catch (_) {}
}
// Three-finger tool gesture state
let threeFingerToolGestureActive = false;
let threeFingerStartX = 0;
let threeFingerBaseX = 0;
let threeFingerLastX = 0;
let threeFingerStartY = 0;
let threeFingerBaseY = 0;
let threeFingerLastY = 0;
let threeFingerStartTimeMs = 0;
const THREE_FINGER_SWIPE_THRESHOLD_PX = 140;
const THREE_FINGER_NAV_THRESHOLD_PX = 120; // vertical threshold to trigger 5-verse jump
const THREE_FINGER_AXIS_RATIO = 1.25; // require vertical to dominate to avoid accidental tool toggles
let _threeFingerMoveHandler = null;
let _threeFingerLock = null;
let threeFingerChangedThisGesture = false; // for horizontal tool toggle
let threeFingerNavIntent = null; // 'up' | 'down' | null
let threeFingerNavPreviewed = false;
function _getActiveNavButtons() {
try {
const tabId = window.bibleApp?.activeTabId;
if (tabId == null) return { up: null, down: null };
const header = document.getElementById(`chapterHeader${tabId}`);
if (!header) return { up: null, down: null };
const up = header.querySelector('.header-nav-btn[data-action="scroll-up-5"]');
const down = header.querySelector('.header-nav-btn[data-action="scroll-down-5"]');
return { up, down };
} catch (_) { return { up: null, down: null }; }
}
function _setNavPreview(direction, intensity = 1) { /* no visual feedback */ }
function _clearNavPreview() { /* no visual feedback */ }
function _fireNavAction(direction) {
try {
const activeTabId = window.bibleApp?.activeTabId;
if (!activeTabId) return;
const tab = window.bibleApp?.tabs?.find(t => t.id === activeTabId);
if (!tab) return;
if (tab.verseEnd !== null) return; // only for full-chapter mode, matching original behavior
const ra = document.getElementById(`readingArea${tab.id}`);
const btEl = document.getElementById(`bibleText${tab.id}`);
if (!ra || !btEl) return;
let headerOffset = 0;
try {
const header = document.getElementById(`chapterHeader${tab.id}`);
if (header && header.style && header.style.display !== 'none') {
const hRect = header.getBoundingClientRect();
headerOffset = Math.max(0, Math.round(hRect.height));
}
} catch (_) {}
const targetTop = ra.getBoundingClientRect().top + headerOffset + 6;
let currentVerse = 1; let bestDelta = Infinity;
const verses = btEl.querySelectorAll('.verse[data-verse]');
verses.forEach(el => {
const r = el.getBoundingClientRect();
const v = parseInt(el.getAttribute('data-verse') || '0', 10);
if (!isNaN(v)) {
const d = Math.abs(r.top - targetTop);
if (d < bestDelta) { bestDelta = d; currentVerse = v; }
}
});
const maxVerses = (bibleStructure[tab.book] && bibleStructure[tab.book][tab.chapter - 1]) || verses.length || 1;
const delta = direction === 'up' ? -5 : (direction === 'down' ? 5 : 0);
if (delta === 0) return;
const dest = Math.max(1, Math.min(maxVerses, currentVerse + delta));
tab._scrollToVerse = dest;
try {
if (typeof window.bibleApp?.autoScrollToSelectedVerse === 'function') {
window.bibleApp.autoScrollToSelectedVerse(tab.id);
} else if (typeof window.bibleApp?.autoScrollToSelectedVerse === 'undefined' && typeof this?.autoScrollToSelectedVerse === 'function') {
// Fallback if called within class context
this.autoScrollToSelectedVerse(tab.id);
}
} catch (_) {}
} catch (_) {}
}
function lockForThreeFingerGesture() {
try {
if (_threeFingerLock && _threeFingerLock.active) return;
const prevHtml = {
touchAction: document.documentElement.style.touchAction,
overscrollBehavior: document.documentElement.style.overscrollBehavior,
overflow: document.documentElement.style.overflow
};
const prevBody = {
touchAction: document.body.style.touchAction,
overscrollBehavior: document.body.style.overscrollBehavior,
overflow: document.body.style.overflow
};
document.documentElement.style.touchAction = 'none';
document.documentElement.style.overscrollBehavior = 'contain';
document.documentElement.style.overflow = 'hidden';
document.body.style.touchAction = 'none';
document.body.style.overscrollBehavior = 'contain';
document.body.style.overflow = 'hidden';
const prevent = (ev) => { try { ev.preventDefault(); ev.stopPropagation(); } catch(_) {} };
window.addEventListener('touchmove', prevent, { passive: false, capture: true });
window.addEventListener('wheel', prevent, { passive: false, capture: true });
_threeFingerLock = { active: true, prevHtml, prevBody, prevent };
} catch(_) {}
}
function unlockForThreeFingerGesture() {
try {
if (!_threeFingerLock || !_threeFingerLock.active) return;
const L = _threeFingerLock;
document.documentElement.style.touchAction = L.prevHtml.touchAction || '';
document.documentElement.style.overscrollBehavior = L.prevHtml.overscrollBehavior || '';
document.documentElement.style.overflow = L.prevHtml.overflow || '';
document.body.style.touchAction = L.prevBody.touchAction || '';
document.body.style.overscrollBehavior = L.prevBody.overscrollBehavior || '';
document.body.style.overflow = L.prevBody.overflow || '';
try {
window.removeEventListener('touchmove', L.prevent, { capture: true });
window.removeEventListener('wheel', L.prevent, { capture: true });
} catch(_) {}
_threeFingerLock = { active: false };
} catch(_) {}
}
// --- Motion blur helpers for passage scrolling ---
function setReadingAreaBlur(tabId, blurPx) {
try {
if (!window.motionBlurEnabled) {
const readingArea = document.querySelector(`#readingArea${tabId}`) || document.querySelector('.reading-area');
const bibleText = readingArea && readingArea.querySelector ? readingArea.querySelector('.bible-text') : null;
if (bibleText) {
bibleText.style.transition = '';
bibleText.style.filter = '';
}
return;
}
const readingArea = document.querySelector(`#readingArea${tabId}`) || document.querySelector('.reading-area');
if (!readingArea) return;
const bibleText = readingArea.querySelector('.bible-text');
if (!bibleText) return;
if (blurPx > 0) {
bibleText.style.filter = `blur(${blurPx.toFixed(2)}px)`;
} else {
bibleText.style.filter = '';
}
} catch (_) {}
}
// Apply a small visual translateY to bible text and annotation canvases to achieve sub-pixel smoothness
function applyVisualScrollResidual(tabId, residualPx) {
try {
twoFingerScroll.visualResidual = residualPx || 0;
if (tabId == null) return;
const readingArea = document.querySelector(`#readingArea${tabId}`);
if (!readingArea) return;
const isReadingArea = true;
if (!isReadingArea) return;
const bibleText = document.getElementById(`bibleText${tabId}`);
const overlay = annotationOverlayCanvases ? annotationOverlayCanvases[tabId] : null;
const canvas = annotationCanvases ? annotationCanvases[tabId] : null;
const t = `translateY(${-residualPx}px)`;
if (bibleText) {
bibleText.style.transform = t;
bibleText.style.willChange = 'transform';
}
if (overlay) {
overlay.style.transform = t;
overlay.style.willChange = 'transform';
}
if (canvas) {
canvas.style.transform = t;
canvas.style.willChange = 'transform';
}
} catch(_) {}
}
function clearVisualScrollResidual(tabId) {
try {
twoFingerScroll.visualResidual = 0;
if (tabId == null) return;
const readingArea = document.querySelector(`#readingArea${tabId}`);
if (!readingArea) return;
const bibleText = document.getElementById(`bibleText${tabId}`);
const overlay = annotationOverlayCanvases ? annotationOverlayCanvases[tabId] : null;
const canvas = annotationCanvases ? annotationCanvases[tabId] : null;
[bibleText, overlay, canvas].forEach(el => {
if (el) {
el.style.transform = '';
el.style.willChange = '';
}
});
} catch(_) {}
}
function fadeReadingAreaBlur(tabId, durationMs = 200) {
try {
const readingArea = document.querySelector(`#readingArea${tabId}`) || document.querySelector('.reading-area');
if (!readingArea) return;
const bibleText = readingArea.querySelector('.bible-text');
if (!bibleText) return;
if (!window.motionBlurEnabled) {
bibleText.style.transition = '';
bibleText.style.filter = '';
return;
}
bibleText.style.transition = `filter ${durationMs}ms ease-out`;
requestAnimationFrame(() => {
bibleText.style.filter = 'blur(0px)';
setTimeout(() => {
bibleText.style.transition = '';
bibleText.style.filter = '';
}, durationMs + 20);
});
} catch (_) {}
}
// Enhanced scroll target detection
function findScrollableTarget(startElement) {
// First: walk up from the start element to find the nearest scrollable ancestor
try {
let el = startElement && startElement.nodeType === 1 ? startElement : null;
while (el && el !== document.body) {
const style = window.getComputedStyle(el);
const hasScrollableStyle = style.overflowY === 'auto' || style.overflowY === 'scroll';
const hasOverflow = el.scrollHeight > el.clientHeight + 1; // small tolerance
if (hasScrollableStyle || hasOverflow) {
console.log('[DEBUG] ? Nearest scrollable ancestor:', el.className || el.tagName);
return el;
}
el = el.parentElement;
}
} catch (e) {
console.log('[DEBUG] Ancestor scan failed:', e.message);
}
// Try multiple strategies to find the scrollable area
const strategies = [
// Prefer the active grid itself first (book/chapter/verse) so we scroll the real list
() => document.querySelector(`#verseCards${window.bibleApp?.activeTabId ?? ''}`),
() => document.querySelector(`#chapterCards${window.bibleApp?.activeTabId ?? ''}`),
() => document.querySelector(`#bookCards${window.bibleApp?.activeTabId ?? ''}`),
// Then try the selection content containers
() => startElement.closest && startElement.closest('.selection-content'),
() => startElement.closest && startElement.closest('.selector-container'),
// Finally, reading area fallbacks
() => startElement.closest && startElement.closest('.reading-area'),
() => document.querySelector('.tab-panel.active .selection-content'),
() => document.querySelector('.tab-content .selection-content'),
() => startElement.closest && startElement.closest('.tab-content')?.querySelector('.reading-area'),
() => document.querySelector('.tab-content.active .reading-area'),
() => document.querySelector('.reading-area'),
() => document.querySelector('#selectionContent' + (window.bibleApp?.activeTabId ?? '')),
() => document.querySelector('#readingArea' + (window.bibleApp?.activeTabId ?? '')),
() => document.querySelector('.main-content'),
() => document.scrollingElement || document.documentElement || document.body
];
console.log('[DEBUG] Searching for scrollable target...');
for (let i = 0; i < strategies.length; i++) {
try {
const target = strategies[i] && strategies[i]();
if (target) {
const scrollHeight = target.scrollHeight;
const clientHeight = target.clientHeight;
const hasOverflow = scrollHeight > clientHeight + 1;
const computedStyle = window.getComputedStyle(target);
const hasScrollableStyle = computedStyle.overflowY === 'auto' || computedStyle.overflowY === 'scroll';
console.log(`[DEBUG] Strategy ${i}: ${target.className || target.tagName}`,
'scrollHeight:', scrollHeight, 'clientHeight:', clientHeight,
'hasOverflow:', hasOverflow, 'overflowY:', computedStyle.overflowY);
if (hasOverflow || hasScrollableStyle) {
console.log('[DEBUG] ? Selected scrollable target:', target.className || target.tagName);
return target;
}
}
} catch (e) {
console.log(`[DEBUG] Strategy ${i} failed:`, e.message);
}
}
// Fallback preference: selection-content, then reading-area, then body
const fallback = document.querySelector('.selection-content') || document.querySelector('.reading-area') || (document.scrollingElement || document.documentElement || document.body);
console.log('[DEBUG] ?? Using fallback scroll target:', fallback.className || fallback.tagName);
return fallback;
}
// Helper: is element visible (not display:none and has box)
function isElementVisible(el) {
if (!el) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
const rect = el.getBoundingClientRect();
return (rect.width > 0 && rect.height > 0);
}
// Helper: choose the currently active (visible) selector grid for the active tab
function getActiveSelectorGrid(tabId) {
const panel = document.querySelector(`#tabContent${tabId}`) || document.querySelector('.tab-panel.active') || document;
const verseGrid = panel.querySelector(`#verseCards${tabId}`);
const chapterGrid = panel.querySelector(`#chapterCards${tabId}`);
const bookGrid = panel.querySelector(`#bookCards${tabId}`);
// Prefer visible verse grid, then chapter, then book
if (isElementVisible(verseGrid)) return verseGrid;
if (isElementVisible(chapterGrid)) return chapterGrid;
if (isElementVisible(bookGrid)) return bookGrid;
// Fallback to any existing
return verseGrid || chapterGrid || bookGrid || null;
}
// Smooth scrolling function with fallback mechanisms
function performScroll(target, newScrollTop) {
if (twoFingerScroll.animationId) {
cancelAnimationFrame(twoFingerScroll.animationId);
}
const oldScrollTop = target.scrollTop;
twoFingerScroll.animationId = requestAnimationFrame(() => {
// Minimal scroll update to avoid layout thrash
target.scrollTop = newScrollTop;
// Keep pending sync state updated for seamless handoff when momentum stops
try {
twoFingerScroll._pendingTarget = target;
twoFingerScroll._pendingScrollTop = newScrollTop;
} catch (_) {}
});
}
// --- Momentum (kinetic) scrolling helpers ---
function stopKineticScroll(reason = 'manual') {
if (twoFingerScroll.kineticId) {
cancelAnimationFrame(twoFingerScroll.kineticId);
twoFingerScroll.kineticId = null;
}
if (twoFingerScroll.kineticActive) {
// Clear after stopping
twoFingerScroll.kineticActive = false;
// Release scrollTarget once kinetic ends to avoid stale refs
// Sync any pending value one last time to avoid subtle jump
try {
if (twoFingerScroll._pendingTarget && twoFingerScroll._pendingScrollTop != null) {
twoFingerScroll._pendingTarget.scrollTop = twoFingerScroll._pendingScrollTop;
}
} catch (_) {}
twoFingerScroll.scrollTarget = null;
console.log('[DEBUG] ?? Kinetic scroll stopped:', reason);
if (twoFingerScroll.activeTabId != null && reason !== 'pre-start') {
// Only fade when not immediately starting a new kinetic sequence
fadeReadingAreaBlur(twoFingerScroll.activeTabId, 220);
}
}
}
function startKineticScroll(initialVelocityY) {
const target = twoFingerScroll.scrollTarget;
if (!target) return;
// If velocity is tiny or predicted travel is too small, skip kinetic
const absV = Math.abs(initialVelocityY);
// Approximate travel for exponential decay: v0 * tau
const predictedTravel = absV * KINETIC_TAU_MS;
if (absV < KINETIC_MIN_VELOCITY || predictedTravel < KINETIC_MIN_TRAVEL_PX) {
twoFingerScroll.scrollTarget = null;
return;
}
stopKineticScroll('pre-start');
twoFingerScroll.kineticActive = true;
// Clear any residual transform - kinetic uses integer scroll updates
try { clearVisualScrollResidual(twoFingerScroll.activeTabId); } catch(_) {}
// Initialize pending sync state to current values to avoid stale jump on stop
try {
twoFingerScroll._pendingTarget = target;
twoFingerScroll._pendingScrollTop = target.scrollTop;
} catch (_) {}
let velocityY = initialVelocityY; // px/ms
let lastTs = performance.now();
const tau = KINETIC_TAU_MS; // ms time constant
// Avoid an immediate position change on the very first frame to prevent a visible jump
let skipFirstFrame = true;
const step = (ts) => {
if (!twoFingerScroll.kineticActive || !twoFingerScroll.scrollTarget) {
stopKineticScroll('state-cleared');
return;
}
const dt = Math.max(0, ts - lastTs);
// Skip the first frame's delta application to ensure seamless transition at lift-off
if (skipFirstFrame) {
skipFirstFrame = false;
lastTs = ts;
twoFingerScroll.kineticId = requestAnimationFrame(step);
return;
}
lastTs = ts;
// Update position with current velocity
const delta = velocityY * dt; // px
const maxScroll = Math.max(0, target.scrollHeight - target.clientHeight);
let newScrollTop = Math.max(0, Math.min(maxScroll, target.scrollTop + delta));
performScroll(target, newScrollTop);
// Explicitly set scrollTop as well to avoid compositor timing quirks on some engines
target.scrollTop = newScrollTop;
// Keep pending sync state in lockstep with the applied position
try {
twoFingerScroll._pendingTarget = target;
twoFingerScroll._pendingScrollTop = newScrollTop;
} catch (_) {}
// Apply motion blur during kinetic scrolling based on current velocity
try {
if (window.motionBlurEnabled && twoFingerScroll.activeTabId != null) {
const isReadingArea = (target.id && target.id.startsWith('readingArea')) || target.classList?.contains('reading-area');
if (isReadingArea) {
const absVel = Math.abs(velocityY); // px/ms
const targetBlur = Math.min(1.5, absVel * 40);
twoFingerScroll.blurSmoothed = twoFingerScroll.blurSmoothed * 0.85 + targetBlur * 0.15;
setReadingAreaBlur(twoFingerScroll.activeTabId, twoFingerScroll.blurSmoothed);
}
}
} catch (_) {}
// Apply exponential decay to velocity (friction-based)
// v(t+dt) = v(t) * exp(-dt / tau)
const decay = Math.exp(-(dt) / tau);
velocityY = velocityY * decay;
const sign = velocityY === 0 ? 0 : (velocityY > 0 ? 1 : -1);
// Stop if velocity tiny or we hit bounds
const atTop = newScrollTop <= 0 && sign < 0;
const atBottom = newScrollTop >= maxScroll && sign > 0;
if (Math.abs(velocityY) < KINETIC_STOP_VELOCITY || atTop || atBottom) {
stopKineticScroll(atTop || atBottom ? 'bounds' : 'velocity-zero');
if (twoFingerScroll.activeTabId != null) {
fadeReadingAreaBlur(twoFingerScroll.activeTabId, 220);
}
return;
}
twoFingerScroll.kineticId = requestAnimationFrame(step);
};
twoFingerScroll.kineticId = requestAnimationFrame(step);
}
document.addEventListener('touchstart', function(e) {
console.log(`[DEBUG] Touch start - fingers: ${e.touches.length}, globalMultiTouchActive: ${globalMultiTouchActive}`);
try {
if (!e.touches || e.touches.length < 2) {
window.__tabStripTwoFingerPanLock = false;
}
} catch (_) {}
try {
if (window.__tabStripTwoFingerPanLock && e.touches && e.touches.length >= 2) {
tabScrollDebug('global-touchstart-lock-bypass', `touches=${e.touches.length}`);
return;
}
} catch (_) {}
// Hard guard: if 3+ touches, do NOT initialize two-finger scroll here
if (e.touches && e.touches.length >= 3) {
globalMultiTouchActive = true;
try { if (e.cancelable) e.preventDefault(); } catch(_) {}
try { e.stopPropagation(); } catch(_) {}
// three-finger handler will manage tool switching; just block here
return false;
}
// Any new touch should stop kinetic momentum so user can "grab" the page
if (twoFingerScroll.kineticActive) {
stopKineticScroll('new-touch');
// Keep slight blur until new gesture takes over or fades
}
// If a second finger arrives while drawing, keep the stroke but ensure scroll is not locked
try {
if (drawing && e.touches && e.touches.length >= 2) {
// Cancel any pending draw-lock activation
try { if (window._drawLockTimeoutId) { clearTimeout(window._drawLockTimeoutId); window._drawLockTimeoutId = null; } } catch (_) {}
// If scroll was already locked for drawing, unlock it so two-finger gestures won't be blocked
try { if (window._drawingScrollLock && window._drawingScrollLock.active) { unlockScrollForDrawing(); } } catch (_) {}
// Keep drawing active; defer two-finger init until movement
return;
}
} catch (_) {}
// ?? MEDIA TAB FIX: Skip blocking for media tabs - let them handle their own touch events
if (isMediaTabTouch(e.target)) {
console.log('[DEBUG] ?? Media tab touch detected - allowing normal media interaction');
return; // Let media tabs handle their own touch events
}
// Let tab-strip handlers own 2-finger tab panning.
try {
const inTabBar = e.target && e.target.closest && e.target.closest('.tab-bar, .browser-tabs');
let inTabBarByPoint = false;
if (!inTabBar && e.touches && e.touches[0]) {
const t = e.touches[0];
const hit = document.elementFromPoint(t.clientX, t.clientY);
inTabBarByPoint = !!(hit && hit.closest && hit.closest('.tab-bar, .browser-tabs'));
}
if ((inTabBar || inTabBarByPoint) && e.touches && e.touches.length >= 2) {
tabScrollDebug('global-touchstart-bypass-tabbar', `touches=${e.touches.length}`);
return;
}
} catch (_) {}
// Allow single-finger taps in selector region; do not block to preserve click/touchend
try {
const inSelector = e.target.closest && e.target.closest('.selector-container, .selection-content, .selection-stage, .card-grid');
if (inSelector && e.touches.length === 1) {
// If the finger moves and we detect panning with one finger, stop it by preventing default.
// But on touchstart we do not block, so taps still work.
return;
}
} catch (_) {}
// Handle three-finger tool swipe first: suppress everything else while active
if (e.touches.length >= 3) {
// Do not let this start drawing or scrolling
threeFingerToolGestureActive = true;
threeFingerChangedThisGesture = false;
try { if (e.cancelable) e.preventDefault(); } catch (_) {}
try { e.stopPropagation(); } catch (_) {}
lockForThreeFingerGesture();
// Track initial centroid for swipe direction (both axes)
const t0 = e.touches[0];
const t1 = e.touches[1];
const t2 = e.touches[2];
threeFingerStartX = ((t0?.clientX || 0) + (t1?.clientX || 0) + (t2?.clientX || 0)) / 3;
threeFingerBaseX = threeFingerStartX;
threeFingerLastX = threeFingerStartX;
threeFingerStartY = ((t0?.clientY || 0) + (t1?.clientY || 0) + (t2?.clientY || 0)) / 3;
threeFingerBaseY = threeFingerStartY;
threeFingerLastY = threeFingerStartY;
threeFingerStartTimeMs = performance.now();
// Install move handler on window so it wins overall
if (!_threeFingerMoveHandler) {
_threeFingerMoveHandler = function(ev) {
if (!threeFingerToolGestureActive) return;
if (!ev.touches || ev.touches.length < 3) return;
try { if (ev.cancelable) ev.preventDefault(); } catch (_) {}
try { ev.stopPropagation(); } catch (_) {}
const a = ev.touches[0];
const b = ev.touches[1];
const c = ev.touches[2];
const avgX = ((a?.clientX || 0) + (b?.clientX || 0) + (c?.clientX || 0)) / 3;
const avgY = ((a?.clientY || 0) + (b?.clientY || 0) + (c?.clientY || 0)) / 3;
const dx = avgX - threeFingerBaseX;
const dy = avgY - threeFingerBaseY;
// Detect dominant axis
const absX = Math.abs(dx);
const absY = Math.abs(dy);
// Vertical nav preview and fire (dominant vertical movement)
if (absY > THREE_FINGER_NAV_THRESHOLD_PX && absY > absX * THREE_FINGER_AXIS_RATIO) {
// Determine intended direction: dy < 0 => swipe up (go to up-5), dy > 0 => swipe down (down-5)
// Invert mapping to match natural scroll: swipe up -> go down, swipe down -> go up
threeFingerNavIntent = dy < 0 ? 'down' : 'up';
const intensity = Math.min(1, (absY - THREE_FINGER_NAV_THRESHOLD_PX) / 200);
_setNavPreview(threeFingerNavIntent, intensity);
threeFingerNavPreviewed = true;
} else if (!threeFingerNavPreviewed && absX >= THREE_FINGER_SWIPE_THRESHOLD_PX && absX > absY * THREE_FINGER_AXIS_RATIO) {
// Horizontal three-finger tool toggle disabled
}
threeFingerLastX = avgX;
threeFingerLastY = avgY;
return false;
};
window.addEventListener('touchmove', _threeFingerMoveHandler, { passive: false, capture: true });
}
// Aggressively block other systems
globalMultiTouchActive = true;
wordInfoSuppressUntil = Date.now() + 600;
drawSuppressUntil = Math.max(drawSuppressUntil, Date.now() + 200);
return false;
}
// IMMEDIATELY set flag if 2 fingers detected (two-finger scroll)
if (e.touches.length >= 2) {
globalMultiTouchActive = true;
console.log('[DEBUG] ?? Multi-touch detected - BLOCKING all drawing events');
// ?? CRITICAL FIX: Clear active pointers when multi-touch starts
window.clearActivePointersFlag = true;
if (window.pendingTouchDrawPointers && typeof window.pendingTouchDrawPointers.clear === 'function') {
window.pendingTouchDrawPointers.clear();
}
// Prefer selector scroll container first; otherwise prefer the active passage reading area
const tabId = window.bibleApp?.activeTabId ?? '';
const activePanel = document.querySelector(`#tabContent${tabId}`) || document.querySelector('.tab-panel.active') || document;
const selectionContentVisible = (function(){
try {
const sc = activePanel.querySelector('.selection-content');
return sc && isElementVisible(sc);
} catch(_) { return false; }
})();
const selectionContentEl = (function(){ try { return activePanel.querySelector(`#selectionContent${tabId}`) || activePanel.querySelector('.selection-content'); } catch(_) { return null; } })();
const activeGrid = (function(){
try {
const grid = getActiveSelectorGrid(tabId);
return grid || activePanel.querySelector(`#verseCards${tabId}`) || activePanel.querySelector(`#chapterCards${tabId}`) || activePanel.querySelector(`#bookCards${tabId}`);
} catch (_) { return null; }
})();
const activeReadingArea = document.querySelector(`#readingArea${tabId}`) || activePanel.querySelector('.reading-area') || document.querySelector('.reading-area');
let effectiveTarget = null;
if (selectionContentVisible) {
// Always use the selector container as the controlled scroll surface for two-finger gestures
effectiveTarget = selectionContentEl || activeGrid;
} else if (activeReadingArea && (activeReadingArea.scrollHeight > activeReadingArea.clientHeight + 1)) {
effectiveTarget = activeReadingArea;
}
if (!effectiveTarget) {
effectiveTarget = document.body;
}
// Always activate scroll, even if target detection is uncertain
twoFingerScroll.active = true;
twoFingerScroll.scrollTarget = effectiveTarget;
twoFingerScroll.startY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
twoFingerScroll.startScrollTop = effectiveTarget.scrollTop;
// Initialize momentum tracking
twoFingerScroll.lastY = twoFingerScroll.startY;
twoFingerScroll.lastTime = performance.now();
twoFingerScroll.velocityY = 0;
twoFingerScroll.activeTabId = window.bibleApp?.activeTabId ?? null;
// Initialize flick detection
twoFingerScroll.gestureStartTimeMs = performance.now();
twoFingerScroll.totalAbsDistance = 0;
twoFingerScroll.netDistance = 0;
twoFingerScroll.lastMotionTs = performance.now();
console.log('[DEBUG] ? Two-finger scroll initialized on:',
effectiveTarget.id || effectiveTarget.className || effectiveTarget.tagName,
'initial scrollTop:', effectiveTarget.scrollTop,
'scrollHeight:', effectiveTarget.scrollHeight,
'clientHeight:', effectiveTarget.clientHeight);
// Aggressively block this event and suppress word info for a short window
// Cancel any pending word hold timers immediately
try {
document.querySelectorAll('.clickable-word').forEach(ws => {
if (ws && ws._touchTapState) {
if (ws._touchTapState.tapTimeout) {
clearTimeout(ws._touchTapState.tapTimeout);
ws._touchTapState.tapTimeout = null;
}
ws._touchTapState.lastTapTime = 0;
}
if (ws && ws.style) {
ws.style.backgroundColor = '';
}
});
} catch (err) {
console.warn('[DEBUG] Failed to cancel word hold timers on multi-touch:', err);
}
// Set suppression window to avoid accidental popup during gesture
wordInfoSuppressUntil = Date.now() + 600;
// Also suppress drawing for a brief period so releasing fingers doesn't create dots/lines
drawSuppressUntil = Date.now() + 180; // ~1-2 frames safety on 60-120Hz
// Attach non-passive handler to the active target so movement is handled immediately
try {
if (!twoFingerScroll._boundMoveHandler) {
twoFingerScroll._boundMoveHandler = handleTwoFingerTouchMove;
twoFingerScroll.scrollTarget.addEventListener('touchmove', twoFingerScroll._boundMoveHandler, { passive: false });
}
} catch (_) {}
// Block this event, but avoid being too aggressive so gesture can start reliably
try { if (e.cancelable) e.preventDefault(); } catch (_) {}
e.stopPropagation();
// Suppress synthetic mouse events generated after touch to avoid accidental clicks
window.suppressMouseUntil = Date.now() + 800;
return false;
} else if (e.touches.length === 1) {
// Single finger - ensure multi-touch flag is clear
if (globalMultiTouchActive) {
console.log('[DEBUG] ?? Resetting multi-touch flag for single finger');
globalMultiTouchActive = false;
twoFingerScroll.active = false;
}
console.log('[DEBUG] ? Single finger detected - allowing drawing');
// Do not clear suppression here; let the timer elapse naturally
}
}, { passive: false, capture: true });
// Attach two-finger move handler only to the active scroll target to avoid
// global non-passive touchmove listeners that can harm scroll performance.
function handleTwoFingerTouchMove(e) {
if (!twoFingerScroll.active || !twoFingerScroll.scrollTarget) return;
// Only handle true two-finger moves
if (!e.touches || e.touches.length < 2) return;
const currentY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
const deltaY = twoFingerScroll.startY - currentY;
// Calculate new scroll position
let target = twoFingerScroll.scrollTarget;
let maxScroll = Math.max(0, target.scrollHeight - target.clientHeight);
if (maxScroll === 0) {
const tabId = window.bibleApp?.activeTabId ?? '';
const readingArea = document.querySelector(`#readingArea${tabId}`) || document.querySelector('.reading-area');
if (readingArea && readingArea !== target) {
twoFingerScroll.scrollTarget = target = readingArea;
twoFingerScroll.startScrollTop = readingArea.scrollTop;
maxScroll = Math.max(0, readingArea.scrollHeight - readingArea.clientHeight);
}
}
// Compute high-precision desired scroll and split into integer scrollTop + fractional residual
const desired = twoFingerScroll.startScrollTop + (deltaY * TWO_FINGER_MANUAL_SPEED_MULTIPLIER);
const clampedDesired = Math.max(0, Math.min(maxScroll, desired));
const intScroll = Math.round(clampedDesired);
const residual = clampedDesired - intScroll; // [-0.5, 0.5] range
// Apply scroll with requestAnimationFrame to sync with refresh rate
if (twoFingerScroll.animationId) cancelAnimationFrame(twoFingerScroll.animationId);
twoFingerScroll.animationId = requestAnimationFrame(() => {
target.scrollTop = intScroll;
// Update pending sync to this precise value to avoid end-jumps
try {
twoFingerScroll._pendingTarget = target;
twoFingerScroll._pendingScrollTop = intScroll;
} catch (_) {}
// Apply visual residual for buttery smooth sub-pixel scroll
try {
applyVisualScrollResidual(twoFingerScroll.activeTabId, residual);
} catch(_) {}
});
// Record the last applied scrollTop to sync at lift-off and avoid a tiny jump
twoFingerScroll._pendingScrollTop = intScroll;
twoFingerScroll._pendingTarget = target;
// Velocity tracking for kinetic continuation
const now = performance.now();
const dt = Math.max(1, now - twoFingerScroll.lastTime);
// Gentle smoothing to keep momentum feel responsive yet stable
const instVel = (twoFingerScroll.lastY - currentY) / dt;
twoFingerScroll.velocityY = twoFingerScroll.velocityY * 0.85 + instVel * 0.15;
twoFingerScroll.lastY = currentY;
twoFingerScroll.lastTime = now;
// Accumulate absolute movement for flick detection
twoFingerScroll.totalAbsDistance += Math.abs(deltaY);
// Track net distance and last motion time to distinguish steady scroll vs flick
twoFingerScroll.netDistance = (twoFingerScroll.netDistance || 0) + (currentY - (twoFingerScroll.lastY || currentY));
twoFingerScroll.lastMotionTs = now;
// Visual polish
try {
const tabId = twoFingerScroll.activeTabId;
const isReadingArea = (target.id && target.id.startsWith('readingArea')) || target.classList?.contains('reading-area');
if (isReadingArea && tabId != null) {
if (window.motionBlurEnabled) {
const absVel = Math.abs(twoFingerScroll.velocityY);
const targetBlur = Math.min(1.5, absVel * 40);
twoFingerScroll.blurSmoothed = twoFingerScroll.blurSmoothed * 0.85 + targetBlur * 0.15;
setReadingAreaBlur(tabId, twoFingerScroll.blurSmoothed);
} else {
setReadingAreaBlur(tabId, 0);
}
}
} catch (_) {}
// Prevent the browser default (pinch-zoom or page pan) only if cancelable
try { if (e.cancelable) e.preventDefault(); } catch (_) {}
return false;
}
document.addEventListener('touchmove', function(e) {
try {
if (window.__tabStripTwoFingerPanLock && e.touches && e.touches.length >= 2) {
tabScrollDebug('global-touchmove-lock-bypass', `touches=${e.touches.length}`);
return;
}
} catch (_) {}
// If three-finger gesture active and touch count dipped below 3, end it gracefully
if (threeFingerToolGestureActive && (!e.touches || e.touches.length < 3)) {
threeFingerToolGestureActive = false;
try {
if (_threeFingerMoveHandler) {
window.removeEventListener('touchmove', _threeFingerMoveHandler, { capture: true });
}
} catch (_) {}
_threeFingerMoveHandler = null;
drawSuppressUntil = Math.max(drawSuppressUntil, Date.now() + 120);
}
// If three-finger tool gesture is active, suppress everything else
if (threeFingerToolGestureActive) {
try { if (e.cancelable) e.preventDefault(); } catch (_) {}
try { e.stopPropagation(); } catch (_) {}
return false;
}
// ?? MEDIA TAB FIX: Skip blocking for media tabs
if (isMediaTabTouch(e.target)) {
return; // Let media tabs handle their own touch events
}
// Let tab-strip handlers own 2-finger tab panning.
try {
const inTabBar = e.target && e.target.closest && e.target.closest('.tab-bar, .browser-tabs');
let inTabBarByPoint = false;
if (!inTabBar && e.touches && e.touches[0]) {
const t = e.touches[0];
const hit = document.elementFromPoint(t.clientX, t.clientY);
inTabBarByPoint = !!(hit && hit.closest && hit.closest('.tab-bar, .browser-tabs'));
}
if ((inTabBar || inTabBarByPoint) && e.touches && e.touches.length >= 2) {
tabScrollDebug('global-touchmove-bypass-tabbar', `touches=${e.touches.length}`);
return;
}
} catch (_) {}
// Allow single-finger interactions in selector areas
try {
const inSelector = e.target.closest && e.target.closest('.selector-container, .selection-content, .selection-stage, .card-grid');
if (inSelector && e.touches && e.touches.length === 1) {
return;
}
} catch (_) {}
// If drawing is active and a second finger starts moving, finalize the stroke first to allow 2-finger scroll
try {
if (drawing && e.touches && e.touches.length >= 2) {
// Mark this stroke to be cancelled; this movement indicates scroll intent
window._multiTouchDuringCurrentStroke = true;
window._cancelCurrentStroke = true;
const activeTabId = window.bibleApp?.activeTabId;
if (typeof endDrawing === 'function') {
endDrawing(activeTabId);
} else if (window.endDrawing) {
window.endDrawing(activeTabId);
}
// Do not return; proceed so two-finger handling can initialize
}
} catch (_) {}
// If user added a second finger after initial touchstart, initialize 2-finger scroll here
if (e.touches && e.touches.length >= 2 && !twoFingerScroll.active) {
globalMultiTouchActive = true;
let effectiveTarget = (function() {
const tabId = window.bibleApp?.activeTabId ?? '';
const panel = document.querySelector(`#tabContent${tabId}`) || document.querySelector('.tab-panel.active') || document;
const selectionVisible = (function(){
try { const sc = panel.querySelector('.selection-content'); return sc && isElementVisible(sc); } catch(_) { return false; }
})();
if (selectionVisible) {
// Prefer the selection content container first (guaranteed to exist)
return panel.querySelector(`#selectionContent${tabId}`) || panel.querySelector('.selection-content') || getActiveSelectorGrid(tabId) || panel.querySelector(`#bookCards${tabId}`) || panel.querySelector(`#chapterCards${tabId}`) || panel.querySelector(`#verseCards${tabId}`) || document.querySelector('[id^="bookCards"], [id^="chapterCards"], [id^="verseCards"]');
}
const reading = document.querySelector(`#readingArea${tabId}`) || panel.querySelector('.reading-area') || document.querySelector('.reading-area');
if (reading && (reading.scrollHeight > reading.clientHeight + 1)) return reading;
return null;
})();
if (!effectiveTarget) effectiveTarget = findScrollableTarget(e.target);
if (!effectiveTarget) effectiveTarget = document.body;
twoFingerScroll.active = true;
twoFingerScroll.scrollTarget = effectiveTarget;
twoFingerScroll.startY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
twoFingerScroll.startScrollTop = effectiveTarget.scrollTop;
twoFingerScroll.lastY = twoFingerScroll.startY;
twoFingerScroll.lastTime = performance.now();
twoFingerScroll.velocityY = 0;
twoFingerScroll.gestureStartTimeMs = performance.now();
twoFingerScroll.totalAbsDistance = 0;
twoFingerScroll.netDistance = 0;
twoFingerScroll.lastMotionTs = performance.now();
// Prime pending sync values immediately
try {
twoFingerScroll._pendingTarget = effectiveTarget;
twoFingerScroll._pendingScrollTop = effectiveTarget.scrollTop;
} catch (_) {}
wordInfoSuppressUntil = Date.now() + 600;
// Attach non-passive handler to the active target only
try {
if (!twoFingerScroll._boundMoveHandler) {
twoFingerScroll._boundMoveHandler = handleTwoFingerTouchMove;
twoFingerScroll.scrollTarget.addEventListener('touchmove', twoFingerScroll._boundMoveHandler, { passive: false });
}
} catch (_) {}
}
// If two-finger scroll is already active, do not proxy to non-passive handler from here.
if (e.touches && e.touches.length >= 2 && twoFingerScroll.active && twoFingerScroll.scrollTarget) {
// Ensure default is prevented only when cancelable, to reduce gesture flakiness
try { if (e.cancelable) e.preventDefault(); } catch (_) {}
return;
}
}, { passive: true, capture: true });
document.addEventListener('touchend', function(e) {
// End three-finger gesture if finger count drops below 3
if (threeFingerToolGestureActive && e.touches.length < 3) {
threeFingerToolGestureActive = false;
threeFingerChangedThisGesture = false;
// Remove move handler
try {
if (_threeFingerMoveHandler) {
window.removeEventListener('touchmove', _threeFingerMoveHandler, { capture: true });
}
} catch (_) {}
_threeFingerMoveHandler = null;
// Brief suppression window to avoid stray inputs right after lift
drawSuppressUntil = Math.max(drawSuppressUntil, Date.now() + 120);
wordInfoSuppressUntil = Math.max(wordInfoSuppressUntil, Date.now() + 300);
unlockForThreeFingerGesture();
}
// ?? MEDIA TAB FIX: Skip blocking for media tabs
if (isMediaTabTouch(e.target)) {
return; // Let media tabs handle their own touch events
}
// Reset when no fingers remain or less than 2 fingers
if (e.touches.length < 2) {
if (globalMultiTouchActive || twoFingerScroll.active) {
console.log('[DEBUG] Ending multi-touch scroll, fingers remaining:', e.touches.length);
// End gesture immediately, then optionally continue with momentum
globalMultiTouchActive = false;
twoFingerScroll.active = false;
// Sync the scrollTop to the last applied value to prevent any jump on lift-off
try {
if (twoFingerScroll._pendingTarget && twoFingerScroll._pendingScrollTop != null) {
twoFingerScroll._pendingTarget.scrollTop = twoFingerScroll._pendingScrollTop;
}
} catch (_) {}
// Detach per-target non-passive handler if present
try {
if (twoFingerScroll._boundMoveHandler && twoFingerScroll.scrollTarget) {
twoFingerScroll.scrollTarget.removeEventListener('touchmove', twoFingerScroll._boundMoveHandler, { passive: false });
}
} catch (_) {}
twoFingerScroll._boundMoveHandler = null;
if (twoFingerScroll.animationId) {
cancelAnimationFrame(twoFingerScroll.animationId);
twoFingerScroll.animationId = null;
}
// Start kinetic scroll only on deliberate flicks (distance, velocity, duration)
const initialVelocity = twoFingerScroll.velocityY;
const absV = Math.abs(initialVelocity);
const predictedTravel = absV * KINETIC_TAU_MS; // approximate travel for exponential decay
const gestureDuration = performance.now() - (twoFingerScroll.gestureStartTimeMs || performance.now());
const totalDist = twoFingerScroll.totalAbsDistance || 0;
const netDist = Math.abs(twoFingerScroll.netDistance || 0);
const recentMotionMs = performance.now() - (twoFingerScroll.lastMotionTs || performance.now());
// Require recent motion (no pause), sufficient net distance, and velocity threshold
const RECENT_MOTION_MAX_MS = 90;
const MIN_NET_DIST_FOR_FLICK = Math.max(30, FLICK_MIN_DISTANCE_PX * 0.6);
const isFlick = (totalDist >= FLICK_MIN_DISTANCE_PX) && (netDist >= MIN_NET_DIST_FOR_FLICK) && (absV >= FLICK_MIN_VELOCITY) && (gestureDuration <= FLICK_MAX_DURATION_MS) && (recentMotionMs <= RECENT_MOTION_MAX_MS);
if (twoFingerScroll.scrollTarget && isFlick && absV >= KINETIC_MIN_VELOCITY && predictedTravel >= KINETIC_MIN_TRAVEL_PX) {
// Freeze the current exact scrollTop as the baseline to avoid a lift-off nudge
try {
if (twoFingerScroll._pendingTarget && twoFingerScroll._pendingScrollTop != null) {
twoFingerScroll._pendingTarget.scrollTop = twoFingerScroll._pendingScrollTop;
}
} catch (_) {}
const boosted = initialVelocity * KINETIC_VELOCITY_BOOST;
console.log('[DEBUG] ?? Starting kinetic scroll with velocity:', boosted.toFixed(4), 'px/ms (boosted)');
// Do not fade blur here; kinetic will manage blur until it stops
startKineticScroll(boosted);
} else {
// No kinetic; clear target
twoFingerScroll.scrollTarget = null;
// No kinetic momentum; fade blur out now
if (twoFingerScroll.activeTabId != null) {
twoFingerScroll.blurSmoothed = 0;
fadeReadingAreaBlur(twoFingerScroll.activeTabId, 250);
}
// Clear pending sync state
twoFingerScroll._pendingScrollTop = null;
twoFingerScroll._pendingTarget = null;
}
// Extend suppression after gesture ends
wordInfoSuppressUntil = Math.max(wordInfoSuppressUntil, Date.now() + 300);
// Shorten drawing suppression; we will also hard-override after 60ms
drawSuppressUntil = Math.max(drawSuppressUntil, Date.now() + 90);
lastMultiTouchEndMs = Date.now();
}
}
}, { passive: false, capture: true });
document.addEventListener('touchcancel', function(e) {
// ?? MEDIA TAB FIX: Skip blocking for media tabs
if (isMediaTabTouch(e.target)) {
return; // Let media tabs handle their own touch events
}
console.log('[DEBUG] Touch cancelled - resetting multi-touch state');
globalMultiTouchActive = false;
twoFingerScroll.active = false;
// Detach per-target non-passive handler if present
try {
if (twoFingerScroll._boundMoveHandler && twoFingerScroll.scrollTarget) {
twoFingerScroll.scrollTarget.removeEventListener('touchmove', twoFingerScroll._boundMoveHandler, { passive: false });
}
} catch (_) {}
twoFingerScroll._boundMoveHandler = null;
twoFingerScroll.scrollTarget = null;
if (twoFingerScroll.animationId) {
cancelAnimationFrame(twoFingerScroll.animationId);
twoFingerScroll.animationId = null;
}
// Extend suppression slightly on cancel
wordInfoSuppressUntil = Math.max(wordInfoSuppressUntil, Date.now() + 300);
}, { passive: false, capture: true });
// --- END: Enhanced 2-Finger Scrolling System ---
// Cancel kinetic scroll on wheel (e.g., mouse wheel or trackpad scroll)
document.addEventListener('wheel', function() {
if (twoFingerScroll.kineticActive) {
stopKineticScroll('wheel');
}
}, { passive: true, capture: true });
// Also cancel kinetic on pointerdown/touchstart so grabbing the page feels solid
document.addEventListener('pointerdown', function() {
if (twoFingerScroll.kineticActive) {
stopKineticScroll('pointerdown');
}
}, { passive: true, capture: true });
// --- Three-finger tool swipe via Pointer Events (for environments that use Pointer Events) ---
const _activeTouchPointers = new Map(); // pointerId -> { x }
function _avgPointerX() {
if (_activeTouchPointers.size === 0) return 0;
let sum = 0; let n = 0;
_activeTouchPointers.forEach(v => { sum += v.x || 0; n++; });
return n ? (sum / n) : 0;
}
window.addEventListener('pointerdown', function(e) {
if (e.pointerType !== 'touch') return;
try { _activeTouchPointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); } catch(_) {}
if (!threeFingerToolGestureActive && _activeTouchPointers.size >= 3) {
threeFingerToolGestureActive = true;
// Prevent drawing and scrolling while gesture is active
globalMultiTouchActive = true;
lockForThreeFingerGesture();
// If a stroke was in progress, cancel it cleanly and mark as multi-touch
try {
if (typeof drawing !== 'undefined' && drawing) {
window._multiTouchDuringCurrentStroke = true;
window._cancelCurrentStroke = true;
const tabId = window.bibleApp?.activeTabId;
if (typeof endDrawing === 'function') {
endDrawing(tabId);
} else if (window.endDrawing) {
window.endDrawing(tabId);
}
}
} catch(_) {}
const ax = _avgPointerX();
threeFingerStartX = ax;
threeFingerBaseX = ax;
threeFingerLastX = ax;
// Estimate Y from current pointers
try {
let sumY = 0, n = 0; _activeTouchPointers.forEach(v => { sumY += (v.y || 0); n++; });
const ay = n ? (sumY / n) : e.clientY;
threeFingerStartY = ay; threeFingerBaseY = ay; threeFingerLastY = ay;
} catch(_) {}
threeFingerStartTimeMs = performance.now();
drawSuppressUntil = Math.max(drawSuppressUntil, Date.now() + 180);
try { if (e.cancelable) e.preventDefault(); } catch(_) {}
try { e.stopPropagation(); } catch(_) {}
}
}, { capture: true, passive: false });
window.addEventListener('pointermove', function(e) {
if (e.pointerType !== 'touch') return;
if (_activeTouchPointers.has(e.pointerId)) {
_activeTouchPointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
}
if (!threeFingerToolGestureActive) {
// Late activation if third finger starts moving
if (_activeTouchPointers.size >= 3) {
threeFingerToolGestureActive = true;
globalMultiTouchActive = true;
lockForThreeFingerGesture();
const ax = _avgPointerX();
threeFingerStartX = ax;
threeFingerBaseX = ax;
threeFingerLastX = ax;
try { let sumY = 0, n = 0; _activeTouchPointers.forEach(v => { sumY += (v.y || 0); n++; }); const ay = n ? (sumY / n) : e.clientY; threeFingerStartY = ay; threeFingerBaseY = ay; threeFingerLastY = ay; } catch(_) {}
threeFingerStartTimeMs = performance.now();
drawSuppressUntil = Math.max(drawSuppressUntil, Date.now() + 180);
threeFingerChangedThisGesture = false;
} else {
return;
}
}
// Actively processing three-finger gesture
try { if (e.cancelable) e.preventDefault(); } catch(_) {}
try { e.stopPropagation(); } catch(_) {}
const avgX = _avgPointerX();
let sumY = 0, n = 0; _activeTouchPointers.forEach(v => { sumY += (v.y || 0); n++; });
const avgY = n ? (sumY / n) : e.clientY;
const dx = avgX - threeFingerBaseX;
const dy = avgY - threeFingerBaseY;
const absX = Math.abs(dx);
const absY = Math.abs(dy);
if (absY > THREE_FINGER_NAV_THRESHOLD_PX && absY > absX * THREE_FINGER_AXIS_RATIO) {
// Invert mapping to match natural scroll: swipe up -> go down, swipe down -> go up
threeFingerNavIntent = dy < 0 ? 'down' : 'up';
const intensity = Math.min(1, (absY - THREE_FINGER_NAV_THRESHOLD_PX) / 200);
_setNavPreview(threeFingerNavIntent, intensity);
threeFingerNavPreviewed = true;
} else if (!threeFingerNavPreviewed && absX >= THREE_FINGER_SWIPE_THRESHOLD_PX && absX > absY * THREE_FINGER_AXIS_RATIO) {
// Horizontal three-finger tool toggle disabled
}
threeFingerLastX = avgX;
threeFingerLastY = avgY;
}, { capture: true, passive: false });
function _endThreeFingerPointerGesture() {
threeFingerToolGestureActive = false;
threeFingerChangedThisGesture = false;
// Keep brief suppression to avoid stray taps
drawSuppressUntil = Math.max(drawSuppressUntil, Date.now() + 120);
wordInfoSuppressUntil = Math.max(wordInfoSuppressUntil, Date.now() + 300);
// Clear map but do not alter two-finger scroll state here
try { _activeTouchPointers.clear(); } catch(_) {}
unlockForThreeFingerGesture();
}
window.addEventListener('pointerup', function(e) {
if (e.pointerType !== 'touch') return;
if (_activeTouchPointers.has(e.pointerId)) {
_activeTouchPointers.delete(e.pointerId);
}
if (threeFingerToolGestureActive && _activeTouchPointers.size < 3) {
try {
if (threeFingerNavPreviewed && threeFingerNavIntent) {
_fireNavAction(threeFingerNavIntent);
}
} catch(_) {}
threeFingerNavPreviewed = false;
threeFingerNavIntent = null;
_clearNavPreview();
_endThreeFingerPointerGesture();
}
}, { capture: true, passive: true });
window.addEventListener('pointercancel', function(e) {
if (e.pointerType !== 'touch') return;
if (_activeTouchPointers.has(e.pointerId)) {
_activeTouchPointers.delete(e.pointerId);
}
if (threeFingerToolGestureActive && _activeTouchPointers.size < 3) {
_endThreeFingerPointerGesture();
}
}, { capture: true, passive: true });
// --- Disable scrolling entirely while drawing (Bible tabs) ---
function lockScrollForDrawing(tabId) {
try {
const readingArea = document.getElementById(`readingArea${tabId}`);
if (!readingArea) return;
if (window._drawingScrollLock && window._drawingScrollLock.active) return;
const prev = {
touchAction: readingArea.style.touchAction,
overscrollBehavior: readingArea.style.overscrollBehavior,
overflow: readingArea.style.overflow
};
const startTop = readingArea.scrollTop;
const preventMove = (ev) => {
try {
// Allow text selection in prayer tabs
if (ev.target && ev.target.closest && ev.target.closest('.prayer-text')) return;
if (ev.target && ev.target.classList && ev.target.classList.contains('prayer-text')) return;
ev.preventDefault();
ev.stopPropagation();
} catch (_) {}
};
const clampScroll = () => { try { if (readingArea.scrollTop !== startTop) readingArea.scrollTop = startTop; } catch (_) {} };
const docPreventMove = (ev) => {
try {
// Allow text selection in prayer tabs
if (ev.target && ev.target.closest && ev.target.closest('.prayer-text')) return;
if (ev.target && ev.target.classList && ev.target.classList.contains('prayer-text')) return;
ev.preventDefault();
ev.stopPropagation();
} catch (_) {}
};
readingArea.style.touchAction = 'none';
readingArea.style.overscrollBehavior = 'contain';
readingArea.style.overflow = 'hidden';
// Block scroll/gestures within the reading area
readingArea.addEventListener('touchmove', preventMove, { passive: false, capture: true });
readingArea.addEventListener('wheel', preventMove, { passive: false, capture: true });
readingArea.addEventListener('scroll', clampScroll, { passive: true, capture: true });
// Safety: also block bubbling gestures while drawing
document.addEventListener('touchmove', docPreventMove, { passive: false, capture: true });
document.addEventListener('wheel', docPreventMove, { passive: false, capture: true });
window._drawingScrollLock = {
active: true,
tabId,
readingArea,
startTop,
prev,
preventMove,
clampScroll,
docPreventMove
};
} catch (_) {}
}
function unlockScrollForDrawing() {
try {
const s = window._drawingScrollLock;
if (!s || !s.active) return;
try {
s.readingArea.removeEventListener('touchmove', s.preventMove, { capture: true });
s.readingArea.removeEventListener('wheel', s.preventMove, { capture: true });
s.readingArea.removeEventListener('scroll', s.clampScroll, { capture: true });
} catch (_) {}
try {
document.removeEventListener('touchmove', s.docPreventMove, { capture: true });
document.removeEventListener('wheel', s.docPreventMove, { capture: true });
} catch (_) {}
// Restore styles
try {
if (s.prev) {
s.readingArea.style.touchAction = s.prev.touchAction || '';
s.readingArea.style.overscrollBehavior = s.prev.overscrollBehavior || '';
s.readingArea.style.overflow = s.prev.overflow || '';
}
} catch (_) {}
window._drawingScrollLock = { active: false };
} catch (_) {}
}
// ?? Helper function to detect if touch is within a media tab
function isMediaTabTouch(target) {
// Check if target is within media content or media drawing canvas
if (target.closest('.media-content') ||
target.closest('.media-drawing-canvas') ||
target.classList?.contains('media-drawing-canvas') ||
target.classList?.contains('media-item')) {
return true;
}
// Check if target is within a media tab's reading area
const readingArea = target.closest('.reading-area');
if (readingArea) {
const mediaContent = readingArea.querySelector('.media-content');
if (mediaContent) {
return true;
}
}
return false;
}
// ?? Test Helper: Load long passage for scrolling test
window.loadLongPassageForScrollTest = function() {
console.log('[TEST] Loading long passage for scroll testing...');
// Load a longer passage like Psalm 119 or Matthew 5-7
if (window.loadPassage) {
window.loadPassage('Psalm', '119', '1', '176', 'ESV');
} else if (window.bibleApp && window.bibleApp.loadPassage) {
window.bibleApp.loadPassage('Psalm', '119', '1', '176', 'ESV');
} else {
console.log('[TEST] loadPassage function not available, trying manual approach...');
// Try alternative method
const input = document.querySelector('input[placeholder*="book"]') || document.querySelector('#bookInput');
if (input) {
input.value = 'Psalm 119';
input.dispatchEvent(new Event('input', {bubbles: true}));
}
}
};
// ?? Test Helper: Force create longer scrollable content for testing
window.makeContentScrollableForTest = function() {
console.log('[TEST] Making content artificially longer for scroll testing...');
const readingArea = document.querySelector('.reading-area');
if (readingArea) {
// Add temporary test content
const testDiv = document.createElement('div');
testDiv.id = 'scroll-test-content';
testDiv.style.height = '5000px';
testDiv.style.background = 'linear-gradient(to bottom, transparent, rgba(255,255,0,0.1))';
testDiv.innerHTML = '?? SCROLL TEST AREA
This is temporary content to test 2-finger scrolling...
'.repeat(100);
readingArea.appendChild(testDiv);
console.log('[TEST] ? Added 5000px of scrollable content. Try 2-finger scroll now!');
}
};
// ?? Test Helper: Remove test content
window.removeScrollTestContent = function() {
const testContent = document.getElementById('scroll-test-content');
if (testContent) {
testContent.remove();
console.log('[TEST] ? Removed test scroll content');
}
};
// ?? ENHANCED LAYERING: Global helper to bring media to front
window.bringMediaToFront = function(mediaItem) {
console.log('[GLOBAL LAYER] Bringing media item to front via global helper');
// Ensure layer manager is initialized
if (!window.mediaLayerManager && window.localMediaHandler) {
window.localMediaHandler.initializeLayerManager();
}
// Use layer manager to bring to front
if (window.mediaLayerManager) {
window.mediaLayerManager.bringToFront(mediaItem);
} else {
console.warn('[GLOBAL LAYER] Layer manager not available, using fallback');
// Fallback - set a high z-index but preserve layer hierarchy
mediaItem.style.setProperty('z-index', '1000', 'important');
}
};
// ?? ENHANCED LAYERING: Global helper to refresh all layering
window.refreshMediaLayering = function() {
console.log('[GLOBAL LAYER] Refreshing all media layering');
if (window.mediaLayerManager) {
window.mediaLayerManager.refreshAllLayers();
} else if (window.localMediaHandler) {
window.localMediaHandler.initializeLayerManager();
if (window.mediaLayerManager) {
window.mediaLayerManager.refreshAllLayers();
}
} else {
console.warn('[GLOBAL LAYER] No layer manager available');
}
};
function attachAnnotationListeners(tabId) {
// Check if this is a media tab - if so, don't attach annotation listeners
const tab = window.bibleApp?.tabs?.find(t => t.id === tabId);
if (tab && tab.mode === 'media') {
console.log('[DEBUG] Skipping annotation listeners for media tab:', tabId);
return;
}
const canvas = annotationCanvases[tabId];
if (!canvas) return;
// NOTE: This function re-attaches pointer listeners (and a fresh activePointers Map) on every
// call. That re-run is load-bearing — it self-heals a stuck activePointers state from a missed
// pointerup on touch (palm rejection / multi-touch), which would otherwise block all drawing.
// Do NOT add a one-time guard here; doing so regressed drawing to a single stroke.
// Remove all previous listeners
canvas.onpointerdown = null;
canvas.onpointermove = null;
canvas.onpointerup = null;
canvas.onpointerleave = null;
canvas.onpointercancel = null;
canvas.ontouchstart = null;
canvas.ontouchmove = null;
canvas.ontouchend = null;
canvas.onmousedown = null;
canvas.onmousemove = null;
canvas.onmouseup = null;
// Disable all browser touch behaviors on canvas - we handle everything manually
canvas.style.touchAction = 'none'; // Disable browser handling of all touch gestures
canvas.style.userSelect = 'none';
canvas.style.webkitUserSelect = 'none';
// Track active pointers
const activePointers = new Map();
// Guard (deprecated hold logic removed): draw starts instantly on touch
// Keep map defined for safety with older state, but we no longer populate it
if (!window.pendingTouchDrawPointers) {
window.pendingTouchDrawPointers = new Map();
}
canvas.addEventListener('pointerdown', function(e) {
console.log(`[DEBUG] ?? Canvas pointer down - pointerType: ${e.pointerType}, globalMultiTouchActive: ${globalMultiTouchActive}, currentTool: ${currentTool}`);
// PORTRAIT MODE FIX: Add extra checks for portrait mode stability
const isPortrait = window.innerHeight > window.innerWidth;
if (isPortrait) {
console.log('[DEBUG] ?? PORTRAIT MODE - Enhanced touch handling active');
// Ensure canvas dimensions are current
void canvas.offsetHeight;
const currentRect = canvas.getBoundingClientRect();
console.log('[DEBUG] ?? Canvas rect in portrait:', currentRect);
}
// ?? CRITICAL FIX: Clear active pointers if flag is set
if (window.clearActivePointersFlag) {
const clearedCount = activePointers.size;
activePointers.clear();
window.clearActivePointersFlag = false;
console.log(`[DEBUG] ?? Cleared ${clearedCount} active pointers due to multi-touch`);
}
// IMMEDIATE check - skip if any multi-touch is active OR three-finger tool gesture active
if (globalMultiTouchActive || threeFingerToolGestureActive) {
console.log('[DEBUG] ? BLOCKED pointer down - multi-touch active');
if (eraserVisualAid) eraserVisualAid.classList.remove('active');
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
e.preventDefault();
e.stopPropagation();
// If a drawing tool hits the canvas, close the tool rail (keep minimal UI)
try {
const isDrawingTool = (window.currentTool === 'pen' || window.currentTool === 'highlight' || window.currentTool === 'erase');
if (isDrawingTool && !window.penSizeAdjustActive && typeof window.closeToolRail === 'function') {
window.closeToolRail();
}
} catch (_) {}
// Only handle primary pointer or first touch
if (!activePointers.size) {
if (e.pointerType === 'touch') {
// Start drawing immediately on first touch (no delay)
activePointers.set(e.pointerId, e);
if (currentTool === 'erase') {
isErasing = true;
updateEraserVisualAid(e.clientX, e.clientY, getCurrentDrawingSettings().size);
eraserVisualAid.classList.add('active');
}
window.currentDrawingInputType = 'touch';
startDrawing(e);
} else {
console.log(`[DEBUG] ? Starting drawing - pointerId: ${e.pointerId}, tool: ${currentTool}`);
activePointers.set(e.pointerId, e);
if (currentTool === 'erase') {
isErasing = true;
updateEraserVisualAid(e.clientX, e.clientY, getCurrentDrawingSettings().size);
eraserVisualAid.classList.add('active');
}
window.currentDrawingInputType = 'mouse';
startDrawing(e);
}
} else {
console.log(`[DEBUG] ?? Ignoring additional pointer - already have ${activePointers.size} active pointers`);
}
});
canvas.addEventListener('pointermove', function(e) {
// IMMEDIATE check - skip if any multi-touch is active OR three-finger tool gesture active
if (globalMultiTouchActive || threeFingerToolGestureActive) {
console.log('[DEBUG] BLOCKED pointer move - multi-touch active');
// Hide the eraser cursor so it doesn't sit "planted" where a 2-finger scroll began.
if (eraserVisualAid) eraserVisualAid.classList.remove('active');
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
e.preventDefault();
e.stopPropagation();
// Removed: no hold-based cancellation; drawing starts instantly on touch
// Only handle move if this is the active pointer
if (activePointers.has(e.pointerId)) {
activePointers.set(e.pointerId, e);
if (currentTool === 'erase' && isErasing) {
updateEraserVisualAid(e.clientX, e.clientY, getCurrentDrawingSettings().size);
}
if (drawing) {
draw(e);
}
}
});
function endPointerDrawing(e) {
// If multi-touch is active, still end the stroke cleanly to avoid glitches
if (globalMultiTouchActive) {
console.log('[DEBUG] Ending pointer drawing due to multi-touch');
// Remove from active pointers if present
if (activePointers.has(e.pointerId)) {
activePointers.delete(e.pointerId);
}
// Hide eraser visual aid if needed
if (isErasing) {
isErasing = false;
eraserVisualAid.classList.remove('active');
}
// Finalize current stroke
if (drawing) {
endDrawing(tabId);
}
return;
}
// No pending-touch state anymore; continue to normal finish
// Only handle end if this is the active pointer
if (activePointers.has(e.pointerId)) {
activePointers.delete(e.pointerId);
if (isErasing) {
isErasing = false;
eraserVisualAid.classList.remove('active');
}
if (drawing) {
endDrawing(tabId);
}
}
}
canvas.addEventListener('pointerup', function(e) {
// IMMEDIATE check - skip if any multi-touch is active OR three-finger tool gesture active
if (globalMultiTouchActive || threeFingerToolGestureActive) {
console.log('[DEBUG] BLOCKED pointer up - multi-touch active');
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
// Only handle if this is an active pointer
if (!activePointers.has(e.pointerId)) {
return;
}
e.preventDefault();
e.stopPropagation();
endPointerDrawing(e);
});
canvas.addEventListener('pointerleave', function(e) {
endPointerDrawing(e);
});
canvas.addEventListener('pointercancel', function(e) {
// Ensure pending touch draw is cleared on cancel
if (e.pointerType === 'touch' && window.pendingTouchDrawPointers.has(e.pointerId)) {
const rec = window.pendingTouchDrawPointers.get(e.pointerId);
clearTimeout(rec.timeoutId);
window.pendingTouchDrawPointers.delete(e.pointerId);
console.log('[DEBUG] ?? Pending touch draw cancelled by pointercancel');
return;
}
endPointerDrawing(e);
});
// Capture pointer to ensure we get events outside the canvas
canvas.setPointerCapture = canvas.setPointerCapture || function(){};
canvas.addEventListener('pointerdown', function(e) {
canvas.setPointerCapture(e.pointerId);
});
}
// Make functions available globally
window.getAnnotationStorageKey = getAnnotationStorageKey;
window.saveAnnotations = saveAnnotations;
window.loadAnnotations = loadAnnotations;
// Expose color variables globally for enhanced tools
window.penColor = penColor;
window.highlighterColor = highlighterColor;
// Create eraser visual aid div on DOMContentLoaded
if (!eraserVisualAid) {
eraserVisualAid = document.createElement('div');
eraserVisualAid.className = 'eraser-visual-aid';
document.body.appendChild(eraserVisualAid);
}
// Helper to update eraser visual aid position and size
function updateEraserVisualAid(x, y, size) {
const dpr = window.devicePixelRatio || 1;
const radius = Math.max(12, size); // ensure visible
eraserVisualAid.style.width = radius + 'px';
eraserVisualAid.style.height = radius + 'px';
eraserVisualAid.style.left = (x - radius / 2) + 'px';
eraserVisualAid.style.top = (y - radius / 2) + 'px';
eraserVisualAid.classList.add('active');
}
// Hide eraser visual when switching away from eraser tool
function hideEraserVisualOnToolChange() {
if (eraserVisualAid) eraserVisualAid.classList.remove('active');
isErasing = false;
}
// Hide eraser visual aid on any scroll/touch scroll to prevent it getting stuck
// Note: Per request, keep eraser visible while using it; do not auto-hide on scroll
// Pre-load annotation image for instant display
function preloadAnnotationImage(passageKey) {
if (annotationImageCache[passageKey]) {
return annotationImageCache[passageKey];
}
const raw = localStorage.getItem(passageKey);
if (!raw) return null;
try {
const data = JSON.parse(raw);
let dataUrl = data.dataUrl;
if (data.compressed) {
dataUrl = 'data:image/png;base64,' + data.dataUrl;
}
const img = new Image();
img.onload = function() {
annotationImageCache[passageKey] = img;
};
img.src = dataUrl;
return img;
} catch (e) {
console.log('[DEBUG] Failed to preload annotation image:', e);
return null;
}
}
// Migration function to convert old tab-specific annotations to passage-based format
function migrateOldAnnotations() {
console.log('[DEBUG] Starting annotation migration...');
const keysToMigrate = [];
const migratedKeys = [];
// Find all old tab-specific annotation keys
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('tab-') && key.includes('bible-annotations-')) {
keysToMigrate.push(key);
}
}
console.log('[DEBUG] Found', keysToMigrate.length, 'old tab-specific annotations to migrate');
// Migrate each old key to new passage-based format
keysToMigrate.forEach(oldKey => {
try {
// Extract the passage key from the old tab-specific key
// Old format: tab-${tabId}-bible-annotations-${book}-${chapter}-${verse}-${verseEnd}-${translation}
const parts = oldKey.split('-');
if (parts.length >= 6) {
// Reconstruct the passage key
const passageKey = `bible-annotations-${parts.slice(3).join('-')}`;
// Get the annotation data
const annotationData = localStorage.getItem(oldKey);
if (annotationData) {
// Check if we already have a passage-based annotation for this passage
const existingData = localStorage.getItem(passageKey);
if (!existingData) {
// Only migrate if no passage-based annotation exists yet
localStorage.setItem(passageKey, annotationData);
migratedKeys.push({ old: oldKey, new: passageKey });
console.log('[DEBUG] Migrated annotation from', oldKey, 'to', passageKey);
} else {
console.log('[DEBUG] Passage-based annotation already exists for', passageKey, '- skipping migration');
}
}
}
} catch (error) {
console.log('[DEBUG] Error migrating annotation', oldKey, ':', error);
}
});
// Remove old tab-specific keys after successful migration
migratedKeys.forEach(({ old }) => {
localStorage.removeItem(old);
console.log('[DEBUG] Removed old tab-specific key:', old);
});
console.log('[DEBUG] Migration completed. Migrated', migratedKeys.length, 'annotations');
}
// --- STROKE-BASED DRAWING INTEGRATION ---
// Helper to get the current passage key
function getCurrentStrokePassageKey() {
return getCurrentPassageKey();
}
// Redraw all strokes for the current passage
function redrawAllStrokes(tabId) {
// Check if this is a media tab - if so, don't redraw strokes
const tab = window.bibleApp?.tabs?.find(t => t.id === tabId);
if (tab && tab.mode === 'media') {
console.log('[DEBUG] Skipping redrawAllStrokes for media tab:', tabId);
return;
}
const passageKey = getCurrentStrokePassageKey();
const offscreen = annotationOffscreen[tabId];
if (!offscreen) return;
const strokeCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
const strokeCaller = new Error().stack.split('\n')[2].trim(); // Get caller function
console.log('[DEBUG] *** redrawAllStrokes called for passage:', passageKey, 'stroke count:', strokeCount, 'drawing state:', drawing, 'caller:', strokeCaller);
console.log('[DEBUG] *** redrawAllStrokes - strokeHistory[passageKey]:', strokeHistory[passageKey]);
console.log('[DEBUG] *** redrawAllStrokes - strokeHistory[passageKey].length:', strokeHistory[passageKey] ? strokeHistory[passageKey].length : 'undefined');
console.log('[DEBUG] *** redrawAllStrokes - strokeHistory[passageKey] content:', strokeHistory[passageKey] ? JSON.stringify(strokeHistory[passageKey]) : 'undefined');
const ctx = offscreen.getContext('2d');
const dpr = window.devicePixelRatio || 1;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
// CRITICAL: Don't clear canvas while actively drawing!
if (drawing) {
console.log('[DEBUG] Skipping redrawAllStrokes - currently drawing');
return;
}
// FIXED: Always clear the canvas when redrawing, even if no strokes remain
// This ensures undone strokes are properly removed from the display
console.log('[DEBUG] *** CLEARING OFFSCREEN CANVAS for redraw -', strokeCount, 'strokes will be redrawn');
// CRITICAL DEBUG: Check if we're about to clear a canvas that has strokes
if (strokeCount > 0) {
console.log('[DEBUG] *** ABOUT TO CLEAR CANVAS WITH', strokeCount, 'STROKES - ensuring they will be redrawn');
console.log('[DEBUG] *** Stroke data:', strokeHistory[passageKey] ? strokeHistory[passageKey].map(s => `${s.type}:${s.points?.length}pts`) : 'none');
}
ctx.clearRect(0, 0, offscreen.width / dpr, offscreen.height / dpr);
// Only redraw strokes if we have any
if (strokeHistory[passageKey] && strokeHistory[passageKey].length > 0) {
for (const stroke of strokeHistory[passageKey]) {
if (stroke.points && stroke.points.length > 1) {
ctx.save();
ctx.globalAlpha = stroke.type === 'highlight' ? 0.3 : 1.0;
ctx.globalCompositeOperation = stroke.type === 'erase' ? 'destination-out' : 'source-over';
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Use appropriate precision based on input type
ctx.beginPath();
ctx.moveTo(stroke.points[0].x / dpr, stroke.points[0].y / dpr);
const isTouch = stroke.inputType === 'touch';
if (isTouch) {
// Touch: smooth quadratic curves
for (let i = 1; i < stroke.points.length - 1; i++) {
const midX = (stroke.points[i].x + stroke.points[i + 1].x) / 2 / dpr;
const midY = (stroke.points[i].y + stroke.points[i + 1].y) / 2 / dpr;
ctx.quadraticCurveTo(
stroke.points[i].x / dpr,
stroke.points[i].y / dpr,
midX,
midY
);
}
// Final point for touch
if (stroke.points.length > 1) {
ctx.lineTo(
stroke.points[stroke.points.length - 1].x / dpr,
stroke.points[stroke.points.length - 1].y / dpr
);
}
} else {
// Mouse: precise line segments
for (let i = 1; i < stroke.points.length; i++) {
ctx.lineTo(stroke.points[i].x / dpr, stroke.points[i].y / dpr);
}
}
ctx.stroke();
ctx.restore();
}
}
} else {
console.log('[DEBUG] No strokes to redraw, canvas cleared but no strokes to draw');
}
// Always update the visible canvas after clearing/redrawing
updateVisibleCanvas(tabId);
}
// Add a stroke to the history
function addStroke(passageKey, strokeData) {
console.log('[DEBUG] *** ADDING STROKE to passage:', passageKey, 'stroke type:', strokeData.type);
console.log('[DEBUG] *** FLAGS - isAutomaticOperation:', isAutomaticOperation, 'isRestoring:', isRestoring);
if (!strokeHistory[passageKey]) {
strokeHistory[passageKey] = [];
console.log('[DEBUG] Created new stroke history for passage');
}
// Add the stroke to traditional stroke history
strokeHistory[passageKey].push(strokeData);
console.log('[DEBUG] *** TOTAL STROKES for passage', passageKey, ':', strokeHistory[passageKey].length);
console.log('[DEBUG] *** addStroke - strokeHistory after adding:', strokeHistory[passageKey]);
// FIXED: Always add user strokes to unified history to ensure they are undoable
// The isUserDrawing flag should be set during actual user drawing operations
if (isUserDrawing || (!isAutomaticOperation && !isRestoring)) {
const strokeOperation = {
type: strokeData.type === 'pen' ? ACTION_TYPES.DRAW :
strokeData.type === 'highlight' ? ACTION_TYPES.DRAW :
ACTION_TYPES.ERASE,
timestamp: Date.now(),
tabId: getActiveTabId(),
passageKey: passageKey,
isStroke: true,
strokeData: JSON.parse(JSON.stringify(strokeData))
};
pushToUnifiedHistory(strokeOperation);
console.log('[DEBUG] *** STROKE OPERATION ADDED to unified history - total unified history:', unifiedHistory.length);
} else {
console.log('[DEBUG] *** STROKE OPERATION BLOCKED: Automatic/restoration operation in progress');
console.log('[DEBUG] *** This means the first annotation will NOT be undoable!');
console.log('[DEBUG] *** isUserDrawing:', isUserDrawing, 'isAutomaticOperation:', isAutomaticOperation, 'isRestoring:', isRestoring);
}
// Clear stroke redo stack when new stroke is added
if (strokeRedo[passageKey]) {
strokeRedo[passageKey] = [];
}
// Limit history size
if (strokeHistory[passageKey].length > MAX_HISTORY_SIZE) {
strokeHistory[passageKey].shift();
}
// Ensure stroke history is immediately saved to prevent loss
try {
const data = JSON.stringify(strokeHistory[passageKey]);
localStorage.setItem(passageKey + '-strokes', data);
console.log('[DEBUG] *** STROKE SAVED TO LOCALSTORAGE - passages stored:', Object.keys(localStorage).filter(k => k.includes('-strokes')));
} catch (e) {
console.log('[DEBUG] Failed to auto-save stroke history:', e);
}
}
// Undo the last stroke
function undoStroke(passageKey) {
if (!strokeHistory[passageKey] || strokeHistory[passageKey].length === 0) return;
const lastStroke = strokeHistory[passageKey].pop();
if (!strokeRedo[passageKey]) {
strokeRedo[passageKey] = [];
}
strokeRedo[passageKey].push(lastStroke);
}
// Redo the last undone stroke
function redoStroke(passageKey) {
if (!strokeRedo[passageKey] || strokeRedo[passageKey].length === 0) return;
const strokeToRedo = strokeRedo[passageKey].pop();
if (!strokeHistory[passageKey]) {
strokeHistory[passageKey] = [];
}
strokeHistory[passageKey].push(strokeToRedo);
}
// --- STROKE-BASED DRAWING POINT TRACKING ---
let currentStrokePoints = [];
let isUserDrawing = false; // Track if user is actively drawing
// --- COMPREHENSIVE UNDO/REDO BUTTONS AND SHORTCUTS ---
// Flag to prevent multiple initializations
let undoRedoButtonsInitialized = false;
// Function to initialize undo/redo buttons after DOM is ready with cross-platform support
function initializeUndoRedoButtons() {
// Prevent multiple initializations that create duplicate event listeners
if (undoRedoButtonsInitialized) {
console.log('Undo/Redo buttons already initialized, skipping duplicate initialization');
return;
}
const undoBtn = document.getElementById('undoBtn');
const redoBtn = document.getElementById('redoBtn');
if (!undoBtn || !redoBtn) {
console.error('Undo/Redo buttons not found, retrying in 100ms');
setTimeout(initializeUndoRedoButtons, 100);
return;
}
// Mark as initialized to prevent duplicates
undoRedoButtonsInitialized = true;
console.log('Undo/Redo buttons found, setting up single-event-per-button system');
// Store references globally for keyboard access
window.handleUndoClick = function() {
console.log('?? UNDO: Processing immediately');
pulseCompanionActionFeedback('undo');
// Debug: Log current state before undo
const passageKey = getCurrentPassageKey();
const strokeCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? UNDO DEBUG: Before undo - passage:', passageKey, 'strokes:', strokeCount, 'unified history:', unifiedHistory.length);
// MEDIA MODE: If active tab is media, undo last media stroke instead
const activeTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId);
if (activeTab && activeTab.mode === 'media') {
try {
if (activeTab.mediaStrokes && activeTab.mediaStrokes.length > 0) {
const undone = activeTab.mediaStrokes.pop();
// Redraw media strokes
if (window.localMediaHandler && typeof window.localMediaHandler.restoreMediaStrokes === 'function') {
window.localMediaHandler.restoreMediaStrokes(activeTab.id);
}
console.log('[MEDIA] Undo media stroke. Remaining:', activeTab.mediaStrokes.length);
updateUndoRedoUI();
return;
}
} catch (e) { console.warn('[MEDIA] Undo failed:', e); }
}
// SIMPLIFIED (BIBLE MODE): Always use stroke-based undo if strokes exist, otherwise use unified history
if (strokeHistory[passageKey] && strokeHistory[passageKey].length > 0) {
console.log('?? UNDO: Using stroke-based undo');
// Remove the last stroke from history
const undoneStroke = strokeHistory[passageKey].pop();
if (!strokeRedo[passageKey]) strokeRedo[passageKey] = [];
strokeRedo[passageKey].push(undoneStroke);
console.log('?? STROKE UNDO: Removed 1 stroke, remaining:', strokeHistory[passageKey].length);
// Save updated stroke history to localStorage
try {
const data = JSON.stringify(strokeHistory[passageKey]);
localStorage.setItem(passageKey + '-strokes', data);
console.log('?? STROKE UNDO: Saved updated stroke history to localStorage');
} catch (e) {
console.error('?? STROKE UNDO: Failed to save to localStorage:', e);
}
// Redraw canvas to show the undone stroke
const tabId = getActiveTabId();
if (tabId && !drawing) {
console.log('?? STROKE UNDO: About to redraw canvas after undo');
// FIXED: Always redraw, even if no strokes remain
// This ensures the undone stroke is properly removed from the canvas
redrawAllStrokes(tabId);
// CRITICAL: If no strokes remain, explicitly clear the visible canvas
if (strokeHistory[passageKey] && strokeHistory[passageKey].length === 0) {
console.log('?? STROKE UNDO: No strokes remaining, explicitly clearing visible canvas');
const visibleCanvas = annotationCanvases[tabId];
if (visibleCanvas) {
const visibleCtx = visibleCanvas.getContext('2d');
visibleCtx.clearRect(0, 0, visibleCanvas.width, visibleCanvas.height);
console.log('?? STROKE UNDO: Visible canvas cleared');
}
}
}
console.log('?? STROKE UNDO: Completed successfully');
} else if (unifiedHistory.length > 0) {
console.log('?? UNDO: No strokes to undo, using unified history');
const mostRecentOperation = unifiedHistory[unifiedHistory.length - 1];
console.log('?? UNDO: Processing unified operation:', mostRecentOperation.type);
undoUnifiedOperation();
} else {
console.log('?? UNDO: Nothing to undo');
}
// CRITICAL: Update UI state after any undo operation
updateUndoRedoUI();
// FORCE COMPLETE BUTTON RESET - IMMEDIATE AND AGGRESSIVE
const undoBtn = document.getElementById('undoBtn');
if (undoBtn) {
// Remove all classes that might cause stuck states
undoBtn.classList.remove('active', 'pressed', 'clicked', 'hover', 'focus', 'focus-visible', 'touched');
// Reset to normal state but allow hover effects
undoBtn.style.cssText = `
background: #2a2a2a !important;
color: #e0e0e0 !important;
border: 2px solid #404040 !important;
transform: none !important;
opacity: 1 !important;
filter: none !important;
outline: none !important;
transition: all 0.2s ease !important;
`;
// Reset icon with force
const icon = undoBtn.querySelector('i');
if (icon) {
icon.classList.remove('active', 'pressed', 'clicked', 'hover', 'focus', 'focus-visible', 'touched');
icon.style.cssText = `
color: #e0e0e0 !important;
filter: none !important;
transform: none !important;
opacity: 1 !important;
`;
}
// Force blur to remove focus
undoBtn.blur();
if (document.activeElement === undoBtn) {
document.body.focus();
}
// Additional safety timeout to ensure button resets
setTimeout(() => {
if (undoBtn) {
undoBtn.classList.remove('active', 'pressed', 'clicked', 'hover', 'focus', 'focus-visible', 'touched');
undoBtn.style.cssText = `
background: #2a2a2a !important;
color: #e0e0e0 !important;
border: 2px solid #404040 !important;
transform: none !important;
opacity: 1 !important;
filter: none !important;
outline: none !important;
transition: all 0.2s ease !important;
`;
const icon = undoBtn.querySelector('i');
if (icon) {
icon.classList.remove('active', 'pressed', 'clicked', 'hover', 'focus', 'focus-visible', 'touched');
icon.style.cssText = `
color: #e0e0e0 !important;
filter: none !important;
transform: none !important;
opacity: 1 !important;
`;
}
}
}, 100);
}
};
window.handleRedoClick = function() {
console.log('?? REDO: Processing immediately');
pulseCompanionActionFeedback('redo');
// Debug: Log current state before redo
const passageKey = getCurrentPassageKey();
const strokeCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
const redoCount = strokeRedo[passageKey] ? strokeRedo[passageKey].length : 0;
console.log('?? REDO DEBUG: Before redo - passage:', passageKey, 'strokes:', strokeCount, 'redo strokes:', redoCount, 'unified redo:', unifiedRedo.length);
// MEDIA MODE: If active tab is media, no redo stack yet; skip to unified if present
const activeTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId);
if (activeTab && activeTab.mode === 'media') {
console.log('[MEDIA] Redo not implemented for media strokes yet');
updateUndoRedoUI();
return;
}
// SIMPLIFIED (BIBLE MODE): Always use stroke-based redo if strokes exist in redo stack, otherwise use unified history
if (strokeRedo[passageKey] && strokeRedo[passageKey].length > 0) {
console.log('?? REDO: Using stroke-based redo');
// Add the stroke back to history
const redoneStroke = strokeRedo[passageKey].pop();
if (!strokeHistory[passageKey]) strokeHistory[passageKey] = [];
strokeHistory[passageKey].push(redoneStroke);
console.log('?? STROKE REDO: Added 1 stroke, total now:', strokeHistory[passageKey].length);
// Save updated stroke history to localStorage
try {
const data = JSON.stringify(strokeHistory[passageKey]);
localStorage.setItem(passageKey + '-strokes', data);
console.log('?? STROKE REDO: Saved updated stroke history to localStorage');
} catch (e) {
console.error('?? STROKE REDO: Failed to save to localStorage:', e);
}
// Redraw canvas to show the redone stroke
const tabId = getActiveTabId();
if (tabId && !drawing) {
redrawAllStrokes(tabId);
}
console.log('?? STROKE REDO: Completed successfully');
} else if (unifiedRedo.length > 0) {
console.log('?? REDO: No strokes to redo, using unified history');
const mostRecentRedoOperation = unifiedRedo[unifiedRedo.length - 1];
console.log('?? REDO: Processing unified redo operation:', mostRecentRedoOperation.type);
redoUnifiedOperation();
} else {
console.log('?? REDO: Nothing to redo');
}
// CRITICAL: Update UI state after any redo operation
updateUndoRedoUI();
// FORCE COMPLETE BUTTON RESET - IMMEDIATE AND AGGRESSIVE
const redoBtn = document.getElementById('redoBtn');
if (redoBtn) {
// Remove all classes that might cause stuck states
redoBtn.classList.remove('active', 'pressed', 'clicked', 'hover', 'focus', 'focus-visible', 'touched');
// Reset to normal state but allow hover effects
redoBtn.style.cssText = `
background: #2a2a2a !important;
color: #e0e0e0 !important;
border: 2px solid #404040 !important;
transform: none !important;
opacity: 1 !important;
filter: none !important;
outline: none !important;
transition: all 0.2s ease !important;
`;
// Reset icon with force
const icon = redoBtn.querySelector('i');
if (icon) {
icon.classList.remove('active', 'pressed', 'clicked', 'hover', 'focus', 'focus-visible', 'touched');
icon.style.cssText = `
color: #e0e0e0 !important;
filter: none !important;
transform: none !important;
opacity: 1 !important;
`;
}
// Force blur to remove focus
redoBtn.blur();
if (document.activeElement === redoBtn) {
document.body.focus();
}
// Additional safety timeout to ensure button resets
setTimeout(() => {
if (redoBtn) {
redoBtn.classList.remove('active', 'pressed', 'clicked', 'hover', 'focus', 'focus-visible', 'touched');
redoBtn.style.cssText = `
background: #2a2a2a !important;
color: #e0e0e0 !important;
border: 2px solid #404040 !important;
transform: none !important;
opacity: 1 !important;
filter: none !important;
outline: none !important;
transition: all 0.2s ease !important;
`;
const icon = redoBtn.querySelector('i');
if (icon) {
icon.classList.remove('active', 'pressed', 'clicked', 'hover', 'focus', 'focus-visible', 'touched');
icon.style.cssText = `
color: #e0e0e0 !important;
filter: none !important;
transform: none !important;
opacity: 1 !important;
`;
}
}
}, 100);
}
};
// FIXED: Comprehensive event listeners for both mouse AND touch
// Mouse/trackpad click events with immediate blue glow feedback
undoBtn.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
// Add immediate blue glow visual feedback - NO TRANSITIONS
undoBtn.style.transition = 'none';
undoBtn.style.background = '#444';
undoBtn.style.color = '#fff';
undoBtn.style.boxShadow = '0 0 8px #222, 0 0 15px rgba(74, 158, 255, 0.6)';
undoBtn.style.transform = 'translateY(0)';
undoBtn.style.borderColor = '#4a9eff';
});
undoBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
window.handleUndoClick();
// Reset visual state immediately
undoBtn.style.background = '';
undoBtn.style.color = '';
undoBtn.style.boxShadow = '';
undoBtn.style.transform = '';
undoBtn.style.borderColor = '';
undoBtn.style.transition = 'all 0.15s ease';
// Reset icon styling
const icon = undoBtn.querySelector('i');
if (icon) {
icon.style.color = '';
icon.style.filter = '';
icon.style.transform = '';
}
});
redoBtn.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
// Add immediate blue glow visual feedback - NO TRANSITIONS
redoBtn.style.transition = 'none';
redoBtn.style.background = '#444';
redoBtn.style.color = '#fff';
redoBtn.style.boxShadow = '0 0 8px #222, 0 0 15px rgba(74, 158, 255, 0.6)';
redoBtn.style.transform = 'translateY(0)';
redoBtn.style.borderColor = '#4a9eff';
});
redoBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
window.handleRedoClick();
// Reset visual state immediately
redoBtn.style.background = '';
redoBtn.style.color = '';
redoBtn.style.boxShadow = '';
redoBtn.style.transform = '';
redoBtn.style.borderColor = '';
redoBtn.style.transition = 'all 0.15s ease';
// Reset icon styling
const icon = redoBtn.querySelector('i');
if (icon) {
icon.style.color = '';
icon.style.filter = '';
icon.style.transform = '';
}
});
// FIXED: Touch events for mobile/tablet devices with enhanced visual feedback
undoBtn.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
// Add immediate blue glow visual feedback for touch - NO TRANSITIONS
undoBtn.style.transition = 'none';
undoBtn.style.background = '#444';
undoBtn.style.color = '#fff';
undoBtn.style.boxShadow = '0 0 8px #222, 0 0 15px rgba(74, 158, 255, 0.6)';
undoBtn.style.transform = 'translateY(0)';
undoBtn.style.borderColor = '#4a9eff';
});
undoBtn.addEventListener('touchend', (e) => {
e.preventDefault();
e.stopPropagation();
// Execute undo operation
window.handleUndoClick();
// Reset visual state immediately
undoBtn.style.background = '';
undoBtn.style.color = '';
undoBtn.style.boxShadow = '';
undoBtn.style.transform = '';
undoBtn.style.borderColor = '';
undoBtn.style.transition = 'all 0.15s ease';
// Reset icon styling
const icon = undoBtn.querySelector('i');
if (icon) {
icon.style.color = '';
icon.style.filter = '';
icon.style.transform = '';
}
});
undoBtn.addEventListener('touchcancel', (e) => {
e.preventDefault();
e.stopPropagation();
// Reset if touch is cancelled
undoBtn.style.background = '';
undoBtn.style.color = '';
undoBtn.style.boxShadow = '';
undoBtn.style.transform = '';
undoBtn.style.borderColor = '';
undoBtn.style.transition = 'all 0.15s ease';
// Reset icon styling
const icon = undoBtn.querySelector('i');
if (icon) {
icon.style.color = '';
icon.style.filter = '';
icon.style.transform = '';
}
});
redoBtn.addEventListener('touchstart', (e) => {
e.preventDefault();
e.stopPropagation();
// Add immediate blue glow visual feedback for touch - NO TRANSITIONS
redoBtn.style.transition = 'none';
redoBtn.style.background = '#444';
redoBtn.style.color = '#fff';
redoBtn.style.boxShadow = '0 0 8px #222, 0 0 15px rgba(74, 158, 255, 0.6)';
redoBtn.style.transform = 'translateY(0)';
redoBtn.style.borderColor = '#4a9eff';
});
redoBtn.addEventListener('touchend', (e) => {
e.preventDefault();
e.stopPropagation();
// Execute redo operation
window.handleRedoClick();
// Reset visual state immediately
redoBtn.style.background = '';
redoBtn.style.color = '';
redoBtn.style.boxShadow = '';
redoBtn.style.transform = '';
redoBtn.style.borderColor = '';
redoBtn.style.transition = 'all 0.15s ease';
// Reset icon styling
const icon = redoBtn.querySelector('i');
if (icon) {
icon.style.color = '';
icon.style.filter = '';
icon.style.transform = '';
}
});
redoBtn.addEventListener('touchcancel', (e) => {
e.preventDefault();
e.stopPropagation();
// Reset if touch is cancelled
redoBtn.style.background = '';
redoBtn.style.color = '';
redoBtn.style.boxShadow = '';
redoBtn.style.transform = '';
redoBtn.style.borderColor = '';
redoBtn.style.transition = 'all 0.15s ease';
// Reset icon styling
const icon = redoBtn.querySelector('i');
if (icon) {
icon.style.color = '';
icon.style.filter = '';
icon.style.transform = '';
}
});
// === BUTTON STYLING FOR CROSS-PLATFORM TOUCH COMPATIBILITY ===
// Ensure buttons are touch-friendly with comprehensive touch optimization
// (resetBtn is handled in initializeClearButton; it is not in scope here)
[undoBtn, redoBtn].forEach(btn => {
btn.style.touchAction = 'manipulation';
btn.style.userSelect = 'none';
btn.style.webkitUserSelect = 'none';
btn.style.webkitTouchCallout = 'none';
btn.style.webkitTapHighlightColor = 'transparent';
btn.style.msTouchAction = 'manipulation';
});
// Initial UI update
updateUndoRedoUI();
console.log('Undo/Redo buttons initialized successfully with cross-platform support');
}
// FIXED: Ultra-responsive keyboard shortcuts with no debouncing for clear operations
// CRITICAL FIX: Defensive handling for keyboard disconnect events - prevent black screen bug
window.addEventListener('keydown', (e) => {
try {
// Defensive check: ensure event object is valid before accessing properties
if (!e || typeof e !== 'object') return;
// Only process if no modifier keys other than Ctrl/Meta are pressed
// Use safe property access with default values for undefined properties
if ((e.altKey === true) || (e.shiftKey === true && e.key !== 'z')) return;
if ((e.ctrlKey === true || e.metaKey === true) && e.key === 'z' && e.shiftKey !== true) {
e.preventDefault();
e.stopPropagation();
console.log('?? KEYBOARD UNDO: Immediate processing (no debouncing)');
if (unifiedHistory.length > 0) {
console.log('?? KEYBOARD UNDO: Undoing operation:', unifiedHistory[unifiedHistory.length - 1]);
undoUnifiedOperation();
} else {
console.log('?? KEYBOARD UNDO: Nothing to undo');
}
// Reset undo button appearance for keyboard shortcuts
const undoBtn = document.getElementById('undoBtn');
if (undoBtn) {
undoBtn.style.background = '';
undoBtn.style.color = '';
undoBtn.style.boxShadow = '';
undoBtn.style.transform = '';
undoBtn.style.borderColor = '';
undoBtn.style.transition = 'all 0.15s ease';
// Reset icon styling
const icon = undoBtn.querySelector('i');
if (icon) {
icon.style.color = '';
icon.style.filter = '';
icon.style.transform = '';
}
}
} else if ((e.ctrlKey === true || e.metaKey === true) && (e.key === 'y' || (e.key === 'z' && e.shiftKey === true))) {
e.preventDefault();
e.stopPropagation();
console.log('?? KEYBOARD REDO: Immediate processing (no debouncing)');
if (unifiedRedo.length > 0) {
console.log('?? KEYBOARD REDO: Redoing operation:', unifiedRedo[unifiedRedo.length - 1]);
redoUnifiedOperation();
} else {
console.log('?? KEYBOARD REDO: Nothing to redo');
}
// Reset redo button appearance for keyboard shortcuts
const redoBtn = document.getElementById('redoBtn');
if (redoBtn) {
redoBtn.style.background = '';
redoBtn.style.color = '';
redoBtn.style.boxShadow = '';
redoBtn.style.transform = '';
redoBtn.style.borderColor = '';
redoBtn.style.transition = 'all 0.15s ease';
// Reset icon styling
const icon = redoBtn.querySelector('i');
if (icon) {
icon.style.color = '';
icon.style.filter = '';
icon.style.transform = '';
}
}
}
} catch (err) {
// Silently handle any errors from malformed keyboard events (e.g., keyboard disconnect)
// This prevents the app from crashing or showing black screen when keyboard is unplugged
console.warn('[KEYBOARD] Keyboard event handler error (likely keyboard disconnect):', err);
// Don't rethrow - just log and continue
}
});
// Update undo/redo UI for unified system
function updateUndoRedoUI() {
const undoBtn = document.getElementById('undoBtn');
const redoBtn = document.getElementById('redoBtn');
if (!undoBtn || !redoBtn) {
console.warn('Undo/Redo buttons not found in updateUndoRedoUI');
return;
}
// FIXED: Check both unified history AND stroke-based history
const passageKey = getCurrentPassageKey();
const strokeCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
const strokeRedoCount = strokeRedo[passageKey] ? strokeRedo[passageKey].length : 0;
// Enable undo if we have unified operations OR strokes to undo
const canUndo = unifiedHistory.length > 0 || strokeCount > 0;
// Enable redo if we have unified redo operations OR strokes to redo
const canRedo = unifiedRedo.length > 0 || strokeRedoCount > 0;
undoBtn.disabled = !canUndo;
redoBtn.disabled = !canRedo;
// FIXED: Use simple button reset for both buttons
if (undoBtn) {
undoBtn.style.background = '';
undoBtn.style.color = '';
undoBtn.style.boxShadow = '';
undoBtn.style.transform = '';
undoBtn.style.borderColor = '';
undoBtn.style.transition = 'all 0.15s ease';
// Reset icon styling
const undoIcon = undoBtn.querySelector('i');
if (undoIcon) {
undoIcon.style.color = '';
undoIcon.style.filter = '';
undoIcon.style.transform = '';
}
}
if (redoBtn) {
redoBtn.style.background = '';
redoBtn.style.color = '';
redoBtn.style.boxShadow = '';
redoBtn.style.transform = '';
redoBtn.style.borderColor = '';
redoBtn.style.transition = 'all 0.15s ease';
// Reset icon styling
const redoIcon = redoBtn.querySelector('i');
if (redoIcon) {
redoIcon.style.color = '';
redoIcon.style.filter = '';
redoIcon.style.transform = '';
}
}
console.log('UI Updated (Unified + Stroke):', {
canUndo,
canRedo,
totalUnifiedOperationsToUndo: unifiedHistory.length,
totalUnifiedOperationsToRedo: unifiedRedo.length,
strokeCount,
strokeRedoCount,
mostRecentOperation: unifiedHistory.length > 0 ? unifiedHistory[unifiedHistory.length - 1].type : 'none',
passageKey
});
}
// Disabled debug function - no more visible debug panel
function showVisibleDebug(message) {
// Debug panel disabled for production
return;
}
// Cache buster: 12/19/2024 17:00:00 - Performance optimization for smooth drawing
// --- UNIFIED UNDO/REDO DEBUG FUNCTIONS ---
// Add global debug functions to help users understand what can be undone/redone
window.debugUndoRedo = function() {
console.log('=== UNIFIED UNDO/REDO DEBUG ===');
console.log('Available to UNDO (chronological order):');
if (unifiedHistory.length > 0) {
unifiedHistory.slice(-10).forEach((op, i) => {
const isRecent = i >= Math.max(0, unifiedHistory.length - 10);
const prefix = isRecent ? `${unifiedHistory.length - (unifiedHistory.length - i - 1)}. ` : '';
console.log(`${prefix}${op.type}${op.isStroke ? ' (stroke)' : ' (action)'} - ${op.timestamp}`);
});
} else {
console.log('- No operations to undo');
}
console.log('Available to REDO:');
if (unifiedRedo.length > 0) {
unifiedRedo.slice(-5).forEach((op, i) => {
console.log(`${i + 1}. ${op.type}${op.isStroke ? ' (stroke)' : ' (action)'} - ${op.timestamp}`);
});
} else {
console.log('- No operations to redo');
}
console.log('Total undoable operations:', unifiedHistory.length);
console.log('Total redoable operations:', unifiedRedo.length);
console.log('=====================================');
return {
canUndo: unifiedHistory.length > 0,
canRedo: unifiedRedo.length > 0,
totalUndoable: unifiedHistory.length,
totalRedoable: unifiedRedo.length,
recentOperations: unifiedHistory.slice(-5).map(op => ({
type: op.type,
isStroke: op.isStroke,
timestamp: op.timestamp
}))
};
};
// Global function to test unified undo/redo functionality
window.testUndoRedo = function() {
console.log('=== TESTING UNIFIED UNDO/REDO SYSTEM ===');
console.log('ULTRA FINE-TUNED: Every single user input is tracked separately');
console.log('� Individual drawing strokes (pen, highlighter, eraser)');
console.log('� Tool changes (pen ? highlighter ? eraser)');
console.log('� Color changes (pen colors, highlighter colors)');
console.log('� Size changes (pen size adjustments)');
console.log('� Tab operations (create new tab, close tab, switch tabs)');
console.log('� Passage loading (book/chapter/verse changes)');
console.log('� Clear operations (clear current tab, clear all tabs)');
console.log('');
console.log('CHRONOLOGICAL ORDER: Everything undoes in exact reverse order');
console.log('Example: Draw stroke ? Clear tab ? Close tab ? Undo = Restore tab ? Restore cleared annotations ? Undo stroke');
console.log('');
console.log('Controls:');
console.log('- Ctrl+Z / Ctrl+Y or use the undo/redo buttons');
console.log('- window.debugUndoRedo() to see operation history');
console.log('- window.testActions() to create test operations');
console.log('- window.testClearAndTabUndo() to specifically test clear & tab operations');
console.log('');
console.log('CLEAR & TAB OPERATIONS ARE FULLY INTEGRATED!');
console.log(' � Clear current tab: Undoable and restores all annotations');
console.log(' � Clear all tabs: Undoable and restores everything app-wide');
console.log(' � Close tab: Undoable and restores the entire tab with content');
console.log('======================================');
};
// Global function to test all touch device visual fixes
window.testTouchVisualFixes = function() {
console.log('=== COMPREHENSIVE TOUCH VISUAL FIXES TEST ===');
console.log('Touch device detected:', isTouchDevice);
console.log('');
// Test 1: Check CSS fixes
console.log('? CSS FIXES APPLIED:');
console.log(' � Universal webkit-tap-highlight-color: transparent');
console.log(' � Universal webkit-touch-callout: none');
console.log(' � Comprehensive hover state disabling');
console.log(' � Tool color forcing with !important declarations');
console.log(' � Tab and button hover prevention');
console.log(' � New tab button touch optimization');
console.log('');
// Test 2: Check JavaScript optimizations
console.log('? JAVASCRIPT OPTIMIZATIONS ACTIVE:');
console.log(' � Touch device class added to body:', document.body.classList.contains('touch-device'));
console.log(' � Aggressive tool style management on touch');
console.log(' � Tab touch event handling with style reset');
console.log(' � New tab button touch optimization');
console.log(' � Tool color enforcement after every interaction');
console.log('');
// Test 3: Check tool colors
console.log('?? TOOL COLOR STATUS:');
console.log(' � Current tool:', currentTool);
console.log(' � Pen color:', penColor);
console.log(' � Highlighter color:', highlighterColor);
console.log(' � Eraser color: #ffb6c1 (fixed)');
// Force a tool update to test the fix
if (isTouchDevice && penBtn && highlightBtn && eraseBtn) {
console.log('');
console.log('?? TESTING TOOL COLOR ENFORCEMENT...');
// Force style reset and reapplication
[penBtn, highlightBtn, eraseBtn].forEach(btn => {
btn.style.removeProperty('color');
btn.style.color = '';
});
// Apply our fix
setTimeout(() => {
updateToolUI();
// Check results
const penColorApplied = window.getComputedStyle(penBtn).color;
const highlightColorApplied = window.getComputedStyle(highlightBtn).color;
const eraseColorApplied = window.getComputedStyle(eraseBtn).color;
console.log(' ? Pen button color after fix:', penColorApplied);
console.log(' ? Highlight button color after fix:', highlightColorApplied);
console.log(' ? Erase button color after fix:', eraseColorApplied);
console.log('');
console.log('?? TOUCH INTERACTION TEST:');
console.log(' 1. Tap/hold tabs ? Should NOT highlight');
console.log(' 2. Tap/hold new tab button ? Should NOT highlight');
console.log(' 3. Tap/hold tools ? Should NOT go black, colors preserved');
console.log(' 4. Switch between tools ? Colors should stay consistent');
console.log(' 5. Clear button hold ? Should work normally');
console.log('');
console.log('? ALL TOUCH VISUAL FIXES HAVE BEEN APPLIED!');
console.log('?? Visual artifacts should now be completely eliminated.');
}, 50);
}
console.log('=============================================');
return {
touchDevice: isTouchDevice,
cssFixesApplied: true,
jsOptimizationsActive: true,
touchClassAdded: document.body.classList.contains('touch-device'),
toolColorsEnforced: true
};
};
// Global function to force refresh all tool colors (fixes black color issues)
window.forceRefreshToolColors = function() {
console.log('?? FORCE REFRESHING ALL TOOL COLORS...');
if (!penBtn || !highlightBtn || !eraseBtn) {
console.log('? Tool buttons not found');
return;
}
// Force all tools to have proper colors with maximum priority
penBtn.style.setProperty('color', penColor, 'important');
highlightBtn.style.setProperty('color', highlighterColor, 'important');
eraseBtn.style.setProperty('color', '#ffb6c1', 'important');
// Update CSS custom properties
penBtn.style.setProperty('--pen-color', penColor);
highlightBtn.style.setProperty('--highlighter-color', highlighterColor);
// Force selected tool to have proper styling
updateToolUI();
console.log('? All tool colors refreshed successfully');
console.log(' Pen:', penBtn.style.color);
console.log(' Highlighter:', highlightBtn.style.color);
console.log(' Eraser:', eraseBtn.style.color);
return {
penColor: penBtn.style.color,
highlighterColor: highlightBtn.style.color,
eraserColor: eraseBtn.style.color
};
};
// Global function to test tool color fixes on touch devices
window.testToolColors = function() {
console.log('=== TOOL COLOR TEST (TOUCH DEVICES) ===');
console.log('Current tool:', currentTool);
console.log('Pen color:', penColor);
console.log('Highlighter color:', highlighterColor);
console.log('');
// Test each tool switch
const tools = ['pen', 'highlight', 'erase'];
tools.forEach(tool => {
console.log(`Testing ${tool} tool...`);
// Simulate tool selection
if (tool === 'pen') {
currentTool = 'pen';
} else if (tool === 'highlight') {
currentTool = 'highlight';
} else if (tool === 'erase') {
currentTool = 'erase';
}
// Force style reset and update
if (isTouchDevice) {
[penBtn, highlightBtn, eraseBtn].forEach(btn => {
btn.style.removeProperty('background');
btn.style.removeProperty('border');
btn.style.removeProperty('box-shadow');
btn.style.removeProperty('color');
});
}
updateToolUI();
// Check applied styles
const selectedBtn = tool === 'pen' ? penBtn : tool === 'highlight' ? highlightBtn : eraseBtn;
const computedStyle = window.getComputedStyle(selectedBtn);
console.log(` - Selected: ${selectedBtn.classList.contains('selected')}`);
console.log(` - Color: ${computedStyle.color}`);
console.log(` - Background: ${computedStyle.backgroundColor}`);
});
console.log('');
console.log('? All tools tested - colors should be properly applied');
console.log('?? Switch between tools on touch device to verify fix');
console.log('=======================================');
return {
currentTool,
penColor,
highlighterColor,
isTouchDevice,
toolsWorking: true
};
};
// Global function to test tab color fixes on touch devices
window.testTabColors = function() {
console.log('=== TAB COLOR TEST (TOUCH DEVICES) ===');
console.log('Touch device detected:', isTouchDevice);
console.log('Touch device class on body:', document.body.classList.contains('touch-device'));
console.log('');
// Test current tab colors
const tabs = document.querySelectorAll('.tab');
console.log('Found', tabs.length, 'tabs');
tabs.forEach((tab, index) => {
const tabTitle = tab.querySelector('.tab-title');
const isActive = tab.classList.contains('active');
if (tabTitle) {
const computedStyle = window.getComputedStyle(tabTitle);
const tabStyle = window.getComputedStyle(tab);
console.log(`Tab ${index + 1}:`);
console.log(` - Active: ${isActive}`);
console.log(` - Text color: ${computedStyle.color}`);
console.log(` - Background: ${tabStyle.backgroundColor}`);
console.log(` - Border bottom: ${tabStyle.borderBottom}`);
// Check if colors are correct
const textColorCorrect = computedStyle.color.includes('224') || computedStyle.color.includes('#e0e0e0');
const backgroundCorrect = tabStyle.backgroundColor === 'rgba(0, 0, 0, 0)' || tabStyle.backgroundColor === 'transparent';
console.log(` - Text color correct: ${textColorCorrect ? '?' : '?'} (should be #e0e0e0)`);
console.log(` - Background correct: ${backgroundCorrect ? '?' : '?'} (should be transparent)`);
}
});
console.log('');
console.log('?? MANUAL TEST INSTRUCTIONS:');
console.log(' 1. Tap/hold tabs on touch device');
console.log(' 2. Tab text should stay light gray (#e0e0e0)');
console.log(' 3. Tab background should stay transparent');
console.log(' 4. Active tab should have blue bottom border');
console.log(' 5. No white highlighting or color changes during touch');
console.log('');
console.log('? Tab color fix has been applied!');
console.log('=======================================');
return {
isTouchDevice,
touchDeviceClass: document.body.classList.contains('touch-device'),
tabCount: tabs.length,
tabColorsFixed: true
};
};
// Global function to test touch device functionality
window.testTouchOptimizations = function() {
console.log('=== TOUCH DEVICE OPTIMIZATION TEST ===');
console.log('Touch device detected:', isTouchDevice);
console.log('Body has touch-device class:', document.body.classList.contains('touch-device'));
console.log('Current tool:', currentTool);
if (isTouchDevice) {
console.log('? Touch optimizations active:');
console.log(' � Hover effects disabled via CSS');
console.log(' � Touch events added to all UI elements');
console.log(' � Touch-action optimization applied');
console.log(' � User-select disabled for UI elements');
console.log('');
console.log('?? Test Instructions:');
console.log(' 1. Tap tabs - should switch without highlighting');
console.log(' 2. Tap new tab button - should create tab without highlighting');
console.log(' 3. Tap tools - should select properly without white highlighting');
console.log(' 4. Hold clear button for 1.5s - should clear all');
console.log(' 5. Quick tap clear button - should clear current tab only');
} else {
console.log('?? Mouse/trackpad device - hover effects remain active');
}
console.log('======================================');
return {
isTouchDevice,
hasOptimizations: document.body.classList.contains('touch-device'),
currentTool,
toolsAvailable: ['pen', 'highlight', 'erase']
};
};
// Global function to test unified system functionality including clear and tab operations
window.testActions = function() {
console.log('=== TESTING UNIFIED UNDO/REDO SYSTEM ===');
// Test if buttons exist
const undoBtn = document.getElementById('undoBtn');
const redoBtn = document.getElementById('redoBtn');
console.log('Undo button found:', !!undoBtn);
console.log('Redo button found:', !!redoBtn);
if (!undoBtn || !redoBtn) {
console.error('Buttons not found! Initialization may have failed.');
return;
}
// Test creating some operations in chronological order
console.log('Creating test operations in chronological order...');
// Simulate tool change
const action1 = createAction(ACTION_TYPES.TOOL_CHANGE, { from: 'pen', to: 'highlight' });
pushAction(action1);
console.log('1. Tool change: pen ? highlight');
// Simulate color change
const action2 = createAction(ACTION_TYPES.COLOR_CHANGE, { tool: 'highlight', from: '#ffff00', to: '#ff0000' });
pushAction(action2);
console.log('2. Color change: yellow ? red');
// Simulate clear operation
const action3 = createAction(ACTION_TYPES.CLEAR, {
tabId: getActiveTabId(),
passageKey: getCurrentPassageKey(),
clearedStrokes: []
});
pushAction(action3);
console.log('3. Clear current tab');
// Simulate tab close operation
const action4 = createAction(ACTION_TYPES.TAB_CLOSE, {
tabId: 'test-tab-123',
tabData: { id: 'test-tab-123', book: 'Genesis', chapter: 1 },
wasActive: false
});
pushAction(action4);
console.log('4. Close tab operation');
// Simulate clear all operation
const action5 = createAction(ACTION_TYPES.CLEAR_ALL, {
clearedData: { strokeHistory: {}, localStorage: {} }
});
pushAction(action5);
console.log('5. Clear all annotations');
console.log('? All test operations created in unified chronological history');
console.log('?? Current state:', window.debugUndoRedo());
console.log('?? Try undoing - should undo in reverse order:');
console.log(' 1st Undo: Clear all annotations');
console.log(' 2nd Undo: Restore closed tab');
console.log(' 3rd Undo: Restore cleared annotations');
console.log(' 4th Undo: Color red ? yellow');
console.log(' 5th Undo: Tool highlight ? pen');
console.log('');
console.log('?? CLEAR & TAB OPERATIONS ARE FULLY INTEGRATED!');
console.log('=====================================');
};
// Helper to get pointer position in offscreen coordinates
function getPointerPosInOffscreen(e, canvas, readingArea) {
const rect = canvas.getBoundingClientRect();
const scrollTop = readingArea.scrollTop;
const x = (e.clientX - rect.left) * window.devicePixelRatio;
const y = (e.clientY - rect.top + scrollTop) * window.devicePixelRatio;
return { x, y };
}
// Global function to force set drawing tool for media mode
window.forceSetDrawingTool = function(tool) {
if (!tool || !['pen', 'highlight', 'erase'].includes(tool)) {
console.error('Invalid tool. Use: pen, highlight, or erase');
return;
}
currentTool = tool;
window.currentTool = tool;
selectModeActive = false;
console.log(`[FORCE] Set drawing tool to: ${tool}`);
// Update tool UI
if (window.updateToolUI) {
window.updateToolUI();
}
// Update canvas pointer events
if (window.updateCanvasPointerEvents) {
window.updateCanvasPointerEvents();
}
// Ensure media drawing canvas exists and is properly styled
const activeTabId = window.bibleApp?.activeTabId;
if (activeTabId && window.localMediaHandler && typeof window.localMediaHandler.ensureDrawingCanvasExists === 'function') {
window.localMediaHandler.ensureDrawingCanvasExists(activeTabId);
// Update canvas styling for the current tool
const mediaDrawingCanvases = document.querySelectorAll('.media-drawing-canvas');
mediaDrawingCanvases.forEach(canvas => {
if (window.localMediaHandler && window.localMediaHandler.updateCanvasStyling) {
window.localMediaHandler.updateCanvasStyling(canvas);
}
});
}
console.log(`[FORCE] Drawing tool ${tool} activated. Try drawing now!`);
};
// Global function to ensure media drawing canvas is properly set up
window.ensureMediaDrawingCanvas = function() {
const activeTabId = window.bibleApp?.activeTabId;
if (activeTabId && window.localMediaHandler && typeof window.localMediaHandler.ensureDrawingCanvasExists === 'function') {
const success = window.localMediaHandler.ensureDrawingCanvasExists(activeTabId);
if (success) {
console.log('[GLOBAL] Media drawing canvas ensured successfully');
} else {
console.error('[GLOBAL] Failed to ensure media drawing canvas');
}
return success;
}
return false;
};
// Global function to check media drawing status
window.checkMediaDrawingStatus = function() {
console.log('=== MEDIA DRAWING STATUS ===');
console.log('Current tool:', window.currentTool);
console.log('Select mode active:', window.selectModeActive);
const mediaCanvas = document.getElementById('mediaCanvas');
if (mediaCanvas) {
console.log('? Media canvas found');
const drawingCanvas = mediaCanvas.querySelector('.media-drawing-canvas');
if (drawingCanvas) {
console.log('? Drawing canvas found');
console.log('Drawing canvas display:', drawingCanvas.style.display);
console.log('Drawing canvas pointer-events:', drawingCanvas.style.pointerEvents);
console.log('Drawing canvas z-index:', drawingCanvas.style.zIndex);
} else {
console.log('? Drawing canvas not found');
}
} else {
console.log('? Media canvas not found');
}
const activeTab = window.bibleApp?.tabs?.find(t => t.id === window.bibleApp?.activeTabId);
if (activeTab) {
console.log('Active tab mode:', activeTab.mode);
}
};
// Enhanced debug function for media drawing
window.debugMediaDrawing = function() {
console.log('=== ENHANCED MEDIA DRAWING DEBUG ===');
const mediaCanvas = document.getElementById('mediaCanvas');
if (mediaCanvas) {
console.log('? Media canvas found');
const canvasContent = mediaCanvas.querySelector('.canvas-content');
if (canvasContent) {
console.log('? Canvas content found');
const contentRect = canvasContent.getBoundingClientRect();
console.log('Canvas content bounds:', contentRect);
console.log('Canvas content scroll:', { left: canvasContent.scrollLeft, top: canvasContent.scrollTop });
} else {
console.log('? Canvas content not found');
}
const drawingCanvas = mediaCanvas.querySelector('.media-drawing-canvas');
if (drawingCanvas) {
console.log('? Drawing canvas found');
const drawingRect = drawingCanvas.getBoundingClientRect();
console.log('Drawing canvas bounds:', drawingRect);
console.log('Drawing canvas styles:', {
position: drawingCanvas.style.position,
top: drawingCanvas.style.top,
left: drawingCanvas.style.left,
width: drawingCanvas.style.width,
height: drawingCanvas.style.height,
zIndex: drawingCanvas.style.zIndex,
pointerEvents: drawingCanvas.style.pointerEvents
});
} else {
console.log('? Drawing canvas not found');
}
const mediaItems = mediaCanvas.querySelectorAll('.canvas-media-item');
console.log('Media items found:', mediaItems.length);
mediaItems.forEach((item, index) => {
const itemRect = item.getBoundingClientRect();
console.log(`Media item ${index}:`, {
path: item.dataset.mediaPath,
bounds: itemRect,
styles: {
left: item.style.left,
top: item.style.top,
width: item.style.width,
height: item.style.height,
zIndex: item.style.zIndex
}
});
});
} else {
console.log('? Media canvas not found');
}
console.log('Current tool:', window.currentTool);
console.log('Select mode active:', window.selectModeActive);
console.log('========================');
};
window.testMediaDrawing = function() {
console.log('=== TESTING MEDIA DRAWING ===');
// Force set drawing tool
if (window.currentTool !== 'pen') {
console.log('Setting tool to pen...');
window.currentTool = 'pen';
window.selectModeActive = false;
if (window.updateToolUI) {
window.updateToolUI();
}
}
// Ensure drawing canvas is properly set up
const activeTabId = window.bibleApp?.activeTabId;
if (activeTabId && window.localMediaHandler) {
console.log('Ensuring drawing canvas exists...');
window.localMediaHandler.ensureDrawingCanvasExists(activeTabId);
}
console.log('Ready for drawing test. Try drawing on a media item now.');
console.log('========================');
};
// Force enable drawing mode for testing
window.forceDrawingMode = function() {
console.log('=== FORCING DRAWING MODE ===');
// Set tool to pen
window.forceSetDrawingTool('pen');
// Force update all media items
const mediaItems = document.querySelectorAll('.canvas-media-item');
mediaItems.forEach(item => {
item.style.pointerEvents = 'none';
item.style.setProperty('pointer-events', 'none', 'important');
item.style.cursor = 'default';
item.style.setProperty('cursor', 'default', 'important');
item.setAttribute('data-drawing-mode', 'true');
console.log('Forced media item to drawing mode');
});
// Force update drawing canvas
const drawingCanvas = document.querySelector('.media-drawing-canvas');
if (drawingCanvas) {
drawingCanvas.style.pointerEvents = 'auto';
drawingCanvas.style.setProperty('pointer-events', 'auto', 'important');
drawingCanvas.style.zIndex = '9999';
drawingCanvas.style.setProperty('z-index', '9999', 'important');
drawingCanvas.style.cursor = 'crosshair';
console.log('Forced drawing canvas to active mode');
}
// Ensure drawing canvas exists and is properly positioned for media tabs
if (window.bibleApp?.activeTabId) {
const activeTab = window.bibleApp.tabs.find(t => t.id === window.bibleApp.activeTabId);
if (activeTab && activeTab.mode === 'media') {
if (window.localMediaHandler && typeof window.localMediaHandler.ensureDrawingCanvasExists === 'function') {
window.localMediaHandler.ensureDrawingCanvasExists(window.bibleApp.activeTabId);
// Force resize the drawing canvas to cover the entire content area
setTimeout(() => {
if (window.localMediaHandler && typeof window.localMediaHandler.resizeMediaDrawingCanvas === 'function') {
window.localMediaHandler.resizeMediaDrawingCanvas(window.bibleApp.activeTabId);
}
}, 100);
}
}
}
// Apply CSS rule
updateMediaDrawingCSS(true);
console.log('=== DRAWING MODE FORCED ===');
};
// Function to force fix the drawing canvas positioning and sizing
window.fixDrawingCanvas = function() {
console.log('=== FIXING DRAWING CANVAS ===');
const activeTabId = window.bibleApp?.activeTabId;
if (!activeTabId) {
console.log('No active tab found');
return;
}
const activeTab = window.bibleApp.tabs.find(t => t.id === activeTabId);
if (!activeTab || activeTab.mode !== 'media') {
console.log('Active tab is not a media tab');
return;
}
// Ensure drawing canvas exists
if (window.localMediaHandler && typeof window.localMediaHandler.ensureDrawingCanvasExists === 'function') {
window.localMediaHandler.ensureDrawingCanvasExists(activeTabId);
// Force resize the drawing canvas
setTimeout(() => {
if (window.localMediaHandler && typeof window.localMediaHandler.resizeMediaDrawingCanvas === 'function') {
window.localMediaHandler.resizeMediaDrawingCanvas(activeTabId);
}
// Force update canvas styling
const drawingCanvas = document.querySelector('.media-drawing-canvas');
if (drawingCanvas && window.localMediaHandler && window.localMediaHandler.updateCanvasStyling) {
window.localMediaHandler.updateCanvasStyling(drawingCanvas);
}
// Force update media items pointer events
const isDrawingTool = window.currentTool === 'pen' || window.currentTool === 'highlight' || window.currentTool === 'erase';
const isSelectTool = window.currentTool === 'select';
if (window.localMediaHandler && window.localMediaHandler.updateMediaItemsPointerEvents) {
window.localMediaHandler.updateMediaItemsPointerEvents(isDrawingTool, isSelectTool);
}
console.log('Drawing canvas fix applied');
}, 100);
}
console.log('=== DRAWING CANVAS FIX COMPLETE ===');
};
// Debug function to check select tool functionality
window.debugSelectTool = function() {
console.log('=== DEBUG SELECT TOOL ===');
console.log('Current tool:', window.currentTool);
console.log('Select mode active:', window.selectModeActive);
const mediaItems = document.querySelectorAll('.canvas-media-item');
console.log('Media items found:', mediaItems.length);
mediaItems.forEach((item, index) => {
console.log(`Media item ${index}:`, {
pointerEvents: item.style.pointerEvents,
computedPointerEvents: getComputedStyle(item).pointerEvents,
cursor: item.style.cursor,
computedCursor: getComputedStyle(item).cursor,
zIndex: item.style.zIndex,
drawingMode: item.getAttribute('data-drawing-mode'),
hasMouseHandler: !!item._mouseDownHandler,
bounds: item.getBoundingClientRect()
});
// Test if element actually receives mouse events
const rect = item.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
console.log(`Testing mouse events on item ${index}...`);
// Add a temporary test listener
const testHandler = (e) => {
console.log(`? Item ${index} received mousedown event!`, e);
item.removeEventListener('mousedown', testHandler, true);
};
item.addEventListener('mousedown', testHandler, true);
// Dispatch a test event
const testEvent = new MouseEvent('mousedown', {
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
bubbles: true,
cancelable: true
});
const eventResult = item.dispatchEvent(testEvent);
console.log(`Item ${index} event dispatch result:`, eventResult);
// Clean up after 100ms
setTimeout(() => {
item.removeEventListener('mousedown', testHandler, true);
}, 100);
}
});
const drawingCanvas = document.querySelector('.media-drawing-canvas');
if (drawingCanvas) {
console.log('Drawing canvas:', {
pointerEvents: drawingCanvas.style.pointerEvents,
computedPointerEvents: getComputedStyle(drawingCanvas).pointerEvents,
zIndex: drawingCanvas.style.zIndex,
computedZIndex: getComputedStyle(drawingCanvas).zIndex,
display: drawingCanvas.style.display
});
} else {
console.log('No drawing canvas found');
}
console.log('=== DEBUG COMPLETE ===');
};
// Comprehensive diagnostic function to find what's blocking mouse events
window.findMouseBlocker = function() {
console.log('=== FINDING MOUSE BLOCKER ===');
const mediaItems = document.querySelectorAll('.canvas-media-item');
if (mediaItems.length === 0) {
console.log('No media items found');
return;
}
const item = mediaItems[0];
const rect = item.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
console.log('Testing at position:', { x: centerX, y: centerY });
console.log('Media item bounds:', rect);
// Get element at the center point
const elementAtPoint = document.elementFromPoint(centerX, centerY);
console.log('Element at center point:', elementAtPoint);
// Get all elements at that point
const elementsAtPoint = document.elementsFromPoint(centerX, centerY);
console.log('All elements at point:', elementsAtPoint.map(el => ({
tagName: el.tagName,
className: el.className,
id: el.id,
zIndex: getComputedStyle(el).zIndex,
pointerEvents: getComputedStyle(el).pointerEvents,
position: getComputedStyle(el).position
})));
// Check for any canvases that might be interfering
const allCanvases = document.querySelectorAll('canvas');
console.log('All canvases in DOM:', Array.from(allCanvases).map(canvas => ({
element: canvas,
className: canvas.className,
zIndex: getComputedStyle(canvas).zIndex,
pointerEvents: getComputedStyle(canvas).pointerEvents,
bounds: canvas.getBoundingClientRect(),
isOverlapping: isOverlapping(canvas.getBoundingClientRect(), rect)
})));
function isOverlapping(rect1, rect2) {
return !(rect1.right < rect2.left ||
rect1.left > rect2.right ||
rect1.bottom < rect2.top ||
rect1.top > rect2.bottom);
}
console.log('=== BLOCKER ANALYSIS COMPLETE ===');
};
// Test function to force select tool and check media interactions
window.testSelectTool = function() {
console.log('=== TESTING SELECT TOOL ===');
// Force set select tool
window.currentTool = 'select';
window.selectModeActive = true;
// Update tool UI
if (typeof updateToolUI === 'function') {
updateToolUI();
}
// Force update media items
if (window.localMediaHandler && window.localMediaHandler.updateMediaItemsPointerEvents) {
window.localMediaHandler.updateMediaItemsPointerEvents(false, true);
}
// Debug the state
window.debugSelectTool();
};
// Quick test to see if mouse events are working
window.testMouseEvents = function() {
console.log('=== TESTING MOUSE EVENTS ===');
const mediaItems = document.querySelectorAll('.canvas-media-item');
mediaItems.forEach((item, index) => {
console.log(`Testing item ${index}...`);
// Add a test listener
const testHandler = (e) => {
console.log(`? Item ${index} received mousedown!`, e);
console.log('Current tool:', window.currentTool);
console.log('Event target:', e.target);
item.removeEventListener('mousedown', testHandler);
};
item.addEventListener('mousedown', testHandler);
console.log(`Added test listener to item ${index}`);
});
console.log('Click on media items now to test...');
};
// Aggressive fix function for select tool issues
window.forceFixSelectTool = function() {
console.log('=== FORCE FIXING SELECT TOOL ===');
// 1. Force tool state
window.currentTool = 'select';
window.selectModeActive = true;
// 2. Update tool UI aggressively
if (window.updateToolUI) {
window.updateToolUI();
}
// 3. Force fix all media items
const mediaItems = document.querySelectorAll('.canvas-media-item');
console.log('Force fixing', mediaItems.length, 'media items...');
mediaItems.forEach((item, index) => {
console.log(`Force fixing item ${index}...`);
// Remove all blocking attributes
item.removeAttribute('data-drawing-mode');
// Force correct styles with highest priority
item.style.setProperty('pointer-events', 'auto', 'important');
item.style.setProperty('cursor', 'move', 'important');
item.style.setProperty('user-select', 'none', 'important');
item.style.setProperty('z-index', '2', 'important');
// Remove any conflicting event listeners and re-add them
if (item._mouseDownHandler) {
item.removeEventListener('mousedown', item._mouseDownHandler);
}
if (item._mouseMoveHandler) {
item.removeEventListener('mousemove', item._mouseMoveHandler);
}
if (item._mouseUpHandler) {
item.removeEventListener('mouseup', item._mouseUpHandler);
}
// Force re-setup with LocalMediaHandler
if (window.localMediaHandler && window.localMediaHandler.makeMediaItemInteractive) {
console.log(`Re-setting up item ${index} with LocalMediaHandler...`);
window.localMediaHandler.makeMediaItemInteractive(item);
}
});
// 4. Fix drawing canvas to not interfere
const drawingCanvas = document.querySelector('.media-drawing-canvas');
if (drawingCanvas) {
drawingCanvas.style.setProperty('pointer-events', 'none', 'important');
drawingCanvas.style.setProperty('z-index', '1', 'important');
}
// 5. Force refresh media interactions
if (window.localMediaHandler && window.localMediaHandler.refreshMediaInteractions) {
window.localMediaHandler.refreshMediaInteractions();
}
console.log('=== FORCE FIX COMPLETE ===');
// Test the result
setTimeout(() => {
window.debugSelectTool();
}, 200);
console.log('Select tool test complete - try dragging media items now');
};
// Test function to verify layering system
window.testLayering = function() {
console.log('=== TESTING LAYERING SYSTEM ===');
const mediaItems = document.querySelectorAll('.canvas-media-item');
const drawingCanvas = document.querySelector('.media-drawing-canvas');
console.log('Media items found:', mediaItems.length);
console.log('Drawing canvas found:', !!drawingCanvas);
mediaItems.forEach((item, index) => {
const itemStyles = window.getComputedStyle(item);
console.log(`Media item ${index}:`, {
zIndex: itemStyles.zIndex,
pointerEvents: itemStyles.pointerEvents,
cursor: itemStyles.cursor
});
});
if (drawingCanvas) {
const canvasStyles = window.getComputedStyle(drawingCanvas);
console.log('Drawing canvas:', {
zIndex: canvasStyles.zIndex,
pointerEvents: canvasStyles.pointerEvents,
display: canvasStyles.display
});
}
console.log('Current tool:', window.currentTool);
console.log('Select mode active:', window.selectModeActive);
};
// Comprehensive fix for media drawing
window.fixMediaDrawing = function() {
console.log('=== COMPREHENSIVE MEDIA DRAWING FIX ===');
const activeTabId = window.bibleApp?.activeTabId;
if (!activeTabId) {
console.log('No active tab found');
return;
}
const activeTab = window.bibleApp.tabs.find(t => t.id === activeTabId);
if (!activeTab || activeTab.mode !== 'media') {
console.log('Active tab is not a media tab');
return;
}
// Step 1: Ensure drawing canvas exists and is properly positioned
if (window.localMediaHandler && typeof window.localMediaHandler.ensureDrawingCanvasExists === 'function') {
window.localMediaHandler.ensureDrawingCanvasExists(activeTabId);
// Step 2: Force resize and reposition
setTimeout(() => {
if (window.localMediaHandler && typeof window.localMediaHandler.resizeMediaDrawingCanvas === 'function') {
window.localMediaHandler.resizeMediaDrawingCanvas(activeTabId);
}
// Step 3: Force update canvas styling
const drawingCanvas = document.querySelector('.media-drawing-canvas');
if (drawingCanvas && window.localMediaHandler && window.localMediaHandler.updateCanvasStyling) {
window.localMediaHandler.updateCanvasStyling(drawingCanvas);
}
// Step 4: Force update media items pointer events
const isDrawingTool = window.currentTool === 'pen' || window.currentTool === 'highlight' || window.currentTool === 'erase';
const isSelectTool = window.currentTool === 'select';
if (window.localMediaHandler && window.localMediaHandler.updateMediaItemsPointerEvents) {
window.localMediaHandler.updateMediaItemsPointerEvents(isDrawingTool, isSelectTool);
}
// Step 5: Apply CSS rules
updateMediaDrawingCSS(isDrawingTool);
// Step 6: Test the setup
setTimeout(() => {
window.testDrawingCanvasPosition();
}, 200);
console.log('Comprehensive media drawing fix applied');
}, 100);
}
console.log('=== COMPREHENSIVE MEDIA DRAWING FIX COMPLETE ===');
};
// Force disable all media item interactions immediately
window.forceDisableMediaItems = function() {
console.log('=== FORCE DISABLING MEDIA ITEMS ===');
const mediaItems = document.querySelectorAll('.canvas-media-item');
mediaItems.forEach(item => {
// Disable the media item itself
item.style.pointerEvents = 'none';
item.style.setProperty('pointer-events', 'none', 'important');
item.style.cursor = 'default';
item.style.setProperty('cursor', 'default', 'important');
// Disable ALL child elements (images, iframes, etc.)
const childElements = item.querySelectorAll('*');
childElements.forEach(child => {
child.style.pointerEvents = 'none';
child.style.setProperty('pointer-events', 'none', 'important');
});
console.log('Forced disabled media item and all children');
});
// Also apply CSS rule
updateMediaDrawingCSS(true);
console.log('=== MEDIA ITEMS FORCE DISABLED ===');
};
// Test function to verify z-index system
window.testZIndexSystem = function() {
console.log('=== TESTING Z-INDEX SYSTEM ===');
const mediaItems = document.querySelectorAll('.canvas-media-item');
const drawingCanvas = document.querySelector('.media-drawing-canvas');
console.log('Media items found:', mediaItems.length);
mediaItems.forEach((item, index) => {
const itemStyles = window.getComputedStyle(item);
console.log(`Media item ${index}:`, {
zIndex: itemStyles.zIndex,
pointerEvents: itemStyles.pointerEvents,
cursor: itemStyles.cursor
});
});
if (drawingCanvas) {
const canvasStyles = window.getComputedStyle(drawingCanvas);
console.log('Drawing canvas:', {
zIndex: canvasStyles.zIndex,
pointerEvents: canvasStyles.pointerEvents,
cursor: canvasStyles.cursor
});
} else {
console.log('Drawing canvas not found');
}
console.log('Current tool:', window.currentTool);
console.log('Expected z-index values:');
console.log('- Media items: 1 (drawing mode) or 2 (select mode)');
console.log('- Drawing canvas: 10 (always)');
};
// Test function to verify media items are disabled
window.testMediaItemDisable = function() {
console.log('=== TESTING MEDIA ITEM DISABLE ===');
const mediaItems = document.querySelectorAll('.canvas-media-item');
console.log('Media items found:', mediaItems.length);
mediaItems.forEach((item, index) => {
const itemStyles = window.getComputedStyle(item);
const childElements = item.querySelectorAll('*');
console.log(`Media item ${index}:`, {
pointerEvents: itemStyles.pointerEvents,
zIndex: itemStyles.zIndex,
cursor: itemStyles.cursor,
children: childElements.length
});
childElements.forEach((child, childIndex) => {
const childStyles = window.getComputedStyle(child);
console.log(` Child ${childIndex}:`, {
tagName: child.tagName,
pointerEvents: childStyles.pointerEvents,
zIndex: childStyles.zIndex
});
});
});
const drawingCanvas = document.querySelector('.media-drawing-canvas');
if (drawingCanvas) {
const canvasStyles = window.getComputedStyle(drawingCanvas);
console.log('Drawing canvas:', {
pointerEvents: canvasStyles.pointerEvents,
zIndex: canvasStyles.zIndex,
cursor: canvasStyles.cursor
});
} else {
console.log('Drawing canvas not found');
}
};
// Function to test drawing canvas positioning
window.testDrawingCanvasPosition = function() {
console.log('=== TESTING DRAWING CANVAS POSITION ===');
const mediaCanvas = document.getElementById('mediaCanvas');
if (!mediaCanvas) {
console.log('? Media canvas not found');
return;
}
const canvasContent = mediaCanvas.querySelector('.canvas-content');
if (!canvasContent) {
console.log('? Canvas content not found');
return;
}
const drawingCanvas = canvasContent.querySelector('.media-drawing-canvas');
if (!drawingCanvas) {
console.log('? Drawing canvas not found in canvas content');
return;
}
const contentRect = canvasContent.getBoundingClientRect();
const drawingRect = drawingCanvas.getBoundingClientRect();
console.log('? Drawing canvas found in canvas content');
console.log('Canvas content bounds:', contentRect);
console.log('Drawing canvas bounds:', drawingRect);
console.log('Drawing canvas styles:', {
position: drawingCanvas.style.position,
top: drawingCanvas.style.top,
left: drawingCanvas.style.left,
width: drawingCanvas.style.width,
height: drawingCanvas.style.height,
zIndex: drawingCanvas.style.zIndex,
pointerEvents: drawingCanvas.style.pointerEvents
});
// Check if drawing canvas covers the content area
const coversContent = drawingRect.left <= contentRect.left &&
drawingRect.top <= contentRect.top &&
drawingRect.right >= contentRect.right &&
drawingRect.bottom >= contentRect.bottom;
if (coversContent) {
console.log('? Drawing canvas properly covers content area');
} else {
console.log('? Drawing canvas does not properly cover content area');
}
// Check media items
const mediaItems = canvasContent.querySelectorAll('.canvas-media-item');
console.log('Media items found:', mediaItems.length);
mediaItems.forEach((item, index) => {
const itemRect = item.getBoundingClientRect();
console.log(`Media item ${index}:`, {
path: item.dataset.mediaPath,
bounds: itemRect,
styles: {
left: item.style.left, top: item.style.top,
width: item.style.width,
height: item.style.height,
zIndex: item.style.zIndex,
pointerEvents: item.style.pointerEvents
}
});
});
};
// Debug function to trace annotation disappearance in real-time
window.debugAnnotationDisappearance = function() {
console.log('=== DEBUGGING ANNOTATION DISAPPEARANCE ===');
// Hook into critical functions to trace what's happening
const originalUpdateVisibleCanvas = window.updateVisibleCanvas || updateVisibleCanvas;
const originalRedrawAllStrokes = window.redrawAllStrokes || redrawAllStrokes;
const originalEndDrawing = window.endDrawing || endDrawing;
// Override updateVisibleCanvas with debugging
window.updateVisibleCanvas = function(tabId, disableScroll = false) {
const passageKey = getCurrentPassageKey();
const strokeCountBefore = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [TRACE] updateVisibleCanvas called - strokes before:', strokeCountBefore);
const result = originalUpdateVisibleCanvas(tabId, disableScroll);
const strokeCountAfter = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [TRACE] updateVisibleCanvas finished - strokes after:', strokeCountAfter);
if (strokeCountBefore !== strokeCountAfter) {
console.error('?? [TRACE] STROKE COUNT CHANGED IN updateVisibleCanvas!');
}
return result;
};
// Override redrawAllStrokes with debugging
window.redrawAllStrokes = function(tabId) {
const passageKey = getCurrentPassageKey();
const strokeCountBefore = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [TRACE] redrawAllStrokes called - strokes before:', strokeCountBefore);
const result = originalRedrawAllStrokes(tabId);
const strokeCountAfter = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [TRACE] redrawAllStrokes finished - strokes after:', strokeCountAfter);
if (strokeCountBefore !== strokeCountAfter) {
console.error('?? [TRACE] STROKE COUNT CHANGED IN redrawAllStrokes!');
}
return result;
};
console.log('? Debugging hooks installed. Draw an annotation and watch the console!');
console.log('?? Look for ?? [TRACE] messages to see where strokes are being lost.');
return {
hooksInstalled: true,
originalFunctions: {
updateVisibleCanvas: originalUpdateVisibleCanvas,
redrawAllStrokes: originalRedrawAllStrokes
}
};
};
// EMERGENCY PORTRAIT FIX: Run this on the TV in portrait mode
window.emergencyPortraitFix = function() {
console.log('=== EMERGENCY PORTRAIT MODE FIX ===');
const activeTabId = getActiveTabId();
if (!activeTabId) {
console.log('? No active tab');
return;
}
const isPortrait = window.innerHeight > window.innerWidth;
console.log('?? Portrait mode detected:', isPortrait);
if (!isPortrait) {
console.log('?? Not in portrait mode - fix may not be needed');
}
// Force canvas recreation with portrait-optimized settings
const readingArea = document.getElementById(`readingArea${activeTabId}`);
if (!readingArea) {
console.log('? No reading area found');
return;
}
// Preserve existing strokes
const passageKey = getCurrentPassageKey();
const strokeBackup = strokeHistory[passageKey] ? [...strokeHistory[passageKey]] : [];
console.log('?? Preserving', strokeBackup.length, 'existing strokes');
// Remove existing canvases
const existingCanvases = readingArea.querySelectorAll('.annotation-canvas, .annotation-overlay-canvas');
existingCanvases.forEach(c => c.remove());
// Clear canvas references to force recreation
delete annotationCanvases[activeTabId];
delete annotationOffscreen[activeTabId];
delete annotationOverlayCanvases[activeTabId];
console.log('?? Recreating canvases for portrait mode...');
// Force stable dimensions calculation
void readingArea.offsetHeight;
const stableWidth = readingArea.clientWidth;
const stableHeight = readingArea.clientHeight;
console.log('?? Stable dimensions:', stableWidth, 'x', stableHeight);
// Recreate canvas with portrait-optimized settings
getOrCreateAnnotationCanvas(activeTabId);
// Restore stroke data
if (strokeBackup.length > 0) {
strokeHistory[passageKey] = strokeBackup;
console.log('? Restored stroke data');
// Redraw all strokes
setTimeout(() => {
redrawAllStrokes(activeTabId);
console.log('? Redrawn all strokes for portrait mode');
}, 100);
}
// Force update visible canvas
setTimeout(() => {
updateVisibleCanvas(activeTabId);
console.log('? Updated visible canvas');
}, 200);
console.log('?? EMERGENCY PORTRAIT FIX COMPLETE');
console.log('?? Try drawing an annotation now in portrait mode');
return {
isPortrait,
strokesPreserved: strokeBackup.length,
canvasRecreated: true
};
};
// PORTRAIT MODE DEBUG: Check for orientation-specific issues
window.debugPortraitMode = function() {
console.log('=== PORTRAIT MODE DEBUG ===');
// Check orientation
const isPortrait = window.innerHeight > window.innerWidth;
const orientation = screen.orientation?.angle || 0;
console.log('?? Orientation info:');
console.log(' - Window size:', window.innerWidth, 'x', window.innerHeight);
console.log(' - Is portrait:', isPortrait);
console.log(' - Screen orientation angle:', orientation);
console.log(' - Device pixel ratio:', window.devicePixelRatio);
const activeTabId = getActiveTabId();
if (!activeTabId) {
console.log('? No active tab');
return;
}
const readingArea = document.getElementById(`readingArea${activeTabId}`);
const canvas = annotationCanvases[activeTabId];
const offscreen = annotationOffscreen[activeTabId];
if (!readingArea || !canvas || !offscreen) {
console.log('? Missing elements');
return;
}
console.log('?? Canvas dimensions:');
console.log(' - Reading area:', readingArea.clientWidth, 'x', readingArea.clientHeight);
console.log(' - Visible canvas:', canvas.width, 'x', canvas.height, 'CSS:', canvas.style.width, 'x', canvas.style.height);
console.log(' - Offscreen canvas:', offscreen.width, 'x', offscreen.height);
const bibleText = document.getElementById(`bibleText${activeTabId}`);
if (bibleText) {
console.log(' - Content height:', bibleText.scrollHeight);
console.log(' - Content width:', bibleText.scrollWidth);
}
console.log('?? Canvas positioning:');
const canvasRect = canvas.getBoundingClientRect();
console.log(' - Canvas rect:', canvasRect);
console.log(' - Canvas transform:', canvas.style.transform);
console.log(' - Reading area scrollTop:', readingArea.scrollTop);
// Test coordinate calculation
console.log('?? Testing coordinate calculation...');
const testEvent = {
touches: [{
clientX: canvasRect.left + canvasRect.width / 2,
clientY: canvasRect.top + canvasRect.height / 2
}]
};
const coords = getPointerPosRelative(testEvent, canvas, readingArea);
console.log(' - Test touch at canvas center:', coords);
console.log(' - Coordinates valid:', coords.x >= 0 && coords.y >= 0 && coords.x <= canvas.width && coords.y <= canvas.height);
// Check for any CSS issues that might affect portrait mode
const computedStyle = getComputedStyle(readingArea);
console.log('?? Reading area computed styles:');
console.log(' - Position:', computedStyle.position);
console.log(' - Overflow:', computedStyle.overflow);
console.log(' - Touch-action:', computedStyle.touchAction);
console.log(' - Transform:', computedStyle.transform);
return {
isPortrait,
orientation,
dimensions: {
window: { width: window.innerWidth, height: window.innerHeight },
readingArea: { width: readingArea.clientWidth, height: readingArea.clientHeight },
canvas: { width: canvas.width, height: canvas.height },
offscreen: { width: offscreen.width, height: offscreen.height }
},
testCoords: coords
};
};
// NUCLEAR DEBUG: Comprehensive tracing for annotation disappearance
window.traceAnnotationLifecycle = function() {
console.log('=== NUCLEAR DEBUG: TRACING ANNOTATION LIFECYCLE ===');
// Clear any existing hooks
if (window._originalFunctions) {
Object.keys(window._originalFunctions).forEach(key => {
window[key] = window._originalFunctions[key];
});
}
// Store original functions
window._originalFunctions = {
addStroke: window.addStroke || addStroke,
endDrawing: window.endDrawing || endDrawing,
updateVisibleCanvas: window.updateVisibleCanvas || updateVisibleCanvas,
redrawAllStrokes: window.redrawAllStrokes || redrawAllStrokes,
loadAnnotations: window.loadAnnotations || loadAnnotations,
saveAnnotations: window.saveAnnotations || saveAnnotations,
setupAnnotationForActiveTab: window.setupAnnotationForActiveTab || setupAnnotationForActiveTab
};
// Hook addStroke
window.addStroke = function(passageKey, strokeData) {
console.log('?? [LIFECYCLE] addStroke called:', passageKey, strokeData.type, strokeData.points?.length, 'points');
const result = window._originalFunctions.addStroke(passageKey, strokeData);
const currentCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [LIFECYCLE] addStroke finished - total strokes now:', currentCount);
return result;
};
// Hook endDrawing
window.endDrawing = function(tabId) {
console.log('?? [LIFECYCLE] endDrawing called for tab:', tabId);
const passageKey = getCurrentPassageKey();
const beforeCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [LIFECYCLE] Strokes before endDrawing:', beforeCount);
const result = window._originalFunctions.endDrawing(tabId);
const afterCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [LIFECYCLE] Strokes after endDrawing:', afterCount);
if (afterCount !== beforeCount) {
console.log('?? [LIFECYCLE] Stroke count changed in endDrawing:', beforeCount, '->', afterCount);
}
return result;
};
// Hook updateVisibleCanvas
window.updateVisibleCanvas = function(tabId, disableScroll = false) {
console.log('?? [LIFECYCLE] updateVisibleCanvas called for tab:', tabId);
const passageKey = getCurrentPassageKey();
const beforeCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [LIFECYCLE] Strokes before updateVisibleCanvas:', beforeCount);
const result = window._originalFunctions.updateVisibleCanvas(tabId, disableScroll);
const afterCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [LIFECYCLE] Strokes after updateVisibleCanvas:', afterCount);
if (afterCount !== beforeCount) {
console.error('?? [LIFECYCLE] STROKE COUNT CHANGED IN updateVisibleCanvas!', beforeCount, '->', afterCount);
}
return result;
};
// Hook redrawAllStrokes
window.redrawAllStrokes = function(tabId) {
console.log('?? [LIFECYCLE] redrawAllStrokes called for tab:', tabId);
const passageKey = getCurrentPassageKey();
const beforeCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [LIFECYCLE] Strokes before redrawAllStrokes:', beforeCount);
const result = window._originalFunctions.redrawAllStrokes(tabId);
const afterCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [LIFECYCLE] Strokes after redrawAllStrokes:', afterCount);
if (afterCount !== beforeCount) {
console.error('?? [LIFECYCLE] STROKE COUNT CHANGED IN redrawAllStrokes!', beforeCount, '->', afterCount);
}
return result;
};
// Hook setupAnnotationForActiveTab
window.setupAnnotationForActiveTab = function() {
console.log('?? [LIFECYCLE] setupAnnotationForActiveTab called');
const passageKey = getCurrentPassageKey();
const beforeCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [LIFECYCLE] Strokes before setupAnnotationForActiveTab:', beforeCount);
const result = window._originalFunctions.setupAnnotationForActiveTab();
const afterCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [LIFECYCLE] Strokes after setupAnnotationForActiveTab:', afterCount);
if (afterCount !== beforeCount) {
console.error('?? [LIFECYCLE] STROKE COUNT CHANGED IN setupAnnotationForActiveTab!', beforeCount, '->', afterCount);
}
return result;
};
// Hook loadPassage to see if it's being called unexpectedly
if (window.bibleApp && window.bibleApp.loadPassage) {
const originalLoadPassage = window.bibleApp.loadPassage.bind(window.bibleApp);
window.bibleApp.loadPassage = function(tabId) {
console.log('?? [LIFECYCLE] loadPassage called for tab:', tabId);
const passageKey = getCurrentPassageKey();
const beforeCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [LIFECYCLE] Strokes before loadPassage:', beforeCount);
const result = originalLoadPassage(tabId);
const afterCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [LIFECYCLE] Strokes after loadPassage:', afterCount);
if (afterCount !== beforeCount) {
console.error('?? [LIFECYCLE] STROKE COUNT CHANGED IN loadPassage!', beforeCount, '->', afterCount);
console.error('?? [LIFECYCLE] This is likely the culprit - loadPassage clears canvases!');
}
return result;
};
}
console.log('?? NUCLEAR DEBUG ENABLED - All annotation functions are now traced');
console.log('?? Draw an annotation in Psalms 119 and watch for the exact moment strokes disappear');
console.log('?? Look for ?? [LIFECYCLE] messages to identify the culprit function');
return { tracingEnabled: true };
};
// Emergency debug function specifically for Psalms 119 finger lift issue
window.debugPsalms119FingerLift = function() {
console.log('=== DEBUGGING PSALMS 119 FINGER LIFT ISSUE ===');
const activeTabId = getActiveTabId();
const tab = window.bibleApp?.tabs?.find(t => t.id === activeTabId);
if (!tab || tab.book !== 'Psalms' || tab.chapter !== 119) {
console.log('? Not currently viewing Psalms 119. Please navigate to Psalms 119 first.');
return;
}
console.log('? Currently viewing Psalms 119');
// Check current state
const passageKey = getCurrentPassageKey();
const strokeCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('Current passage key:', passageKey);
console.log('Current stroke count:', strokeCount);
// Override endDrawing to add extensive debugging
const originalEndDrawing = window.endDrawing || endDrawing;
window.endDrawing = function(tabId) {
console.log('?? [FINGER LIFT DEBUG] endDrawing called for tabId:', tabId);
console.log('?? [FINGER LIFT DEBUG] Current stroke points:', currentStrokePoints.length);
const passageKey = getCurrentPassageKey();
const strokeCountBefore = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [FINGER LIFT DEBUG] Stroke count before endDrawing:', strokeCountBefore);
// Call original function
const result = originalEndDrawing(tabId);
const strokeCountAfter = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [FINGER LIFT DEBUG] Stroke count after endDrawing:', strokeCountAfter);
// Check if stroke was added
if (strokeCountAfter > strokeCountBefore) {
console.log('? [FINGER LIFT DEBUG] Stroke successfully added!');
// Wait a moment and check if it's still there
setTimeout(() => {
const strokeCountDelayed = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [FINGER LIFT DEBUG] Stroke count after 500ms delay:', strokeCountDelayed);
if (strokeCountDelayed < strokeCountAfter) {
console.error('?? [FINGER LIFT DEBUG] STROKE DISAPPEARED AFTER DELAY!');
console.error('?? [FINGER LIFT DEBUG] This indicates a delayed operation is clearing strokes');
} else {
console.log('? [FINGER LIFT DEBUG] Stroke persisted after delay');
}
}, 500);
// Check again after longer delay
setTimeout(() => {
const strokeCountLonger = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? [FINGER LIFT DEBUG] Stroke count after 2s delay:', strokeCountLonger);
if (strokeCountLonger < strokeCountAfter) {
console.error('?? [FINGER LIFT DEBUG] STROKE DISAPPEARED AFTER LONGER DELAY!');
} else {
console.log('? [FINGER LIFT DEBUG] Stroke fully persisted');
}
}, 2000);
} else {
console.error('?? [FINGER LIFT DEBUG] NO STROKE WAS ADDED!');
}
return result;
};
console.log('? Finger lift debugging enabled for Psalms 119');
console.log('?? Now draw an annotation and watch the console for detailed trace');
return { debuggingEnabled: true, currentStrokeCount: strokeCount };
};
// Simple test to check Psalms 119 passage key generation
window.checkPsalms119PassageKey = function() {
console.log('=== CHECKING PSALMS 119 PASSAGE KEY ===');
const activeTabId = getActiveTabId();
const tab = window.bibleApp?.tabs?.find(t => t.id === activeTabId);
if (!tab) {
console.log('? No active tab found');
return;
}
console.log('?? Current tab info:');
console.log(' - Book:', tab.book);
console.log(' - Chapter:', tab.chapter);
console.log(' - Verse:', tab.verse);
console.log(' - VerseEnd:', tab.verseEnd);
console.log(' - Translation:', tab.translation);
console.log(' - Mode:', tab.mode);
const passageKey = getCurrentPassageKey();
console.log('?? Generated passage key:', passageKey);
// Check if stroke data exists for this key
const strokeData = strokeHistory[passageKey];
const localStorageData = localStorage.getItem(passageKey + '-strokes');
console.log('?? Storage status:');
console.log(' - In memory strokes:', strokeData ? strokeData.length : 0);
console.log(' - In localStorage:', localStorageData ? JSON.parse(localStorageData).length : 0);
if (localStorageData) {
console.log(' - localStorage key exists:', passageKey + '-strokes');
} else {
console.log(' - No localStorage data found for key:', passageKey + '-strokes');
}
// Test stroke addition
console.log('?? Testing stroke addition...');
const testStroke = {
type: 'pen',
color: '#ff0000',
size: 4,
points: [{x: 100, y: 100}, {x: 150, y: 150}],
timestamp: Date.now(),
inputType: 'test'
};
if (!strokeHistory[passageKey]) {
strokeHistory[passageKey] = [];
}
const beforeTest = strokeHistory[passageKey].length;
strokeHistory[passageKey].push(testStroke);
const afterTest = strokeHistory[passageKey].length;
console.log(' - Before test stroke:', beforeTest);
console.log(' - After test stroke:', afterTest);
console.log(' - Test successful:', afterTest > beforeTest ? '?' : '?');
// Remove test stroke
strokeHistory[passageKey].pop();
return {
tabInfo: { book: tab.book, chapter: tab.chapter, verse: tab.verse, verseEnd: tab.verseEnd, translation: tab.translation },
passageKey,
memoryStrokes: strokeData ? strokeData.length : 0,
localStorageStrokes: localStorageData ? JSON.parse(localStorageData).length : 0,
testSuccessful: afterTest > beforeTest
};
};
// Test function to verify the Psalms 119 annotation fix
window.testPsalms119AnnotationFix = function() {
console.log('=== TESTING PSALMS 119 ANNOTATION FIX ===');
const activeTabId = getActiveTabId();
if (!activeTabId) {
console.log('? No active tab found');
return;
}
const tab = window.bibleApp?.tabs?.find(t => t.id === activeTabId);
if (!tab) {
console.log('? No tab data found');
return;
}
console.log('? Current passage:', tab.book, tab.chapter, 'verses:', tab.verse, '-', tab.verseEnd);
// Check canvas setup
const offscreen = annotationOffscreen[activeTabId];
const visible = annotationCanvases[activeTabId];
if (!offscreen || !visible) {
console.log('? Annotation canvases not found');
return;
}
const readingArea = document.getElementById(`readingArea${activeTabId}`);
const bibleText = document.getElementById(`bibleText${activeTabId}`);
if (!readingArea || !bibleText) {
console.log('? Reading area or bible text not found');
return;
}
const contentHeight = bibleText.scrollHeight;
const canvasHeight = offscreen.height / (window.devicePixelRatio || 1);
console.log('?? Content dimensions:');
console.log(' - Content height:', contentHeight, 'px');
console.log(' - Canvas height:', canvasHeight, 'px');
console.log(' - Ratio:', (contentHeight / canvasHeight).toFixed(2));
console.log(' - Threshold for recreation: 1.2x =', (canvasHeight * 1.2).toFixed(0), 'px');
// Check if canvas would be recreated
const wouldRecreate = contentHeight > canvasHeight * 1.2;
console.log('?? Canvas recreation analysis:');
console.log(' - Would trigger recreation:', wouldRecreate ? '?? YES' : '? NO');
if (wouldRecreate) {
console.log(' - ?? This content is large enough to trigger canvas recreation');
console.log(' - ? But annotations will now be preserved during recreation');
} else {
console.log(' - ? Content fits within current canvas allocation');
}
// Check stroke data
const passageKey = getCurrentPassageKey();
const strokeCount = strokeHistory[passageKey] ? strokeHistory[passageKey].length : 0;
console.log('?? Annotation status:');
console.log(' - Passage key:', passageKey);
console.log(' - Strokes in memory:', strokeCount);
console.log(' - Drawing state:', drawing ? '?? ACTIVE' : '?? INACTIVE');
if (strokeCount > 0) {
console.log(' - ? Annotations exist and should be preserved');
} else {
console.log(' - ?? No annotations yet - draw something to test the fix');
}
console.log('');
console.log('?? TEST INSTRUCTIONS:');
console.log('1. Draw some annotations on this passage');
console.log('2. Scroll around to trigger canvas updates');
console.log('3. Draw more annotations');
console.log('4. Annotations should persist throughout the session');
console.log('');
console.log('? ANNOTATION PRESERVATION FIX ACTIVE!');
console.log('=======================================');
return {
tabId: activeTabId,
passage: `${tab.book} ${tab.chapter}`,
contentHeight,
canvasHeight,
wouldRecreate,
strokeCount,
isDrawing: drawing,
fixActive: true
};
};
// Debug function to test select tool specifically
window.debugSelectTool = function() {
console.log('=== SELECT TOOL DEBUG ===');
console.log('Current tool:', window.currentTool);
console.log('Select mode active:', window.selectModeActive);
const mediaItems = document.querySelectorAll('.canvas-media-item');
const drawingCanvas = document.querySelector('.media-drawing-canvas');
console.log('Media items found:', mediaItems.length);
mediaItems.forEach((item, index) => {
const computedStyle = window.getComputedStyle(item);
console.log(`Media Item ${index + 1}:`, {
zIndex: computedStyle.zIndex,
pointerEvents: computedStyle.pointerEvents,
cursor: computedStyle.cursor,
display: computedStyle.display
});
});
if (drawingCanvas) {
const computedStyle = window.getComputedStyle(drawingCanvas);
console.log('Drawing Canvas:', {
zIndex: computedStyle.zIndex,
pointerEvents: computedStyle.pointerEvents,
cursor: computedStyle.cursor,
display: computedStyle.display
});
}
// Test if select tool is properly set
if (window.currentTool !== 'select') {
console.log('?? Select tool not active. Current tool:', window.currentTool);
} else {
console.log('? Select tool is active');
}
// Test if media items should be interactive
const shouldBeInteractive = window.currentTool === 'select' && !window.selectModeActive;
console.log('Media items should be interactive:', shouldBeInteractive);
};